@skyramp/mcp 0.3.2 → 0.3.3
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/build/index.js +47 -2
- package/build/playwright/PlaywrightTraceService.d.ts +8 -0
- package/build/playwright/PlaywrightTraceService.js +1 -0
- package/build/prompts/pom-aware-code-reuse.js +17 -8
- package/build/prompts/testbot/testbot-prompts.d.ts +2 -12
- package/build/prompts/testbot/testbot-prompts.js +24 -5
- package/build/resources/testbotResource.js +21 -4
- package/build/services/TestExecutionService.d.ts +13 -8
- package/build/services/TestExecutionService.js +48 -25
- package/build/services/containerEnv.js +24 -0
- package/build/tools/queryProxyMocksTool.js +0 -1
- package/build/tools/submitReportTool.js +10 -1
- package/build/tools/test-management/actionsTool.js +12 -2
- package/build/types/TestAnalysis.d.ts +7 -3
- package/build/types/TestbotPromptOptions.d.ts +35 -0
- package/build/types/TestbotPromptOptions.js +1 -0
- package/build/types/index.d.ts +1 -0
- package/build/utils/frontendSelectors.js +0 -1
- package/build/utils/gitStaging.d.ts +5 -0
- package/build/utils/gitStaging.js +1 -1
- package/build/utils/pom-catalog.d.ts +23 -0
- package/build/utils/pom-catalog.js +30 -0
- package/build/utils/pom-scope/pom-files.d.ts +14 -0
- package/build/utils/pom-scope/pom-files.js +32 -6
- package/build/utils/pom-scope/testIdDiscovery.d.ts +40 -0
- package/build/utils/pom-scope/testIdDiscovery.js +104 -0
- package/build/utils/reportVerification.d.ts +7 -2
- package/build/utils/reportVerification.js +9 -3
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/node_modules/playwright/ThirdPartyNotices.txt +319 -266
- package/node_modules/playwright/lib/dom-analyzer/blueprint.js +154 -27
- package/node_modules/playwright/lib/dom-analyzer/crawler.js +2 -2
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +6 -2
- package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +3 -2
- package/node_modules/playwright/lib/mcp/skyramp/resultCode.js +4 -3
- package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +15 -3
- package/node_modules/playwright/lib/mcp/skyramp/specImport.js +781 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +14 -3
- package/node_modules/playwright/lib/mcp/test/resultCode.test.js +2 -1
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +1 -1
- package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +30 -0
- package/node_modules/playwright/lib/transform/babelBundleImpl.js +200 -199
- package/node_modules/playwright/node_modules/playwright-core/ThirdPartyNotices.txt +3 -3
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/.package-lock.json +3 -3
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/adapter/aws-lambda/handler.js +16 -25
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/adapter/bun/websocket.js +3 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/adapter/lambda-edge/handler.js +20 -4
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/adapter/aws-lambda/handler.js +16 -25
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/adapter/bun/websocket.js +3 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/adapter/lambda-edge/handler.js +20 -4
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/client/client.js +10 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/client/utils.js +1 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/helper/css/common.js +3 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/helper/css/index.js +9 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/jsx/base.js +8 -14
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/jsx/components.js +41 -21
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/jsx/context.js +131 -5
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/jsx/streaming.js +9 -7
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/bearer-auth/index.js +1 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/cache/index.js +1 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/compress/index.js +2 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/cors/index.js +2 -5
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/etag/index.js +2 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/language/language.js +10 -32
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/method-override/index.js +5 -3
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/serve-static/index.js +2 -2
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/timing/timing.js +3 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/router/trie-router/node.js +9 -0
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/utils/body.js +12 -4
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/utils/buffer.js +2 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/utils/ipaddr.js +6 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/validator/validator.js +3 -3
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/client/client.js +10 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/client/utils.js +1 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/helper/css/common.js +3 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/helper/css/index.js +9 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/jsx/base.js +15 -15
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/jsx/components.js +42 -22
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/jsx/context.js +129 -5
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/jsx/streaming.js +10 -8
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/bearer-auth/index.js +1 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/cache/index.js +1 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/compress/index.js +2 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/cors/index.js +2 -5
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/etag/index.js +2 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/language/language.js +10 -32
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/method-override/index.js +5 -3
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/serve-static/index.js +2 -2
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/timing/timing.js +3 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/router/trie-router/node.js +9 -0
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/adapter/aws-lambda/handler.d.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/adapter/lambda-edge/handler.d.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/helper/websocket/index.d.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/jsx/base.d.ts +1 -3
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/jsx/context.d.ts +39 -0
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/middleware/context-storage/index.d.ts +2 -2
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/middleware/language/language.d.ts +18 -0
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/utils/body.d.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/utils/types.d.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/utils/body.js +12 -4
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/utils/buffer.js +2 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/utils/ipaddr.js +6 -1
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/validator/validator.js +3 -3
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/package.json +29 -22
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/package-lock.json +3 -3
- package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +5 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/specReader.js +781 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderApp.js +25 -6
- package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/skyramp/replayEngine.js +3 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/utils.js +2 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/htmlReport/index.html +253 -27
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/{codeMirrorModule-D0BjbCb7.js → codeMirrorModule-DtudTj_v.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BpDwp16L.js +422 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/index.html +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-Bzd72-bG.js → codeMirrorModule-FNMuBzX1.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Co9upU5h.js +1035 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.DXNIQ_dx.js +2 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CIKB3XSv.js +5 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +4 -0
- package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/specReader.ts +1028 -0
- package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderApp.ts +31 -8
- package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/skyramp/replayEngine.ts +1 -0
- package/node_modules/playwright/node_modules/playwright-core/src/utils.ts +1 -0
- package/node_modules/playwright/package.json +1 -1
- package/package.json +9 -6
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/tsconfig.build.tsbuildinfo +0 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-lvTRGFx-.js +0 -193
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-DzxTioTK.js +0 -809
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.BGc30U3S.js +0 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.IaDrb29A.js +0 -5
|
@@ -0,0 +1,1028 @@
|
|
|
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
|
+
|
|
17
|
+
import path from 'path';
|
|
18
|
+
|
|
19
|
+
import { locatorOrSelectorAsSelector } from '../../../utils/isomorphic/locatorParser';
|
|
20
|
+
|
|
21
|
+
import type * as actions from '@recorder/actions';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Spec reader (zip channel / manual recorder)
|
|
25
|
+
//
|
|
26
|
+
// This is the playwright-core counterpart to the MCP package's specImport.ts
|
|
27
|
+
// (packages/playwright/src/mcp/skyramp/specImport.ts). The npm channel cannot
|
|
28
|
+
// import modded core sources and core cannot depend on the playwright package,
|
|
29
|
+
// so the parser is mirrored here alongside the JsonlReader duplication. Keep
|
|
30
|
+
// the two files in sync; see project_jsonl_reader_writer_duplication for the
|
|
31
|
+
// planned shared refactor.
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Spec import: parse a GENERATED Skyramp test file (.spec.ts / .ts / .js /
|
|
36
|
+
// .py) into the same ActionInContext[] that parseJsonl produces, so
|
|
37
|
+
// skyramp_load_trace can replay a test file when the original trace zip/JSONL
|
|
38
|
+
// was not kept.
|
|
39
|
+
//
|
|
40
|
+
// A generated spec is lossy + enriched relative to the trace it came from
|
|
41
|
+
// (codegen adds waitForResponse/getValue/hydration/scaffolding and drops
|
|
42
|
+
// pageGuid/timestamps/signals), so this recovers the REPLAYABLE INTERACTION
|
|
43
|
+
// SKELETON, not a byte-faithful re-record. Every statement is either parsed
|
|
44
|
+
// into an action, consumed as a binding, dropped as known scaffolding, or
|
|
45
|
+
// reported in `diagnostics` — never silently skipped.
|
|
46
|
+
//
|
|
47
|
+
// Locator source → selector conversion is delegated to the upstream
|
|
48
|
+
// isomorphic locatorParser (the "Pick locator" engine), which understands the
|
|
49
|
+
// full locator grammar in both javascript and python. Only the statement-level
|
|
50
|
+
// vocabulary (a finite set emitted by the Go codegen, see
|
|
51
|
+
// fixes-contexts/loadtrace-from-spec-plan.md) is parsed here.
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
const TEST_ID_ATTRIBUTE = 'data-testid';
|
|
55
|
+
|
|
56
|
+
export type SpecLanguage = 'javascript' | 'python';
|
|
57
|
+
|
|
58
|
+
export type SpecDiagnostic = {
|
|
59
|
+
/** 1-based line number of the statement's first line in the spec file. */
|
|
60
|
+
line: number;
|
|
61
|
+
/** The statement text (truncated for display). */
|
|
62
|
+
text: string;
|
|
63
|
+
reason: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type SpecImportResult = {
|
|
67
|
+
actions: actions.ActionInContext[];
|
|
68
|
+
diagnostics: SpecDiagnostic[];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Infer the spec language from a file path (.py → python, .ts/.js → javascript). */
|
|
72
|
+
export function specLanguageForPath(specPath: string): SpecLanguage | undefined {
|
|
73
|
+
if (specPath.endsWith('.py'))
|
|
74
|
+
return 'python';
|
|
75
|
+
if (specPath.endsWith('.ts') || specPath.endsWith('.js') || specPath.endsWith('.mjs') || specPath.endsWith('.cjs'))
|
|
76
|
+
return 'javascript';
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse a generated test file into replayable actions.
|
|
82
|
+
* `specDir` resolves relative upload paths (`path.join(__dirname, ...)` /
|
|
83
|
+
* `os.path.join(os.path.dirname(__file__), ...)`).
|
|
84
|
+
*/
|
|
85
|
+
export function specToActions(content: string, language: SpecLanguage, specDir?: string): SpecImportResult {
|
|
86
|
+
const dir = specDir ?? '.';
|
|
87
|
+
return language === 'python'
|
|
88
|
+
? new PySpecParser(content, dir).parse()
|
|
89
|
+
: new JsSpecParser(content, dir).parse();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Statement scanning
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
type Statement = { text: string, line: number, indent: number };
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Join physical lines into logical statements by tracking bracket depth
|
|
100
|
+
* outside string literals (generated specs span statements across lines only
|
|
101
|
+
* inside (), {} or [] — e.g. multi-line playwrightRequestN object literals).
|
|
102
|
+
* `indent` is the first physical line's leading whitespace width (used by the
|
|
103
|
+
* Python parser to skip non-test function bodies).
|
|
104
|
+
*/
|
|
105
|
+
function scanStatements(content: string, commentPrefix: string): Statement[] {
|
|
106
|
+
const statements: Statement[] = [];
|
|
107
|
+
let buffer = '';
|
|
108
|
+
let startLine = 0;
|
|
109
|
+
let indent = 0;
|
|
110
|
+
let depth = 0;
|
|
111
|
+
|
|
112
|
+
const lines = content.split('\n');
|
|
113
|
+
for (let i = 0; i < lines.length; i++) {
|
|
114
|
+
const raw = lines[i].trim();
|
|
115
|
+
if (!buffer) {
|
|
116
|
+
if (!raw || raw.startsWith(commentPrefix))
|
|
117
|
+
continue;
|
|
118
|
+
startLine = i + 1;
|
|
119
|
+
indent = lines[i].length - lines[i].trimStart().length;
|
|
120
|
+
}
|
|
121
|
+
buffer = buffer ? `${buffer} ${raw}` : raw;
|
|
122
|
+
depth = bracketDepth(buffer);
|
|
123
|
+
// The JS test-function opener (`test('testUi', async ({ page }) => {`)
|
|
124
|
+
// opens a brace that only closes at the very end of the file — flush it as
|
|
125
|
+
// its own (scaffolding) statement instead of swallowing the whole body.
|
|
126
|
+
// Same for the matching closers (`});`), whose depth goes negative.
|
|
127
|
+
if (depth > 0 && !/^test\(/.test(buffer))
|
|
128
|
+
continue;
|
|
129
|
+
statements.push({ text: buffer, line: startLine, indent });
|
|
130
|
+
buffer = '';
|
|
131
|
+
}
|
|
132
|
+
if (buffer)
|
|
133
|
+
statements.push({ text: buffer, line: startLine, indent });
|
|
134
|
+
return statements;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Net bracket depth of `text`, ignoring brackets inside string literals. */
|
|
138
|
+
function bracketDepth(text: string): number {
|
|
139
|
+
let depth = 0;
|
|
140
|
+
let quote: string | undefined;
|
|
141
|
+
for (let i = 0; i < text.length; i++) {
|
|
142
|
+
const c = text[i];
|
|
143
|
+
if (quote) {
|
|
144
|
+
if (c === '\\')
|
|
145
|
+
i++;
|
|
146
|
+
else if (c === quote)
|
|
147
|
+
quote = undefined;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (c === '\'' || c === '"' || c === '`')
|
|
151
|
+
quote = c;
|
|
152
|
+
else if (c === '(' || c === '{' || c === '[')
|
|
153
|
+
depth++;
|
|
154
|
+
else if (c === ')' || c === '}' || c === ']')
|
|
155
|
+
depth--;
|
|
156
|
+
}
|
|
157
|
+
return depth;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Keyboard modifier names → the recorder's bitmask (skyRampExport
|
|
161
|
+
// modifiersArrayToMask: Alt=1, Control=2, Meta=4, Shift=8; ControlOrMeta=6).
|
|
162
|
+
const MODIFIER_MASKS: Record<string, number> = {
|
|
163
|
+
'Alt': 1, 'Control': 2, 'Meta': 4, 'Shift': 8, 'ControlOrMeta': 6,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Shared parser state + emission (language-specific statement matching lives
|
|
168
|
+
// in the subclasses)
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
abstract class SpecParserBase {
|
|
172
|
+
protected _statements: Statement[];
|
|
173
|
+
protected _specDir: string;
|
|
174
|
+
protected _actions: actions.ActionInContext[] = [];
|
|
175
|
+
protected _diagnostics: SpecDiagnostic[] = [];
|
|
176
|
+
// const playwrightRequestN / expectedPayloadN = {...} — data bindings whose
|
|
177
|
+
// values are inlined where getValue() references them.
|
|
178
|
+
protected _bindings = new Map<string, any>();
|
|
179
|
+
// Variables that hold pages (test fixture `page` + pageN/popupPage
|
|
180
|
+
// declarations). Value is the pageAlias used in the emitted actions.
|
|
181
|
+
protected _pageAliases = new Map<string, string>([['page', 'page']]);
|
|
182
|
+
// Variables consumed by the waitForResponse / fileChooser patterns.
|
|
183
|
+
protected _promiseVars = new Set<string>();
|
|
184
|
+
// A pending expect-filechooser: the next click is the chooser trigger and is
|
|
185
|
+
// folded into the fileChooser action (replay performs the trigger click
|
|
186
|
+
// itself — see loadTraceTool performClientAction).
|
|
187
|
+
protected _pendingFileChooser = false;
|
|
188
|
+
protected _heldChooserTrigger: { selector: string, framePath: string[], pageAlias: string } | undefined;
|
|
189
|
+
|
|
190
|
+
/** locatorParser language + the pattern that splits iframe frame hops. */
|
|
191
|
+
protected abstract readonly _locatorLanguage: 'javascript' | 'python';
|
|
192
|
+
protected abstract readonly _frameHopSplit: RegExp;
|
|
193
|
+
|
|
194
|
+
constructor(statements: Statement[], specDir: string) {
|
|
195
|
+
this._statements = statements;
|
|
196
|
+
this._specDir = specDir;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
parse(): SpecImportResult {
|
|
200
|
+
for (const stmt of this._statements)
|
|
201
|
+
this._parseStatement(stmt);
|
|
202
|
+
if (this._pendingFileChooser)
|
|
203
|
+
this._diagnostics.push({ line: 0, text: 'expect filechooser', reason: 'file chooser was opened but no setFiles/set_files followed' });
|
|
204
|
+
return { actions: this._actions, diagnostics: this._diagnostics };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
protected abstract _parseStatement(stmt: Statement): void;
|
|
208
|
+
|
|
209
|
+
protected _diag(stmt: Statement, reason: string) {
|
|
210
|
+
this._diagnostics.push({ line: stmt.line, text: stmt.text.length > 120 ? stmt.text.slice(0, 117) + '...' : stmt.text, reason });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
protected _push(pageAlias: string, framePath: string[], action: Record<string, any>) {
|
|
214
|
+
const index = this._actions.length;
|
|
215
|
+
this._actions.push({
|
|
216
|
+
frame: { pageGuid: '', pageAlias, framePath },
|
|
217
|
+
action: { signals: [], timestamp: String(index + 1), ...action } as unknown as actions.Action,
|
|
218
|
+
startTime: index + 1,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Split a locator chain into iframe frame hops + leaf, converting each part
|
|
224
|
+
* to a selector via the upstream locator parser. Generated chains pierce
|
|
225
|
+
* frames only via contentFrame()/content_frame:
|
|
226
|
+
* locator("iframe[title=\"x\"]").contentFrame().getByRole(...) →
|
|
227
|
+
* framePath ['iframe[title="x"]'], leaf getByRole(...)
|
|
228
|
+
*/
|
|
229
|
+
protected _resolveLocatorChain(chain: string): { selector: string, framePath: string[] } | undefined {
|
|
230
|
+
const segments = chain.split(this._frameHopSplit);
|
|
231
|
+
const framePath: string[] = [];
|
|
232
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
233
|
+
const hop = locatorOrSelectorAsSelector(this._locatorLanguage, segments[i], TEST_ID_ATTRIBUTE);
|
|
234
|
+
if (!hop)
|
|
235
|
+
return undefined;
|
|
236
|
+
framePath.push(hop);
|
|
237
|
+
}
|
|
238
|
+
const leafSource = segments[segments.length - 1];
|
|
239
|
+
if (!leafSource)
|
|
240
|
+
return undefined;
|
|
241
|
+
const selector = locatorOrSelectorAsSelector(this._locatorLanguage, leafSource, TEST_ID_ATTRIBUTE);
|
|
242
|
+
if (!selector)
|
|
243
|
+
return undefined;
|
|
244
|
+
return { selector, framePath };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Emit the action for a parsed `<locator chain>.<method>(<args>)` statement. */
|
|
248
|
+
protected _emitLocatorAction(stmt: Statement, pageAlias: string, chain: string, method: string, args: string) {
|
|
249
|
+
const resolved = this._resolveLocatorChain(chain);
|
|
250
|
+
if (!resolved) {
|
|
251
|
+
this._diag(stmt, 'could not parse the locator chain — not replayed');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const { selector, framePath } = resolved;
|
|
255
|
+
|
|
256
|
+
// A pending filechooser marks this click as the chooser trigger; it is
|
|
257
|
+
// folded into the upcoming fileChooser action instead of emitted.
|
|
258
|
+
if (method === 'click' && this._pendingFileChooser) {
|
|
259
|
+
this._pendingFileChooser = false;
|
|
260
|
+
this._heldChooserTrigger = { selector, framePath, pageAlias };
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
switch (method) {
|
|
265
|
+
case 'click':
|
|
266
|
+
case 'dblclick': {
|
|
267
|
+
const opts = this._parseCallOptions(args);
|
|
268
|
+
const action: Record<string, any> = { name: 'click', selector, button: opts.button ?? 'left', modifiers: opts.modifiers ?? 0, clickCount: method === 'dblclick' ? 2 : (opts.clickCount ?? 1) };
|
|
269
|
+
if (opts.position)
|
|
270
|
+
action.position = opts.position;
|
|
271
|
+
this._push(pageAlias, framePath, action);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
case 'hover': {
|
|
275
|
+
const opts = this._parseCallOptions(args);
|
|
276
|
+
const action: Record<string, any> = { name: 'hover', selector };
|
|
277
|
+
if (opts.position)
|
|
278
|
+
action.position = opts.position;
|
|
279
|
+
this._push(pageAlias, framePath, action);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
case 'fill':
|
|
283
|
+
case 'pressSequentially': {
|
|
284
|
+
const text = this._resolveStringArg(args);
|
|
285
|
+
if (text === undefined) {
|
|
286
|
+
this._diag(stmt, `could not resolve the ${method}() argument — not replayed`);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
this._push(pageAlias, framePath, { name: method, selector, text });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
case 'press': {
|
|
293
|
+
const shortcut = this._resolveStringArg(args);
|
|
294
|
+
if (shortcut === undefined) {
|
|
295
|
+
this._diag(stmt, 'could not resolve the press() argument — not replayed');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
// "Shift+Enter" → key Enter + modifier mask, mirroring the JSONL shape.
|
|
299
|
+
const parts = shortcut.split('+');
|
|
300
|
+
const key = parts.pop()!;
|
|
301
|
+
let modifiers = 0;
|
|
302
|
+
for (const p of parts)
|
|
303
|
+
modifiers |= MODIFIER_MASKS[p] ?? 0;
|
|
304
|
+
this._push(pageAlias, framePath, { name: 'press', selector, key, modifiers });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
case 'check':
|
|
308
|
+
case 'uncheck':
|
|
309
|
+
this._push(pageAlias, framePath, { name: method, selector });
|
|
310
|
+
return;
|
|
311
|
+
case 'select': {
|
|
312
|
+
const options = this._parseStringArray(args);
|
|
313
|
+
if (!options) {
|
|
314
|
+
this._diag(stmt, 'could not resolve the select option argument — not replayed');
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
this._push(pageAlias, framePath, { name: 'select', selector, options });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
case 'setInputFiles': {
|
|
321
|
+
this._push(pageAlias, framePath, { name: 'setInputFiles', selector, files: this._parseFileArgs(args) });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
case 'dragTo': {
|
|
325
|
+
// Argument is a locator expression on a page variable.
|
|
326
|
+
const targetMatch = args.match(/^\s*(\w+)\.([\s\S]+?)\s*(?:,\s*\{[\s\S]*\})?$/);
|
|
327
|
+
const target = targetMatch && this._pageAliases.has(targetMatch[1]) ? this._resolveLocatorChain(targetMatch[2]) : undefined;
|
|
328
|
+
if (!target) {
|
|
329
|
+
this._diag(stmt, 'could not parse the drag target locator — not replayed');
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
this._push(pageAlias, framePath, { name: 'dragTo', selector, target: target.selector });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Emit the bare assertApiRequest MARKER for a checkRequestPayload statement.
|
|
340
|
+
* The payload assertion cannot execute during replay (no primitive), but its
|
|
341
|
+
* trace-side representation is just a marker whose payload/URL live in the
|
|
342
|
+
* HAR — the Go codegen re-correlates them at generate time. Emitting the
|
|
343
|
+
* marker means a combined export + regenerate keeps the assertion instead of
|
|
344
|
+
* silently losing it (SKYR-4083). Attributed to the last action's tab (the
|
|
345
|
+
* statement itself names no page).
|
|
346
|
+
*/
|
|
347
|
+
protected _emitApiRequestMarker(stmt: Statement) {
|
|
348
|
+
const pageAlias = this._actions.length ? this._actions[this._actions.length - 1].frame.pageAlias : 'page';
|
|
349
|
+
this._push(pageAlias, [], { name: 'assertApiRequest' });
|
|
350
|
+
this._diag(stmt, 'payload assertion is not executed during replay; preserved as an assertApiRequest marker for export/regeneration');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Emit the fileChooser action for a setFiles/set_files on a chooser variable. */
|
|
354
|
+
protected _emitSetFiles(stmt: Statement, args: string) {
|
|
355
|
+
const trigger = this._heldChooserTrigger;
|
|
356
|
+
this._heldChooserTrigger = undefined;
|
|
357
|
+
if (!trigger) {
|
|
358
|
+
this._diag(stmt, 'setFiles without a preceding chooser trigger click — not replayed');
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
this._push(trigger.pageAlias, trigger.framePath, { name: 'fileChooser', selector: trigger.selector, files: this._parseFileArgs(args) });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Emit the assert/visualSnapshot action for an expect() statement. */
|
|
365
|
+
protected _emitExpect(stmt: Statement, subject: string, matcher: 'assertText' | 'assertTextSub' | 'assertValue' | 'assertChecked' | 'assertVisible' | 'screenshot', args: string) {
|
|
366
|
+
if (matcher === 'screenshot') {
|
|
367
|
+
this._emitScreenshot(stmt, subject, args);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Subject must be a locator on a known page variable.
|
|
372
|
+
const subjMatch = subject.match(/^(\w+)\.([\s\S]+)$/);
|
|
373
|
+
const resolved = subjMatch && this._pageAliases.has(subjMatch[1]) ? this._resolveLocatorChain(subjMatch[2]) : undefined;
|
|
374
|
+
if (!resolved) {
|
|
375
|
+
this._diag(stmt, 'could not parse the expect() subject locator — not replayed');
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const { selector, framePath } = resolved;
|
|
379
|
+
const pageAlias = this._pageAliases.get(subjMatch![1])!;
|
|
380
|
+
|
|
381
|
+
switch (matcher) {
|
|
382
|
+
case 'assertText':
|
|
383
|
+
case 'assertTextSub': {
|
|
384
|
+
const text = this._resolveStringArg(args);
|
|
385
|
+
if (text === undefined) {
|
|
386
|
+
this._diag(stmt, 'could not resolve the text assertion argument — not replayed');
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
this._push(pageAlias, framePath, { name: 'assertText', selector, text, substring: matcher === 'assertTextSub' });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
case 'assertValue': {
|
|
393
|
+
const value = this._resolveStringArg(args);
|
|
394
|
+
if (value === undefined) {
|
|
395
|
+
this._diag(stmt, 'could not resolve the value assertion argument — not replayed');
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
this._push(pageAlias, framePath, { name: 'assertValue', selector, value });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
case 'assertChecked':
|
|
402
|
+
this._push(pageAlias, framePath, { name: 'assertChecked', selector, checked: !/checked\s*[:=]\s*(false|False)/.test(args) });
|
|
403
|
+
return;
|
|
404
|
+
case 'assertVisible':
|
|
405
|
+
this._push(pageAlias, framePath, { name: 'assertVisible', selector });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private _emitScreenshot(stmt: Statement, subject: string, args: string) {
|
|
411
|
+
const filenameMatch = args.match(/^\s*(["'])((?:[^\\]|\\.)*?)\1/);
|
|
412
|
+
const filename = filenameMatch ? unescapeString(filenameMatch[2]) : '';
|
|
413
|
+
const fullPage = /full_?[pP]age\s*[:=]\s*(true|True)/.test(args);
|
|
414
|
+
const clipMatch = args.match(/clip\s*[:=]\s*(\{[^}]*\})/);
|
|
415
|
+
|
|
416
|
+
// expect(page).toHaveScreenshot → page (or region when clipped).
|
|
417
|
+
if (this._pageAliases.has(subject.trim())) {
|
|
418
|
+
const pageAlias = this._pageAliases.get(subject.trim())!;
|
|
419
|
+
if (clipMatch)
|
|
420
|
+
this._push(pageAlias, [], { name: 'visualSnapshot', snapshotType: 'region', filename, clip: parseLooseObject(clipMatch[1]) });
|
|
421
|
+
else
|
|
422
|
+
this._push(pageAlias, [], { name: 'visualSnapshot', snapshotType: 'page', filename, ...(fullPage ? { fullPage: true } : {}) });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// expect(page.<locator>).toHaveScreenshot → element.
|
|
427
|
+
const subjMatch = subject.match(/^(\w+)\.([\s\S]+)$/);
|
|
428
|
+
const resolved = subjMatch && this._pageAliases.has(subjMatch[1]) ? this._resolveLocatorChain(subjMatch[2]) : undefined;
|
|
429
|
+
if (!resolved) {
|
|
430
|
+
this._diag(stmt, 'could not parse the screenshot subject locator — not replayed');
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
this._push(this._pageAliases.get(subjMatch![1])!, resolved.framePath, { name: 'visualSnapshot', snapshotType: 'element', selector: resolved.selector, filename });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Emit page.mouse.* actions (coordinates + kwargs are language-neutral). */
|
|
437
|
+
protected _emitMouse(stmt: Statement, pageAlias: string, method: string, args: string) {
|
|
438
|
+
const nums = [...args.matchAll(/-?\d+(?:\.\d+)?/g)].map(m => Number(m[0]));
|
|
439
|
+
switch (method) {
|
|
440
|
+
case 'move': {
|
|
441
|
+
if (nums.length < 2)
|
|
442
|
+
break;
|
|
443
|
+
const stepsMatch = args.match(/steps\s*[:=]\s*(\d+)/);
|
|
444
|
+
const action: Record<string, any> = { name: 'mouse.move', position: { x: nums[0], y: nums[1] } };
|
|
445
|
+
if (stepsMatch)
|
|
446
|
+
action.steps = Number(stepsMatch[1]);
|
|
447
|
+
this._push(pageAlias, [], action);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
case 'down':
|
|
451
|
+
case 'up': {
|
|
452
|
+
const buttonMatch = args.match(/button\s*[:=]\s*["'](\w+)["']/);
|
|
453
|
+
const action: Record<string, any> = { name: `mouse.${method}` };
|
|
454
|
+
if (buttonMatch)
|
|
455
|
+
action.button = buttonMatch[1];
|
|
456
|
+
this._push(pageAlias, [], action);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
case 'wheel': {
|
|
460
|
+
if (nums.length < 2)
|
|
461
|
+
break;
|
|
462
|
+
this._push(pageAlias, [], { name: 'mouse.wheel', position: { x: 0, y: 0 }, deltaX: nums[0], deltaY: nums[1], modifiers: 0 });
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
case 'click': {
|
|
466
|
+
if (nums.length < 2)
|
|
467
|
+
break;
|
|
468
|
+
this._push(pageAlias, [], { name: 'click', selector: 'body', position: { x: nums[0], y: nums[1] }, button: 'left', modifiers: 0, clickCount: 1 });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
this._diag(stmt, `could not parse mouse.${method}() arguments — not replayed`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// --- Argument helpers (subclasses provide the getValue call shape) --------
|
|
476
|
+
|
|
477
|
+
/** The data-binding accessor pattern, e.g. getValue(x, "k") / skyramp.get_value(x, "k"). */
|
|
478
|
+
protected abstract _matchGetValue(args: string): { binding: string, key: string } | undefined;
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Resolve a single string-valued call argument: a literal, or a
|
|
482
|
+
* getValue(playwrightRequestN, "key") reference whose value is inlined from
|
|
483
|
+
* the tracked binding.
|
|
484
|
+
*/
|
|
485
|
+
protected _resolveStringArg(args: string): string | undefined {
|
|
486
|
+
const literal = args.match(/^\s*(["'])((?:[^\\]|\\.|(?!\1).)*?)\1\s*$/s);
|
|
487
|
+
if (literal)
|
|
488
|
+
return unescapeString(literal[2]);
|
|
489
|
+
|
|
490
|
+
const getValue = this._matchGetValue(args);
|
|
491
|
+
if (getValue) {
|
|
492
|
+
const binding = this._bindings.get(getValue.binding);
|
|
493
|
+
if (binding === undefined)
|
|
494
|
+
return undefined;
|
|
495
|
+
// The key is a gjson-style path; generated UI specs use flat keys, but
|
|
496
|
+
// resolve dotted paths for safety.
|
|
497
|
+
let value: any = binding;
|
|
498
|
+
for (const part of getValue.key.split('.'))
|
|
499
|
+
value = value?.[part];
|
|
500
|
+
if (value === undefined)
|
|
501
|
+
return undefined;
|
|
502
|
+
return String(value);
|
|
503
|
+
}
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Parse select-option args: a string literal or an array of string literals. */
|
|
508
|
+
protected _parseStringArray(args: string): string[] | undefined {
|
|
509
|
+
const single = this._resolveStringArg(args);
|
|
510
|
+
if (single !== undefined)
|
|
511
|
+
return [single];
|
|
512
|
+
const arrayMatch = args.match(/^\s*\[([\s\S]*)\]\s*$/);
|
|
513
|
+
if (arrayMatch)
|
|
514
|
+
return [...arrayMatch[1].matchAll(/(["'])((?:[^\\]|\\.|(?!\1).)*?)\1/g)].map(m => unescapeString(m[2]));
|
|
515
|
+
return undefined;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Parse setFiles/set_input_files arguments into file paths. Generated specs
|
|
520
|
+
* emit a spec-relative join (`path.join(__dirname, 'fixtures', "data.csv")`
|
|
521
|
+
* / `os.path.join(os.path.dirname(__file__), ...)`) — resolved against the
|
|
522
|
+
* spec file's directory — or plain string literals.
|
|
523
|
+
*/
|
|
524
|
+
protected _parseFileArgs(args: string): string[] {
|
|
525
|
+
const files: string[] = [];
|
|
526
|
+
const joinRe = /(?:path\.join\(\s*__dirname|os\.path\.join\(\s*os\.path\.dirname\(__file__\))\s*((?:,\s*(["'])(?:[^\\]|\\.)*?\2\s*)+)\)/g;
|
|
527
|
+
let joinMatch: RegExpExecArray | null;
|
|
528
|
+
let rest = args;
|
|
529
|
+
while ((joinMatch = joinRe.exec(args))) {
|
|
530
|
+
const parts = [...joinMatch[1].matchAll(/(["'])((?:[^\\]|\\.)*?)\1/g)].map(m => unescapeString(m[2]));
|
|
531
|
+
files.push(path.join(this._specDir, ...parts));
|
|
532
|
+
rest = rest.replace(joinMatch[0], '');
|
|
533
|
+
}
|
|
534
|
+
for (const m of rest.matchAll(/(["'])((?:[^\\]|\\.)*?)\1/g)) {
|
|
535
|
+
const p = unescapeString(m[2]);
|
|
536
|
+
files.push(path.isAbsolute(p) ? p : path.join(this._specDir, p));
|
|
537
|
+
}
|
|
538
|
+
return files;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Parse the trailing options of a click/hover call (button, clickCount/click_count, position, modifiers). */
|
|
542
|
+
protected _parseCallOptions(args: string): { button?: 'left' | 'middle' | 'right', clickCount?: number, position?: { x: number, y: number }, modifiers?: number } {
|
|
543
|
+
const out: { button?: 'left' | 'middle' | 'right', clickCount?: number, position?: { x: number, y: number }, modifiers?: number } = {};
|
|
544
|
+
const buttonMatch = args.match(/button\s*[:=]\s*["'](left|middle|right)["']/);
|
|
545
|
+
if (buttonMatch)
|
|
546
|
+
out.button = buttonMatch[1] as 'left' | 'middle' | 'right';
|
|
547
|
+
const clickCountMatch = args.match(/click_?[cC]ount\s*[:=]\s*(\d+)/);
|
|
548
|
+
if (clickCountMatch)
|
|
549
|
+
out.clickCount = Number(clickCountMatch[1]);
|
|
550
|
+
const positionMatch = args.match(/position\s*[:=]\s*(\{[^}]*\})/);
|
|
551
|
+
if (positionMatch) {
|
|
552
|
+
const pos = parseLooseObject(positionMatch[1]);
|
|
553
|
+
if (typeof pos.x === 'number' && typeof pos.y === 'number')
|
|
554
|
+
out.position = { x: pos.x, y: pos.y };
|
|
555
|
+
}
|
|
556
|
+
const modifiersMatch = args.match(/modifiers\s*[:=]\s*\[([^\]]*)\]/);
|
|
557
|
+
if (modifiersMatch) {
|
|
558
|
+
let mask = 0;
|
|
559
|
+
for (const m of modifiersMatch[1].matchAll(/["'](\w+)["']/g))
|
|
560
|
+
mask |= MODIFIER_MASKS[m[1]] ?? 0;
|
|
561
|
+
out.modifiers = mask;
|
|
562
|
+
}
|
|
563
|
+
return out;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
// JS/TS parser (TypeScript specs differ from JavaScript only in imports and
|
|
569
|
+
// type annotations, which the matchers below ignore)
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
// Statements that are pure scaffolding — dropped without a diagnostic.
|
|
573
|
+
const JS_SCAFFOLDING: RegExp[] = [
|
|
574
|
+
/^import\b/,
|
|
575
|
+
/^const\s+.*=\s*require\(/,
|
|
576
|
+
/^const\s+pageTimeout\b/,
|
|
577
|
+
/^test\(/,
|
|
578
|
+
/^test\.setTimeout\(/,
|
|
579
|
+
/^test\.use\(/,
|
|
580
|
+
/^\}\)?;?$/,
|
|
581
|
+
/^(await\s+)?\w+\.setDefaultTimeout\(/,
|
|
582
|
+
/^page\s*=\s*newSkyrampPlaywrightPage\(/,
|
|
583
|
+
/^'use strict'/,
|
|
584
|
+
];
|
|
585
|
+
|
|
586
|
+
const JS_LOCATOR_METHODS = 'click|dblclick|hover|fill|press|pressSequentially|check|uncheck|selectOption|setInputFiles|dragTo';
|
|
587
|
+
|
|
588
|
+
// Spec method name → the canonical action-dispatch key used by _emitLocatorAction.
|
|
589
|
+
const JS_METHOD_MAP: Record<string, string> = {
|
|
590
|
+
selectOption: 'select',
|
|
591
|
+
dragTo: 'dragTo',
|
|
592
|
+
setInputFiles: 'setInputFiles',
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
class JsSpecParser extends SpecParserBase {
|
|
596
|
+
protected override readonly _locatorLanguage = 'javascript' as const;
|
|
597
|
+
protected override readonly _frameHopSplit = /\.contentFrame\(\)\.?/;
|
|
598
|
+
|
|
599
|
+
constructor(content: string, specDir: string) {
|
|
600
|
+
super(scanStatements(content, '//'), specDir);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
protected override _matchGetValue(args: string): { binding: string, key: string } | undefined {
|
|
604
|
+
const m = args.match(/^\s*getValue\(\s*(\w+)\s*,\s*(["'])((?:[^\\]|\\.)*?)\2\s*\)(\.toString\(\))?\s*$/);
|
|
605
|
+
return m ? { binding: m[1], key: unescapeString(m[3]) } : undefined;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
protected override _parseStatement(stmt: Statement) {
|
|
609
|
+
const text = stmt.text.replace(/;$/, '');
|
|
610
|
+
|
|
611
|
+
for (const re of JS_SCAFFOLDING) {
|
|
612
|
+
if (re.test(text))
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// --- Bindings ---------------------------------------------------------
|
|
617
|
+
|
|
618
|
+
// const playwrightRequestN(: <type>)? = {...} / const expectedPayloadN = {...}
|
|
619
|
+
const bindingMatch = text.match(/^const\s+(\w+)\s*(?::[^=]+)?=\s*(\{[\s\S]*\})$/);
|
|
620
|
+
if (bindingMatch) {
|
|
621
|
+
try {
|
|
622
|
+
this._bindings.set(bindingMatch[1], JSON.parse(bindingMatch[2]));
|
|
623
|
+
} catch {
|
|
624
|
+
this._diag(stmt, 'object literal is not plain JSON; getValue() references to it cannot be inlined');
|
|
625
|
+
this._bindings.set(bindingMatch[1], undefined);
|
|
626
|
+
}
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// const responsePromiseN = page.waitForResponse("...") — network
|
|
631
|
+
// correlation added by codegen from the HAR; there is no replay primitive,
|
|
632
|
+
// so it is consumed (the triggering action itself is preserved).
|
|
633
|
+
const responsePromiseMatch = text.match(/^const\s+(\w+)\s*=\s*(\w+)\.waitForResponse\(/);
|
|
634
|
+
if (responsePromiseMatch) {
|
|
635
|
+
this._promiseVars.add(responsePromiseMatch[1]);
|
|
636
|
+
this._diag(stmt, 'waitForResponse is a codegen enrichment with no replay primitive — not replayed');
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// const fileChooserPromiseN = page.waitForEvent("filechooser") — the next
|
|
641
|
+
// click is the chooser trigger; hold it for the setFiles statement.
|
|
642
|
+
const chooserPromiseMatch = text.match(/^const\s+(\w+)\s*=\s*(\w+)\.waitForEvent\(\s*["']filechooser["']\s*\)/);
|
|
643
|
+
if (chooserPromiseMatch) {
|
|
644
|
+
this._promiseVars.add(chooserPromiseMatch[1]);
|
|
645
|
+
this._pendingFileChooser = true;
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// const responseN = await responsePromiseN / const fileChooserN = await fileChooserPromiseN
|
|
650
|
+
const awaitPromiseMatch = text.match(/^const\s+(\w+)\s*=\s*await\s+(\w+)$/);
|
|
651
|
+
if (awaitPromiseMatch && this._promiseVars.has(awaitPromiseMatch[2])) {
|
|
652
|
+
this._promiseVars.add(awaitPromiseMatch[1]);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// const page1 = await page.context().newPage()
|
|
657
|
+
const newPageMatch = text.match(/^const\s+(\w+)\s*=\s*await\s+(\w+)\.context\(\)\.newPage\(\)$/);
|
|
658
|
+
if (newPageMatch) {
|
|
659
|
+
this._pageAliases.set(newPageMatch[1], newPageMatch[1]);
|
|
660
|
+
this._push(newPageMatch[1], [], { name: 'openPage', url: 'about:blank' });
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// const popupPage = await page.waitForPage('/match/') — SkyrampPlaywrightPage
|
|
665
|
+
// popup binding. Register the alias; the load-trace popup machinery binds it
|
|
666
|
+
// to a live tab at replay time.
|
|
667
|
+
const waitForPageMatch = text.match(/^const\s+(\w+)\s*=\s*await\s+(\w+)\.waitForPage\(/);
|
|
668
|
+
if (waitForPageMatch) {
|
|
669
|
+
this._pageAliases.set(waitForPageMatch[1], waitForPageMatch[1]);
|
|
670
|
+
this._push(waitForPageMatch[1], [], { name: 'openPage', url: 'about:blank' });
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// fileChooserN.setFiles(...) → the held trigger click + files become one
|
|
675
|
+
// fileChooser action (replay clicks the trigger and sets the files).
|
|
676
|
+
const setFilesMatch = text.match(/^await\s+(\w+)\.setFiles\(([\s\S]*)\)$/);
|
|
677
|
+
if (setFilesMatch && this._promiseVars.has(setFilesMatch[1])) {
|
|
678
|
+
this._emitSetFiles(stmt, setFilesMatch[2]);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// checkRequestPayload(...) — network payload assertion. Not executable at
|
|
683
|
+
// replay time, but preserved as an assertApiRequest marker (SKYR-4083).
|
|
684
|
+
if (/^(await\s+)?checkRequestPayload\(/.test(text)) {
|
|
685
|
+
this._emitApiRequestMarker(stmt);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// --- Actions ----------------------------------------------------------
|
|
690
|
+
|
|
691
|
+
// await page.goto("URL")
|
|
692
|
+
const gotoMatch = text.match(/^await\s+(\w+)\.goto\(\s*(["'])((?:[^\\]|\\.)*?)\2/);
|
|
693
|
+
if (gotoMatch && this._pageAliases.has(gotoMatch[1])) {
|
|
694
|
+
this._push(this._pageAliases.get(gotoMatch[1])!, [], { name: 'navigate', url: unescapeString(gotoMatch[3]) });
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// await page.waitForTimeout(N) — hydration/settle wait; replayable, kept.
|
|
699
|
+
const waitTimeoutMatch = text.match(/^await\s+(\w+)\.waitForTimeout\(\s*(\d+)\s*\)$/);
|
|
700
|
+
if (waitTimeoutMatch && this._pageAliases.has(waitTimeoutMatch[1])) {
|
|
701
|
+
this._push(this._pageAliases.get(waitTimeoutMatch[1])!, [], { name: 'waitForTimeout', duration: Number(waitTimeoutMatch[2]) });
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// await page.evaluate(<function source>) — browser_evaluate replay
|
|
706
|
+
// (SKYR-3831/3914). The argument is an unquoted function literal; the
|
|
707
|
+
// replay engine IIFE-wraps the source string.
|
|
708
|
+
const evaluateMatch = text.match(/^await\s+(\w+)\.evaluate\(([\s\S]*)\)$/);
|
|
709
|
+
if (evaluateMatch && this._pageAliases.has(evaluateMatch[1])) {
|
|
710
|
+
this._push(this._pageAliases.get(evaluateMatch[1])!, [], { name: 'evaluate', expression: evaluateMatch[2].trim() });
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// await pageN.close()
|
|
715
|
+
const closeMatch = text.match(/^await\s+(\w+)\.close\(\)$/);
|
|
716
|
+
if (closeMatch && this._pageAliases.has(closeMatch[1])) {
|
|
717
|
+
this._push(this._pageAliases.get(closeMatch[1])!, [], { name: 'closePage' });
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// await page.mouse.move/down/up/wheel(...)
|
|
722
|
+
const mouseMatch = text.match(/^await\s+(\w+)\.mouse\.(move|down|up|wheel|click)\(([\s\S]*)\)$/);
|
|
723
|
+
if (mouseMatch && this._pageAliases.has(mouseMatch[1])) {
|
|
724
|
+
this._emitMouse(stmt, this._pageAliases.get(mouseMatch[1])!, mouseMatch[2], mouseMatch[3]);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// await expect(...).toHaveScreenshot / assertion matchers
|
|
729
|
+
const expectMatch = text.match(/^await\s+expect\(\s*([\s\S]+?)\s*\)\.(not\.)?(\w+)\(([\s\S]*)\)$/);
|
|
730
|
+
if (expectMatch) {
|
|
731
|
+
if (expectMatch[2]) {
|
|
732
|
+
this._diag(stmt, `negated expect matcher .not.${expectMatch[3]} has no trace equivalent — not replayed`);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const matcher = ({
|
|
736
|
+
toHaveScreenshot: 'screenshot',
|
|
737
|
+
toHaveText: 'assertText',
|
|
738
|
+
toContainText: 'assertTextSub',
|
|
739
|
+
toHaveValue: 'assertValue',
|
|
740
|
+
toBeChecked: 'assertChecked',
|
|
741
|
+
toBeVisible: 'assertVisible',
|
|
742
|
+
} as const)[expectMatch[3] as string];
|
|
743
|
+
if (!matcher) {
|
|
744
|
+
this._diag(stmt, `expect matcher .${expectMatch[3]}() has no trace equivalent — not replayed`);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
this._emitExpect(stmt, expectMatch[1], matcher, expectMatch[4]);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// await <pageVar>.<locator chain>.<action>(<args>)
|
|
752
|
+
const actionMatch = text.match(new RegExp(`^await\\s+(\\w+)\\.([\\s\\S]+)\\.(${JS_LOCATOR_METHODS})\\(([\\s\\S]*)\\)$`));
|
|
753
|
+
if (actionMatch && this._pageAliases.has(actionMatch[1])) {
|
|
754
|
+
this._emitLocatorAction(stmt, this._pageAliases.get(actionMatch[1])!, actionMatch[2], JS_METHOD_MAP[actionMatch[3]] ?? actionMatch[3], actionMatch[4]);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
this._diag(stmt, 'unrecognized statement — not replayed');
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
// Python parser (pytest specs from `skyramp generate --language python`).
|
|
764
|
+
// Sync API: no await; `with` blocks wrap network/file-chooser expectations and
|
|
765
|
+
// their INDENTED BODIES contain real actions, parsed like any other statement.
|
|
766
|
+
// ---------------------------------------------------------------------------
|
|
767
|
+
|
|
768
|
+
const PY_SCAFFOLDING: RegExp[] = [
|
|
769
|
+
/^import\b/,
|
|
770
|
+
/^from\s+[\w.]+\s+import\b/,
|
|
771
|
+
/^@/,
|
|
772
|
+
/^def\s+test_\w+\(/,
|
|
773
|
+
/^\w+\.set_default_timeout\(/,
|
|
774
|
+
/^page\s*=\s*skyramp\.new_skyramp_playwright_page\(/,
|
|
775
|
+
];
|
|
776
|
+
|
|
777
|
+
// The Python emitter snake_cases method names — but has a known bug leaving
|
|
778
|
+
// pressSequentially camelCase (invalid Python API, tolerated here; see
|
|
779
|
+
// fixes-contexts/loadtrace-from-spec-plan.md M0 findings).
|
|
780
|
+
const PY_LOCATOR_METHODS = 'click|dblclick|hover|fill|press|press_sequentially|pressSequentially|check|uncheck|select_option|set_input_files|drag_to';
|
|
781
|
+
|
|
782
|
+
const PY_METHOD_MAP: Record<string, string> = {
|
|
783
|
+
press_sequentially: 'pressSequentially',
|
|
784
|
+
select_option: 'select',
|
|
785
|
+
set_input_files: 'setInputFiles',
|
|
786
|
+
drag_to: 'dragTo',
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
const PY_EXPECT_MATCHERS: Record<string, 'assertText' | 'assertTextSub' | 'assertValue' | 'assertChecked' | 'assertVisible' | 'screenshot'> = {
|
|
790
|
+
to_have_screenshot: 'screenshot',
|
|
791
|
+
to_have_text: 'assertText',
|
|
792
|
+
to_contain_text: 'assertTextSub',
|
|
793
|
+
to_have_value: 'assertValue',
|
|
794
|
+
to_be_checked: 'assertChecked',
|
|
795
|
+
to_be_visible: 'assertVisible',
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
class PySpecParser extends SpecParserBase {
|
|
799
|
+
protected override readonly _locatorLanguage = 'python' as const;
|
|
800
|
+
protected override readonly _frameHopSplit = /\.content_frame\./;
|
|
801
|
+
// Inside a non-test function body (e.g. the pytest browser_context_args
|
|
802
|
+
// fixture): statements indented deeper than this are dropped silently.
|
|
803
|
+
private _skipBlockIndent: number | undefined;
|
|
804
|
+
|
|
805
|
+
constructor(content: string, specDir: string) {
|
|
806
|
+
super(scanStatements(content, '#'), specDir);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
protected override _matchGetValue(args: string): { binding: string, key: string } | undefined {
|
|
810
|
+
// skyramp.get_value(x, "k"), optionally str(...)-wrapped for numbers.
|
|
811
|
+
const m = args.match(/^\s*(?:str\(\s*)?skyramp\.get_value\(\s*(\w+)\s*,\s*(["'])((?:[^\\]|\\.)*?)\2\s*\)\s*\)?\s*$/);
|
|
812
|
+
return m ? { binding: m[1], key: unescapeString(m[3]) } : undefined;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
protected override _parseStatement(stmt: Statement) {
|
|
816
|
+
// Skip the body of a non-test function (fixture defs) by indentation.
|
|
817
|
+
if (this._skipBlockIndent !== undefined) {
|
|
818
|
+
if (stmt.indent > this._skipBlockIndent)
|
|
819
|
+
return;
|
|
820
|
+
this._skipBlockIndent = undefined;
|
|
821
|
+
}
|
|
822
|
+
const defMatch = stmt.text.match(/^def\s+(\w+)\(/);
|
|
823
|
+
if (defMatch && !defMatch[1].startsWith('test_')) {
|
|
824
|
+
this._skipBlockIndent = stmt.indent;
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const text = stmt.text;
|
|
829
|
+
|
|
830
|
+
for (const re of PY_SCAFFOLDING) {
|
|
831
|
+
if (re.test(text))
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// --- with-blocks (their bodies arrive as separate statements) ----------
|
|
836
|
+
|
|
837
|
+
// with page.expect_response("...") as response_0:
|
|
838
|
+
const expectResponseMatch = text.match(/^with\s+(\w+)\.expect_response\([\s\S]*\)\s+as\s+(\w+)\s*:$/);
|
|
839
|
+
if (expectResponseMatch) {
|
|
840
|
+
this._promiseVars.add(expectResponseMatch[2]);
|
|
841
|
+
this._diag(stmt, 'waitForResponse is a codegen enrichment with no replay primitive — not replayed');
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// with page.expect_file_chooser() as file_chooser_info_0:
|
|
846
|
+
const expectChooserMatch = text.match(/^with\s+(\w+)\.expect_file_chooser\(\)\s+as\s+(\w+)\s*:$/);
|
|
847
|
+
if (expectChooserMatch) {
|
|
848
|
+
this._promiseVars.add(expectChooserMatch[2]);
|
|
849
|
+
this._pendingFileChooser = true;
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// --- Bindings ---------------------------------------------------------
|
|
854
|
+
|
|
855
|
+
// playwright_request_0 = {...} / expected_payload_1 = {...}
|
|
856
|
+
const bindingMatch = text.match(/^(\w+)\s*=\s*(\{[\s\S]*\})$/);
|
|
857
|
+
if (bindingMatch) {
|
|
858
|
+
const json = pyLiteralToJson(bindingMatch[2]);
|
|
859
|
+
try {
|
|
860
|
+
this._bindings.set(bindingMatch[1], JSON.parse(json));
|
|
861
|
+
} catch {
|
|
862
|
+
this._diag(stmt, 'dict literal could not be parsed; get_value() references to it cannot be inlined');
|
|
863
|
+
this._bindings.set(bindingMatch[1], undefined);
|
|
864
|
+
}
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// page1 = page.context.new_page()
|
|
869
|
+
const newPageMatch = text.match(/^(\w+)\s*=\s*(\w+)\.context\.new_page\(\)$/);
|
|
870
|
+
if (newPageMatch) {
|
|
871
|
+
this._pageAliases.set(newPageMatch[1], newPageMatch[1]);
|
|
872
|
+
this._push(newPageMatch[1], [], { name: 'openPage', url: 'about:blank' });
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// popup_page = page.wait_for_page('/match/')
|
|
877
|
+
const waitForPageMatch = text.match(/^(\w+)\s*=\s*(\w+)\.wait_for_page\(/);
|
|
878
|
+
if (waitForPageMatch) {
|
|
879
|
+
this._pageAliases.set(waitForPageMatch[1], waitForPageMatch[1]);
|
|
880
|
+
this._push(waitForPageMatch[1], [], { name: 'openPage', url: 'about:blank' });
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// file_chooser_0 = file_chooser_info_0.value
|
|
885
|
+
const valueMatch = text.match(/^(\w+)\s*=\s*(\w+)\.value$/);
|
|
886
|
+
if (valueMatch && this._promiseVars.has(valueMatch[2])) {
|
|
887
|
+
this._promiseVars.add(valueMatch[1]);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// file_chooser_0.set_files(...)
|
|
892
|
+
const setFilesMatch = text.match(/^(\w+)\.set_files\(([\s\S]*)\)$/);
|
|
893
|
+
if (setFilesMatch && this._promiseVars.has(setFilesMatch[1])) {
|
|
894
|
+
this._emitSetFiles(stmt, setFilesMatch[2]);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// skyramp.check_request_payload(...) — network payload assertion. Not
|
|
899
|
+
// executable at replay time, but preserved as an assertApiRequest marker
|
|
900
|
+
// (SKYR-4083).
|
|
901
|
+
if (/^skyramp\.check_request_payload\(/.test(text)) {
|
|
902
|
+
this._emitApiRequestMarker(stmt);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// --- Actions ----------------------------------------------------------
|
|
907
|
+
|
|
908
|
+
// page.goto("URL")
|
|
909
|
+
const gotoMatch = text.match(/^(\w+)\.goto\(\s*(["'])((?:[^\\]|\\.)*?)\2/);
|
|
910
|
+
if (gotoMatch && this._pageAliases.has(gotoMatch[1])) {
|
|
911
|
+
this._push(this._pageAliases.get(gotoMatch[1])!, [], { name: 'navigate', url: unescapeString(gotoMatch[3]) });
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// page.wait_for_timeout(N)
|
|
916
|
+
const waitTimeoutMatch = text.match(/^(\w+)\.wait_for_timeout\(\s*(\d+)\s*\)$/);
|
|
917
|
+
if (waitTimeoutMatch && this._pageAliases.has(waitTimeoutMatch[1])) {
|
|
918
|
+
this._push(this._pageAliases.get(waitTimeoutMatch[1])!, [], { name: 'waitForTimeout', duration: Number(waitTimeoutMatch[2]) });
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// page.evaluate("<function source>") — in Python the JS function source is
|
|
923
|
+
// passed as a STRING (the sync API forwards it to the same evaluate).
|
|
924
|
+
const evaluateMatch = text.match(/^(\w+)\.evaluate\(([\s\S]*)\)$/);
|
|
925
|
+
if (evaluateMatch && this._pageAliases.has(evaluateMatch[1])) {
|
|
926
|
+
const expression = this._resolveStringArg(evaluateMatch[2]) ?? evaluateMatch[2].trim();
|
|
927
|
+
this._push(this._pageAliases.get(evaluateMatch[1])!, [], { name: 'evaluate', expression });
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// page1.close()
|
|
932
|
+
const closeMatch = text.match(/^(\w+)\.close\(\)$/);
|
|
933
|
+
if (closeMatch && this._pageAliases.has(closeMatch[1])) {
|
|
934
|
+
this._push(this._pageAliases.get(closeMatch[1])!, [], { name: 'closePage' });
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// page.mouse.move/down/up/wheel(...)
|
|
939
|
+
const mouseMatch = text.match(/^(\w+)\.mouse\.(move|down|up|wheel|click)\(([\s\S]*)\)$/);
|
|
940
|
+
if (mouseMatch && this._pageAliases.has(mouseMatch[1])) {
|
|
941
|
+
this._emitMouse(stmt, this._pageAliases.get(mouseMatch[1])!, mouseMatch[2], mouseMatch[3]);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// expect(<subject>).<matcher>(<args>) — from skyramp import expect.
|
|
946
|
+
const expectMatch = text.match(/^expect\(\s*([\s\S]+?)\s*\)\.(not_)?(\w+)\(([\s\S]*)\)$/);
|
|
947
|
+
if (expectMatch) {
|
|
948
|
+
if (expectMatch[2]) {
|
|
949
|
+
this._diag(stmt, `negated expect matcher .not_${expectMatch[3]} has no trace equivalent — not replayed`);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const matcher = PY_EXPECT_MATCHERS[expectMatch[3]];
|
|
953
|
+
if (!matcher) {
|
|
954
|
+
this._diag(stmt, `expect matcher .${expectMatch[3]}() has no trace equivalent — not replayed`);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
this._emitExpect(stmt, expectMatch[1], matcher, expectMatch[4]);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Extension-frame actions target a frame by URL (wait_for_frame) — the
|
|
962
|
+
// frame chain is not expressible in the JSONL framePath model yet.
|
|
963
|
+
if (/\bwait_for_frame\(/.test(text)) {
|
|
964
|
+
this._diag(stmt, 'wait_for_frame (extension frame) replay is not supported yet — not replayed');
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// <pageVar>.<locator chain>.<action>(<args>)
|
|
969
|
+
const actionMatch = text.match(new RegExp(`^(\\w+)\\.([\\s\\S]+)\\.(${PY_LOCATOR_METHODS})\\(([\\s\\S]*)\\)$`));
|
|
970
|
+
if (actionMatch && this._pageAliases.has(actionMatch[1])) {
|
|
971
|
+
this._emitLocatorAction(stmt, this._pageAliases.get(actionMatch[1])!, actionMatch[2], PY_METHOD_MAP[actionMatch[3]] ?? actionMatch[3], actionMatch[4]);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
this._diag(stmt, 'unrecognized statement — not replayed');
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// ---------------------------------------------------------------------------
|
|
980
|
+
// Small literal parsers
|
|
981
|
+
// ---------------------------------------------------------------------------
|
|
982
|
+
|
|
983
|
+
function unescapeString(text: string): string {
|
|
984
|
+
return text.replace(/\\(.)/g, (_, c) => c === 'n' ? '\n' : c === 't' ? '\t' : c === 'r' ? '\r' : c);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Convert a Python dict literal to JSON: True/False/None → true/false/null,
|
|
989
|
+
* applied outside string literals only (generated dicts are otherwise
|
|
990
|
+
* JSON-shaped: double-quoted keys/strings, numbers, nesting).
|
|
991
|
+
*/
|
|
992
|
+
function pyLiteralToJson(text: string): string {
|
|
993
|
+
let out = '';
|
|
994
|
+
let quote: string | undefined;
|
|
995
|
+
for (let i = 0; i < text.length; i++) {
|
|
996
|
+
const c = text[i];
|
|
997
|
+
if (quote) {
|
|
998
|
+
out += c;
|
|
999
|
+
if (c === '\\')
|
|
1000
|
+
out += text[++i] ?? '';
|
|
1001
|
+
else if (c === quote)
|
|
1002
|
+
quote = undefined;
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
if (c === '"' || c === '\'') {
|
|
1006
|
+
quote = c;
|
|
1007
|
+
out += c;
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
if (/\w/.test(c)) {
|
|
1011
|
+
let word = c;
|
|
1012
|
+
while (i + 1 < text.length && /\w/.test(text[i + 1]))
|
|
1013
|
+
word += text[++i];
|
|
1014
|
+
out += word === 'True' ? 'true' : word === 'False' ? 'false' : word === 'None' ? 'null' : word;
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
out += c;
|
|
1018
|
+
}
|
|
1019
|
+
return out;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** Parse `{ x: 1, y: 2, ... }` / `{"x": 1, ...}` with number/bool values. */
|
|
1023
|
+
function parseLooseObject(text: string): Record<string, any> {
|
|
1024
|
+
const out: Record<string, any> = {};
|
|
1025
|
+
for (const m of text.matchAll(/["']?(\w+)["']?\s*[:=]\s*(-?\d+(?:\.\d+)?|true|false|True|False)/g))
|
|
1026
|
+
out[m[1]] = /^true$/i.test(m[2]) ? true : /^false$/i.test(m[2]) ? false : Number(m[2]);
|
|
1027
|
+
return out;
|
|
1028
|
+
}
|