@skyramp/testbot-core 0.13.0-v3.179 → 0.13.0-v3.181
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/assets/skyramp-api/gen.cjs +247 -0
- package/assets/skyramp-walk/walk.cjs +321 -0
- package/dist/VERSION.json +1 -1
- package/dist/main.js +493 -487
- package/dist/post.js +349 -343
- package/dist/runner-client.cjs +42 -42
- package/dist/runner-client.d.cts +10 -5
- package/package.json +4 -2
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skyramp backend API helper (testbot exp/skyramp-api). Generates a Skyramp contract, integration or fuzz test through
|
|
3
|
+
* the Skyramp MCP package's own generation path (ContractTestService / IntegrationTestService / FuzzTestService, the
|
|
4
|
+
* classes behind its generation tools, called directly: no MCP server, no protocol), so the options are built and the
|
|
5
|
+
* workspace's auth, TLS and query params resolved exactly as the MCP tools do.
|
|
6
|
+
*
|
|
7
|
+
* const api = require(process.env.SKYRAMP_API);
|
|
8
|
+
* // one request: a contract test
|
|
9
|
+
* const { test } = await api.generate({ kind: "contract", baseUrl: "http://localhost:8000", endpoint: "/api/v1/products",
|
|
10
|
+
* method: "POST", requestData: { name: "Lamp", price: 10 }, responseData: { id: 7, name: "Lamp", price: 10 },
|
|
11
|
+
* statusCode: 201, language: "python", framework: "pytest", outputDir: "tests/api", output: "test_products.py" });
|
|
12
|
+
* // several requests: a trace built from the steps, then an integration test from it
|
|
13
|
+
* const trace = await api.trace([
|
|
14
|
+
* { method: "POST", path: "/api/v1/products", request: { name: "Lamp" }, status: 201, response: { id: 7, name: "Lamp" } },
|
|
15
|
+
* { method: "GET", path: "/api/v1/products/{{1.id}}", status: 200, response: { id: 7, name: "Lamp" } },
|
|
16
|
+
* ], { baseUrl: "http://localhost:8000", authHeader: "Authorization", authScheme: "Bearer" });
|
|
17
|
+
* await api.generate({ kind: "integration", trace, language: "python", framework: "pytest", outputDir: "tests/api", output: "test_product_flow.py" });
|
|
18
|
+
*
|
|
19
|
+
* `{{N.field}}` in a step's path, query or request is the value of `field` (dotted) in step N's response (1-based). The
|
|
20
|
+
* trace then carries the real value, and the generator chains it: the test reads it from that step's response.
|
|
21
|
+
* `schema` (a spec path or URL) may stand in for requestData/responseData in a contract or fuzz test.
|
|
22
|
+
*
|
|
23
|
+
* Generation needs the Skyramp licence; the generated tests do not. The licence reaches only the generation call:
|
|
24
|
+
* SKYRAMP_API_LICENSE_FILE (a path) or SKYRAMP_API_TOKEN_FILE (a file holding a run token) go into this process's env
|
|
25
|
+
* for the call and come out after it. Each generated test is copied, as generated, to SKYRAMP_API_DRAFTS and logged to
|
|
26
|
+
* SKYRAMP_API_LOG: that copy is what the finished test is compared with.
|
|
27
|
+
*/
|
|
28
|
+
"use strict";
|
|
29
|
+
const path = require("path");
|
|
30
|
+
const fs = require("fs");
|
|
31
|
+
const os = require("os");
|
|
32
|
+
const { pathToFileURL } = require("url");
|
|
33
|
+
|
|
34
|
+
const SERVICES = {
|
|
35
|
+
contract: ["tools/generate-tests/generateContractRestTool.js", "ContractTestService"],
|
|
36
|
+
integration: ["tools/generate-tests/generateIntegrationRestTool.js", "IntegrationTestService"],
|
|
37
|
+
fuzz: ["tools/generate-tests/generateFuzzRestTool.js", "FuzzTestService"],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const mcpModule = (rel) => {
|
|
41
|
+
const root = process.env.SKYRAMP_API_MCP;
|
|
42
|
+
if (!root) throw new Error("SKYRAMP_API_MCP is not set: the Skyramp MCP package was not found");
|
|
43
|
+
return import(pathToFileURL(path.join(root, "build", rel)).href);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const roots = () => [process.env.SKYRAMP_API_SDK, ...(process.env.NODE_PATH || "").split(path.delimiter)].filter(Boolean);
|
|
47
|
+
/** A client of the SDK the session resolved (SKYRAMP_API_SDK first), else undefined and the MCP package's own is used. */
|
|
48
|
+
const sdkClient = () => {
|
|
49
|
+
for (const d of roots()) {
|
|
50
|
+
try {
|
|
51
|
+
return new (require(require.resolve("@skyramp/skyramp", { paths: [d] })).SkyrampClient)();
|
|
52
|
+
} catch { /* next */ }
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** The value at a dotted path of a parsed JSON value, or undefined. */
|
|
58
|
+
const at = (value, dotted) => dotted.split(".").reduce((v, k) => (v == null ? undefined : v[k]), value);
|
|
59
|
+
|
|
60
|
+
const REF = /\{\{\s*(\d+)\.([\w.-]+)\s*\}\}/g;
|
|
61
|
+
/**
|
|
62
|
+
* `value` with each `{{N.field}}` replaced by that field of step N's response. A string that is one reference keeps
|
|
63
|
+
* the referenced value's type (an id stays a number in a request body); one inside other text becomes text.
|
|
64
|
+
*/
|
|
65
|
+
function resolveRefs(value, responses) {
|
|
66
|
+
const lookup = (n, field) => {
|
|
67
|
+
const r = responses[Number(n) - 1];
|
|
68
|
+
const v = r === undefined ? undefined : at(r, field);
|
|
69
|
+
if (v === undefined) throw new Error(`{{${n}.${field}}}: step ${n} has no response field ${field}`);
|
|
70
|
+
return v;
|
|
71
|
+
};
|
|
72
|
+
if (typeof value === "string") {
|
|
73
|
+
const whole = value.match(/^\{\{\s*(\d+)\.([\w.-]+)\s*\}\}$/);
|
|
74
|
+
if (whole) return lookup(whole[1], whole[2]);
|
|
75
|
+
return value.replace(REF, (_, n, field) => String(lookup(n, field)));
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(value)) return value.map((v) => resolveRefs(v, responses));
|
|
78
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveRefs(v, responses)]));
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const asJson = (v) => (v === undefined ? undefined : typeof v === "string" ? v : JSON.stringify(v));
|
|
83
|
+
const parsed = (v) => {
|
|
84
|
+
if (typeof v !== "string") return v;
|
|
85
|
+
try { return JSON.parse(v); } catch { return v; }
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The trace requests for the plan's steps, in order. Pure: `toEntry` makes one trace request from one step's
|
|
90
|
+
* parameters (in `trace`, the MCP package's ScenarioGenerationService.generateTraceRequestFromInput). Each step has a
|
|
91
|
+
* method and a path, and optionally request (body), query (object), status and response (body).
|
|
92
|
+
*/
|
|
93
|
+
function traceEntries(steps, opts, toEntry) {
|
|
94
|
+
if (!Array.isArray(steps) || steps.length === 0) throw new Error("a trace needs at least one step");
|
|
95
|
+
if (!opts || !opts.baseUrl) throw new Error("a trace needs the app's baseUrl");
|
|
96
|
+
const host = new URL(opts.baseUrl).hostname;
|
|
97
|
+
const responses = [];
|
|
98
|
+
return steps.map((s, i) => {
|
|
99
|
+
if (!s || !s.method || !s.path) throw new Error(`step ${i + 1} needs a method and a path`);
|
|
100
|
+
const stepPath = resolveRefs(String(s.path), responses);
|
|
101
|
+
const request = resolveRefs(s.request, responses);
|
|
102
|
+
const query = resolveRefs(s.query, responses);
|
|
103
|
+
responses.push(parsed(s.response));
|
|
104
|
+
const entry = toEntry({
|
|
105
|
+
scenarioName: opts.name || "testbot-plan",
|
|
106
|
+
destination: host,
|
|
107
|
+
baseURL: opts.baseUrl,
|
|
108
|
+
method: String(s.method).toUpperCase(),
|
|
109
|
+
path: stepPath,
|
|
110
|
+
requestBody: asJson(request),
|
|
111
|
+
queryParams: asJson(query),
|
|
112
|
+
responseBody: asJson(s.response),
|
|
113
|
+
statusCode: s.status === undefined ? undefined : Number(s.status),
|
|
114
|
+
authHeader: opts.authHeader,
|
|
115
|
+
authScheme: opts.authScheme,
|
|
116
|
+
});
|
|
117
|
+
if (!entry) throw new Error(`step ${i + 1} (${s.method} ${s.path}) could not be made a trace request`);
|
|
118
|
+
return entry;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const draftsDir = () => process.env.SKYRAMP_API_DRAFTS || path.join(os.tmpdir(), "skyramp-api-drafts");
|
|
123
|
+
|
|
124
|
+
/** Write the trace for `steps` to a file (opts.file, else beside the drafts) and return its path. */
|
|
125
|
+
async function trace(steps, opts = {}) {
|
|
126
|
+
const { ScenarioGenerationService } = await mcpModule("services/ScenarioGenerationService.js");
|
|
127
|
+
const service = new ScenarioGenerationService();
|
|
128
|
+
const entries = traceEntries(steps, opts, (p) => service.generateTraceRequestFromInput(p));
|
|
129
|
+
const file = path.resolve(opts.file || path.join(draftsDir(), `trace-${Date.now()}.json`));
|
|
130
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
131
|
+
fs.writeFileSync(file, JSON.stringify(entries, null, 2));
|
|
132
|
+
return file;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let generating = Promise.resolve();
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Run `fn` with the licence in this process's env, and restore the env after it. Calls run one at a time: with
|
|
139
|
+
* `Promise.all(cases.map(api.generate))`, one call's restore would otherwise take the token from another mid-call.
|
|
140
|
+
*/
|
|
141
|
+
function withLicence(fn) {
|
|
142
|
+
const run = async () => {
|
|
143
|
+
const saved = { LICENSE_FILE: process.env.LICENSE_FILE, SKYRAMP_RUN_TOKEN: process.env.SKYRAMP_RUN_TOKEN, CI: process.env.CI };
|
|
144
|
+
const tokenFile = process.env.SKYRAMP_API_TOKEN_FILE;
|
|
145
|
+
const licence = process.env.SKYRAMP_API_LICENSE_FILE;
|
|
146
|
+
if (tokenFile) process.env.SKYRAMP_RUN_TOKEN = fs.readFileSync(tokenFile, "utf-8").trim();
|
|
147
|
+
else if (licence) process.env.LICENSE_FILE = licence;
|
|
148
|
+
if (tokenFile || licence) process.env.CI = "true";
|
|
149
|
+
try {
|
|
150
|
+
return await fn();
|
|
151
|
+
} finally {
|
|
152
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
153
|
+
if (v === undefined) delete process.env[k];
|
|
154
|
+
else process.env[k] = v;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const next = generating.then(run, run);
|
|
159
|
+
generating = next.catch(() => {});
|
|
160
|
+
return next;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Is this output name one Skyramp's generator writes as asked: anything but a TS/JS Playwright test, or one named *.{spec,test}.*? */
|
|
164
|
+
function playwrightName(opts) {
|
|
165
|
+
const node = /^(typescript|javascript)$/i.test(String(opts.language || "")) && (!opts.framework || /playwright/i.test(opts.framework));
|
|
166
|
+
return !node || /\.(spec|test)\.[cm]?[jt]s$/.test(opts.output) || !/\.[cm]?[jt]s$/.test(opts.output);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Did an earlier generation of this run write `file` (the log names it)? Only such a file may be generated over. */
|
|
170
|
+
function helperWrote(file) {
|
|
171
|
+
const at = process.env.SKYRAMP_API_LOG;
|
|
172
|
+
if (!at || !fs.existsSync(at)) return false;
|
|
173
|
+
return fs.readFileSync(at, "utf-8").split("\n").some((line) => {
|
|
174
|
+
try { return path.resolve(JSON.parse(line).test || "") === file; } catch { return false; }
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const log = (record) => {
|
|
179
|
+
if (process.env.SKYRAMP_API_LOG) fs.appendFileSync(process.env.SKYRAMP_API_LOG, JSON.stringify({ at: new Date().toISOString(), ...record }) + "\n");
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Generate one test and return { test, draft }: the file written in outputDir, and its copy as generated. Throws
|
|
184
|
+
* with the generator's message when generation fails.
|
|
185
|
+
*/
|
|
186
|
+
async function generate(opts) {
|
|
187
|
+
const spec = SERVICES[opts && opts.kind];
|
|
188
|
+
if (!spec) throw new Error(`kind must be one of ${Object.keys(SERVICES).join(", ")}`);
|
|
189
|
+
if (!opts.outputDir || !opts.output) throw new Error("outputDir and output are required");
|
|
190
|
+
if (!opts.trace && !(opts.baseUrl && opts.endpoint)) throw new Error("give baseUrl and endpoint, or (integration) a trace");
|
|
191
|
+
const outputDir = path.resolve(opts.outputDir);
|
|
192
|
+
const target = path.join(outputDir, opts.output);
|
|
193
|
+
// Skyramp's generator takes a Playwright test only as *.{spec,test}.{ts,js}; a name the target's runner wants
|
|
194
|
+
// otherwise (the Testbot package's *.e2e.ts) is generated under an accepted one, then renamed to it.
|
|
195
|
+
const asGenerated = playwrightName(opts) ? opts.output : opts.output.replace(/(\.e2e)?\.([cm]?[jt]s)$/, ".spec.$2");
|
|
196
|
+
const generatedAt = path.join(outputDir, asGenerated);
|
|
197
|
+
if (asGenerated !== opts.output && fs.existsSync(generatedAt) && !helperWrote(generatedAt))
|
|
198
|
+
throw new Error(`${generatedAt} exists and the helper did not write it: generate under a new name`);
|
|
199
|
+
if (fs.existsSync(target) && !helperWrote(target))
|
|
200
|
+
throw new Error(`${target} exists and the helper did not write it: generate under a new name, or add the case to that test by hand (no draft needed)`);
|
|
201
|
+
const params = {
|
|
202
|
+
...(opts.trace
|
|
203
|
+
? { trace: path.resolve(opts.trace) }
|
|
204
|
+
: { endpointURL: `${String(opts.baseUrl).replace(/\/$/, "")}${opts.endpoint}`, method: String(opts.method || "GET").toUpperCase() }),
|
|
205
|
+
...(opts.schema ? { apiSchema: opts.schema } : {}),
|
|
206
|
+
...(opts.requestData !== undefined ? { requestData: asJson(opts.requestData) } : {}),
|
|
207
|
+
...(opts.responseData !== undefined ? { responseData: asJson(opts.responseData) } : {}),
|
|
208
|
+
...(opts.statusCode !== undefined ? { responseStatusCode: String(opts.statusCode) } : {}),
|
|
209
|
+
// Left out, the MCP's generation resolves it from the workspace; "" is no auth header.
|
|
210
|
+
...(opts.authHeader !== undefined ? { authHeader: opts.authHeader } : {}),
|
|
211
|
+
...(opts.authScheme !== undefined ? { authScheme: opts.authScheme } : {}),
|
|
212
|
+
...(opts.chainingKey ? { chainingKey: opts.chainingKey } : {}),
|
|
213
|
+
...(opts.pathParams ? { pathParams: opts.pathParams } : {}),
|
|
214
|
+
...(opts.queryParams ? { queryParams: opts.queryParams } : {}),
|
|
215
|
+
language: opts.language,
|
|
216
|
+
...(opts.framework ? { framework: opts.framework } : {}),
|
|
217
|
+
outputDir,
|
|
218
|
+
output: asGenerated,
|
|
219
|
+
force: true,
|
|
220
|
+
};
|
|
221
|
+
const record = { kind: opts.kind, method: params.method, endpoint: opts.endpoint, trace: params.trace };
|
|
222
|
+
try {
|
|
223
|
+
const [{ [spec[1]]: Service }, { generatedFilesFrom }] = await Promise.all([mcpModule(spec[0]), mcpModule("services/TestGenerationService.js")]);
|
|
224
|
+
const service = new Service();
|
|
225
|
+
const client = sdkClient();
|
|
226
|
+
if (client) service.client = client;
|
|
227
|
+
const result = await withLicence(() => service.generateTest(params));
|
|
228
|
+
const text = (result.content || []).map((c) => c.text || "").join("\n");
|
|
229
|
+
if (result.isError) throw new Error(`Skyramp generation failed: ${text.trim().split("\n")[0] || "no message"}`);
|
|
230
|
+
let test = generatedFilesFrom(text)[0] || generatedAt;
|
|
231
|
+
if (!fs.existsSync(test)) throw new Error(`Skyramp generation reported success but wrote no ${test}`);
|
|
232
|
+
if (path.resolve(test) === generatedAt && generatedAt !== target) {
|
|
233
|
+
fs.renameSync(generatedAt, target);
|
|
234
|
+
test = target;
|
|
235
|
+
}
|
|
236
|
+
fs.mkdirSync(draftsDir(), { recursive: true });
|
|
237
|
+
const draft = path.join(draftsDir(), `${Date.now()}-${path.basename(test)}`);
|
|
238
|
+
fs.copyFileSync(test, draft);
|
|
239
|
+
log({ ...record, test, draft });
|
|
240
|
+
return { test, draft };
|
|
241
|
+
} catch (e) {
|
|
242
|
+
log({ ...record, error: String((e && e.message) || e).split("\n")[0] });
|
|
243
|
+
throw e;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
module.exports = { generate, trace, traceEntries, resolveRefs, _internal: { withLicence } };
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skyramp walk helper (testbot exp/skyramp-ui). Walk a browser test once against the running app; the helper records
|
|
3
|
+
* it the way Skyramp's recorder does and generates a Skyramp Playwright test from it.
|
|
4
|
+
*
|
|
5
|
+
* const { walk } = require(process.env.SKYRAMP_WALK);
|
|
6
|
+
* await walk({ baseUrl: "http://localhost:5173", apiHosts: ["localhost:8000"],
|
|
7
|
+
* outputDir: "e2e", output: "add_product.spec.ts", language: "typescript" }, async (w) => {
|
|
8
|
+
* await w.goto("/products");
|
|
9
|
+
* await w.click("page.getByRole('button', { name: 'Add Product' })");
|
|
10
|
+
* await w.fill("page.getByPlaceholder('e.g. Apple Macbook Pro')", "Desk Lamp");
|
|
11
|
+
* w.expectRequest(); // the next action's request body is checked in the test
|
|
12
|
+
* await w.click("page.getByRole('button', { name: 'Submit' })");
|
|
13
|
+
* await w.expectText("page.getByRole('heading').first()", "Product Details");
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* Other options: actionTimeoutMs (each action, default 10 s), checkTimeoutMs (each expect*, retried until it holds,
|
|
17
|
+
* default 5 s), navigationTimeoutMs (goto, default 30 s), settleMs (after each action, default 800 ms).
|
|
18
|
+
*
|
|
19
|
+
* When a step fails, the error names the step, its action and expression, the page URL and a short accessibility
|
|
20
|
+
* snapshot of the page to fix the selector from, and the steps recorded before it are written as a partial recording.
|
|
21
|
+
*
|
|
22
|
+
* Three things make Skyramp tie each action to the request it caused, and so write the request-body check:
|
|
23
|
+
* - each action is stamped when it starts, so the request it fires falls in its own window;
|
|
24
|
+
* - every API call passes through a route while recording, so its response body is kept even when the page navigates
|
|
25
|
+
* the moment it arrives (Playwright's HAR drops it otherwise, and the export keeps only entries with a JSON body);
|
|
26
|
+
* - expectRequest() records Skyramp's request-body marker before the action whose request matters.
|
|
27
|
+
* Generation needs @skyramp/skyramp 1.3.52 or later (SKYRAMP_WALK_SDK, else NODE_PATH).
|
|
28
|
+
*/
|
|
29
|
+
"use strict";
|
|
30
|
+
const path = require("path");
|
|
31
|
+
const fs = require("fs");
|
|
32
|
+
|
|
33
|
+
const req = (name, dirs) => {
|
|
34
|
+
for (const d of dirs.filter(Boolean)) {
|
|
35
|
+
try { return require(require.resolve(name, { paths: [d] })); } catch { /* next */ }
|
|
36
|
+
}
|
|
37
|
+
return require(name);
|
|
38
|
+
};
|
|
39
|
+
const roots = () => [process.env.SKYRAMP_WALK_SDK, ...(process.env.NODE_PATH || "").split(path.delimiter)];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The fork's Playwright, launching the Chromium walk.ts installed for it (SKYRAMP_WALK_BROWSERS_PATH) rather than
|
|
43
|
+
* whatever a repo's PLAYWRIGHT_BROWSERS_PATH points at. Playwright reads that variable once, as it loads.
|
|
44
|
+
*/
|
|
45
|
+
function loadPlaywright(load = () => req("playwright", roots())) {
|
|
46
|
+
const want = process.env.SKYRAMP_WALK_BROWSERS_PATH;
|
|
47
|
+
const prev = process.env.PLAYWRIGHT_BROWSERS_PATH;
|
|
48
|
+
if (want) process.env.PLAYWRIGHT_BROWSERS_PATH = want;
|
|
49
|
+
try {
|
|
50
|
+
return load();
|
|
51
|
+
} finally {
|
|
52
|
+
if (want) {
|
|
53
|
+
if (prev === undefined) delete process.env.PLAYWRIGHT_BROWSERS_PATH;
|
|
54
|
+
else process.env.PLAYWRIGHT_BROWSERS_PATH = prev;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const firstLines = (s, n) => String(s ?? "").split("\n").slice(0, n).join("\n");
|
|
60
|
+
|
|
61
|
+
/** Run attempt until it stops throwing or timeoutMs passes; attempt gets the time left. The last error is thrown. */
|
|
62
|
+
async function retryUntil(attempt, timeoutMs, { now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
63
|
+
const deadline = now() + timeoutMs;
|
|
64
|
+
const waits = [100, 250, 500, 1000];
|
|
65
|
+
for (let i = 0; ; i++) {
|
|
66
|
+
try {
|
|
67
|
+
return await attempt(Math.max(1, deadline - now()));
|
|
68
|
+
} catch (e) {
|
|
69
|
+
const left = deadline - now();
|
|
70
|
+
if (left <= 0) throw e;
|
|
71
|
+
await sleep(Math.min(waits[Math.min(i, waits.length - 1)], left));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The API route: fetch the request so its response body is kept, and fulfil it with that response. Navigations pass
|
|
78
|
+
* straight through, a redirect goes back to the browser to follow, and when the fetch fails (the page is closing, or
|
|
79
|
+
* Node cannot reach the server the browser can) the request continues untouched instead of failing the walk.
|
|
80
|
+
*/
|
|
81
|
+
function apiRoute(state, warn = (m) => process.stderr.write(`${m}\n`)) {
|
|
82
|
+
const pass = (route) => route.continue().catch(() => { /* already handled, or the page is gone */ });
|
|
83
|
+
return async (route) => {
|
|
84
|
+
const request = route.request();
|
|
85
|
+
if (state.closing || request.isNavigationRequest()) return pass(route);
|
|
86
|
+
let response;
|
|
87
|
+
try {
|
|
88
|
+
response = await route.fetch({ maxRedirects: 0 });
|
|
89
|
+
} catch (e) {
|
|
90
|
+
if (!state.closing) warn(`skyramp walk: ${request.method()} ${request.url()} went through without its response kept (${firstLines(e && e.message, 1)})`);
|
|
91
|
+
return pass(route);
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
await route.fulfill({ response, body: await response.body() });
|
|
95
|
+
} catch {
|
|
96
|
+
/* the page is gone; the request was made already, so it is not sent again */
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A step's failure, with what the agent needs to fix it: the step, the page URL and its accessibility snapshot. */
|
|
102
|
+
function stepFailure({ step, label, url, snapshot, error }, maxSnapshot = 3000) {
|
|
103
|
+
const snap = snapshot.length > maxSnapshot ? `${snapshot.slice(0, maxSnapshot)}\n… (${snapshot.length - maxSnapshot} more characters)` : snapshot;
|
|
104
|
+
const e = new Error(
|
|
105
|
+
`walk step ${step} failed: ${label}\n${firstLines(error && error.message, 6)}\npage: ${url || "(none)"}\n` +
|
|
106
|
+
`accessibility snapshot of the page (fix the selector from it):\n${snap.replace(/^/gm, " ")}`,
|
|
107
|
+
{ cause: error },
|
|
108
|
+
);
|
|
109
|
+
e.walkStep = step;
|
|
110
|
+
return e;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The fork's own serialisation of a locator expression ("page.getByTestId('x').first()"), as its export writes a
|
|
115
|
+
* click on it: { expr, selector, locator }, or undefined when the export cannot express it. The export's assertions
|
|
116
|
+
* map only test-id, role and text selectors, so each assertion's locator comes from here instead.
|
|
117
|
+
*/
|
|
118
|
+
function forkLocator(exp, expr) {
|
|
119
|
+
if (!expr || !/^page\./.test(expr)) return undefined;
|
|
120
|
+
const { jsonl } = exp.buildJsonlContent([{ toolName: "browser_click", args: {}, code: `await ${expr}.click();`, timestamp: 1, pageAlias: "page" }], "chromium", "");
|
|
121
|
+
for (const line of jsonl.split("\n")) {
|
|
122
|
+
if (!line) continue;
|
|
123
|
+
const o = JSON.parse(line);
|
|
124
|
+
if (o.name === "click" && o.locator && o.locator.kind) return { expr, selector: o.selector, locator: o.locator };
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The locator in a form the export can express: Playwright's own print of it (single-quoted, which is what the export
|
|
131
|
+
* parses), else the agent's expression as written; undefined when neither. The export drops what it cannot parse (a
|
|
132
|
+
* filter, a role's level) without a word, so a form counts only when its selector is the live locator's own.
|
|
133
|
+
*/
|
|
134
|
+
function exportable(exp, expr, loc) {
|
|
135
|
+
let canonical;
|
|
136
|
+
let live;
|
|
137
|
+
try {
|
|
138
|
+
if (loc && typeof loc._selector === "string") {
|
|
139
|
+
live = loc._selector;
|
|
140
|
+
canonical = `page.${String(loc)}`;
|
|
141
|
+
}
|
|
142
|
+
} catch { /* not a locator */ }
|
|
143
|
+
for (const form of [canonical, expr]) {
|
|
144
|
+
const got = forkLocator(exp, form);
|
|
145
|
+
if (got && (live === undefined || got.selector === live)) return got;
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ASSERTS = new Set(["assertText", "assertVisible", "assertValue", "assertCount", "assertChecked", "assertDisabled"]);
|
|
151
|
+
|
|
152
|
+
/** Give each assertion line the locator recorded for it (found by its timestamp) in place of the export's mapping. */
|
|
153
|
+
function patchAssertLocators(jsonl, byTimestamp) {
|
|
154
|
+
return jsonl
|
|
155
|
+
.split("\n")
|
|
156
|
+
.map((line) => {
|
|
157
|
+
if (!line) return line;
|
|
158
|
+
const o = JSON.parse(line);
|
|
159
|
+
const got = ASSERTS.has(o.name) && byTimestamp.get(Number(o.timestamp));
|
|
160
|
+
return got ? JSON.stringify({ ...o, selector: got.selector, locator: got.locator }) : line;
|
|
161
|
+
})
|
|
162
|
+
.join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const EXPORTABLE = "getByTestId, getByRole, getByText, getByLabel, getByPlaceholder, getByAltText, getByTitle, locator('css'), chains of these, .first() and .nth(n)";
|
|
166
|
+
|
|
167
|
+
let generating = Promise.resolve();
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Run `fn` with the walk's credential in this process's env (a run token from SKYRAMP_WALK_TOKEN_FILE, else
|
|
171
|
+
* SKYRAMP_WALK_LICENSE_FILE, else a local SKYRAMP_LICENSE_FILE), and restore the env after it. Calls run one at a
|
|
172
|
+
* time, so walks started together never see another's restore.
|
|
173
|
+
*/
|
|
174
|
+
function withCredential(fn) {
|
|
175
|
+
const run = async () => {
|
|
176
|
+
const saved = { LICENSE_FILE: process.env.LICENSE_FILE, SKYRAMP_RUN_TOKEN: process.env.SKYRAMP_RUN_TOKEN, CI: process.env.CI };
|
|
177
|
+
const tokenFile = process.env.SKYRAMP_WALK_TOKEN_FILE;
|
|
178
|
+
const licence = process.env.SKYRAMP_WALK_LICENSE_FILE || (!process.env.LICENSE_FILE && process.env.SKYRAMP_LICENSE_FILE);
|
|
179
|
+
if (tokenFile) process.env.SKYRAMP_RUN_TOKEN = fs.readFileSync(tokenFile, "utf-8").trim();
|
|
180
|
+
else if (licence) process.env.LICENSE_FILE = licence;
|
|
181
|
+
if (tokenFile || licence) process.env.CI = "true";
|
|
182
|
+
try {
|
|
183
|
+
return await fn();
|
|
184
|
+
} finally {
|
|
185
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
186
|
+
if (v === undefined) delete process.env[k];
|
|
187
|
+
else process.env[k] = v;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const next = generating.then(run, run);
|
|
192
|
+
generating = next.catch(() => {});
|
|
193
|
+
return next;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function walk(opts, body) {
|
|
197
|
+
const { chromium } = loadPlaywright();
|
|
198
|
+
const exp = req("playwright/lib/mcp/skyramp", roots());
|
|
199
|
+
const { SkyrampClient } = req("@skyramp/skyramp", roots());
|
|
200
|
+
const scratch = fs.mkdtempSync(path.join(require("os").tmpdir(), "skyramp-walk-"));
|
|
201
|
+
const zip = opts.zip || path.join(scratch, "walk.zip");
|
|
202
|
+
const har = path.join(scratch, "walk.har");
|
|
203
|
+
const checkTimeout = opts.checkTimeoutMs ?? 5_000;
|
|
204
|
+
const browser = await chromium.launch({ headless: opts.headless !== false });
|
|
205
|
+
const context = await browser.newContext({ recordHar: { path: har, mode: "minimal" }, viewport: { width: 1280, height: 720 }, ...(opts.storageState ? { storageState: opts.storageState } : {}) });
|
|
206
|
+
context.setDefaultTimeout(opts.actionTimeoutMs ?? 10_000);
|
|
207
|
+
context.setDefaultNavigationTimeout(opts.navigationTimeoutMs ?? 30_000);
|
|
208
|
+
const state = { closing: false };
|
|
209
|
+
const hosts = opts.apiHosts || [];
|
|
210
|
+
if (hosts.length)
|
|
211
|
+
// A pattern, not a predicate: the URL-predicate form did not intercept these requests in the Skyramp fork.
|
|
212
|
+
await context.route(new RegExp(`^https?://(?:${hosts.map((h) => h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(?:[/?#]|$)`), apiRoute(state));
|
|
213
|
+
const page = await context.newPage();
|
|
214
|
+
const actions = [];
|
|
215
|
+
const checks = new Map();
|
|
216
|
+
const leftOut = [];
|
|
217
|
+
let last = 0;
|
|
218
|
+
// Timestamps are unique, so each assertion line can be found again in the export by its own.
|
|
219
|
+
const push = (toolName, args, code = "", timestamp = Date.now()) => {
|
|
220
|
+
const t = Math.max(timestamp, last + 1);
|
|
221
|
+
last = t;
|
|
222
|
+
actions.push({ toolName, args, code, timestamp: t, pageAlias: "page" });
|
|
223
|
+
return t;
|
|
224
|
+
};
|
|
225
|
+
const omit = (k, what, expr) => {
|
|
226
|
+
leftOut.push({ step: k, expr });
|
|
227
|
+
process.stderr.write(`skyramp walk: step ${k}: Skyramp's export cannot write ${what} on ${expr}, so the generated test leaves it out; use ${EXPORTABLE}, or add it to the test by hand\n`);
|
|
228
|
+
};
|
|
229
|
+
const at = (expr) => new Function("page", `return (${expr});`)(page);
|
|
230
|
+
const settle = () => page.waitForTimeout(opts.settleMs ?? 800);
|
|
231
|
+
let n = 0;
|
|
232
|
+
const step = async (label, run) => {
|
|
233
|
+
const k = ++n;
|
|
234
|
+
try {
|
|
235
|
+
return await run(k);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
let url = "";
|
|
238
|
+
let snapshot = "";
|
|
239
|
+
try { url = page.url(); } catch { /* closed */ }
|
|
240
|
+
try { snapshot = await page.locator("body").ariaSnapshot({ timeout: 2000 }); } catch (s) { snapshot = `(no snapshot: ${firstLines(s && s.message, 1)})`; }
|
|
241
|
+
throw stepFailure({ step: k, label, url, snapshot, error });
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const act = (toolName, expr, verb, args, run) =>
|
|
245
|
+
step(`${verb.replace(/\(.*$/s, "")} ${expr}`, async (k) => {
|
|
246
|
+
const loc = at(expr);
|
|
247
|
+
const mapped = exportable(exp, expr, loc);
|
|
248
|
+
if (!mapped) omit(k, "this action", expr);
|
|
249
|
+
const t = Date.now();
|
|
250
|
+
await run(loc);
|
|
251
|
+
push(toolName, args, `await ${mapped ? mapped.expr : expr}.${verb};`, t);
|
|
252
|
+
await settle();
|
|
253
|
+
});
|
|
254
|
+
const url = (p) => (/^https?:/.test(p) ? p : `${(opts.baseUrl || "").replace(/\/$/, "")}${p.startsWith("/") ? "" : "/"}${p}`);
|
|
255
|
+
// Each check is made now, against the live page and retried like Playwright's expect, so a walk that records one the app does not meet fails here.
|
|
256
|
+
const assert = (name, type, expr, expected, check) =>
|
|
257
|
+
step(`${name}(${expr}${expected !== undefined ? `, ${JSON.stringify(expected)}` : ""})`, async (k) => {
|
|
258
|
+
const loc = at(expr);
|
|
259
|
+
try {
|
|
260
|
+
// Each attempt waits up to a second for the element; never so briefly that its timeout hides what the check saw.
|
|
261
|
+
await retryUntil((left) => check(loc, Math.max(100, Math.min(left, 1000))), checkTimeout);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
throw new Error(`not met within ${checkTimeout} ms: ${firstLines(e && e.message, 3)}`, { cause: e });
|
|
264
|
+
}
|
|
265
|
+
const mapped = exportable(exp, expr, loc);
|
|
266
|
+
if (!mapped) return omit(k, "this check", expr);
|
|
267
|
+
checks.set(push("browser_assert", { type, selector: mapped.selector, ...(expected !== undefined ? { expected: String(expected) } : {}) }), mapped);
|
|
268
|
+
});
|
|
269
|
+
const w = {
|
|
270
|
+
page,
|
|
271
|
+
goto: (p) => step(`goto ${url(p)}`, async () => { const t = Date.now(); await page.goto(url(p)); push("browser_navigate", { url: url(p) }, "", t); await settle(); }),
|
|
272
|
+
click: (expr) => act("browser_click", expr, "click()", {}, (l) => l.click()),
|
|
273
|
+
fill: (expr, text) => act("browser_type", expr, `fill(${JSON.stringify(text)})`, { text }, (l) => l.fill(text)),
|
|
274
|
+
select: (expr, value) => act("browser_select_option", expr, `selectOption(${JSON.stringify(value)})`, { values: [value] }, (l) => l.selectOption(value)),
|
|
275
|
+
press: (expr, key) => act("browser_press_key", expr, `press(${JSON.stringify(key)})`, { key }, (l) => l.press(key)),
|
|
276
|
+
expectRequest: () => { push("browser_assert_api_request", {}); },
|
|
277
|
+
expectText: (expr, text) => assert("expectText", "text", expr, text, async (l, timeout) => { const got = (await l.textContent({ timeout })) || ""; if (!got.includes(text)) throw new Error(`expected it to contain ${JSON.stringify(text)}, got ${JSON.stringify(got.trim().slice(0, 200))}`); }),
|
|
278
|
+
expectVisible: (expr) => assert("expectVisible", "visible", expr, undefined, async (l) => { if (!(await l.isVisible())) throw new Error("expected it to be visible; it is not"); }),
|
|
279
|
+
expectValue: (expr, value) => assert("expectValue", "value", expr, value, async (l, timeout) => { const got = await l.inputValue({ timeout }); if (got !== String(value)) throw new Error(`expected value ${JSON.stringify(String(value))}, got ${JSON.stringify(got)}`); }),
|
|
280
|
+
expectCount: (expr, count) => assert("expectCount", "count", expr, count, async (l) => { const got = await l.count(); if (got !== count) throw new Error(`expected ${count} matching, got ${got}`); }),
|
|
281
|
+
};
|
|
282
|
+
let failure;
|
|
283
|
+
try {
|
|
284
|
+
await body(w);
|
|
285
|
+
} catch (e) {
|
|
286
|
+
failure = e;
|
|
287
|
+
}
|
|
288
|
+
// Requests still in flight fail once the context closes; none of that may replace the walk's own error.
|
|
289
|
+
state.closing = true;
|
|
290
|
+
await context.unrouteAll({ behavior: "ignoreErrors" }).catch(() => {});
|
|
291
|
+
await context.close().catch((e) => { failure = failure || e; });
|
|
292
|
+
await browser.close().catch(() => {});
|
|
293
|
+
const record = async () => {
|
|
294
|
+
const jsonl = patchAssertLocators(exp.buildJsonlContent(actions, "chromium", har).jsonl, checks);
|
|
295
|
+
await exp.writeSkyrampZip(zip, jsonl, har);
|
|
296
|
+
return jsonl;
|
|
297
|
+
};
|
|
298
|
+
if (failure) {
|
|
299
|
+
try {
|
|
300
|
+
const partial = path.join(scratch, "partial.jsonl");
|
|
301
|
+
fs.writeFileSync(partial, await record());
|
|
302
|
+
process.stderr.write(`skyramp walk: the recording up to the failure (actions recorded: ${actions.length}) is in ${partial} (zip: ${zip})\n`);
|
|
303
|
+
} catch { /* the walk's own error is the one that matters */ }
|
|
304
|
+
throw failure;
|
|
305
|
+
}
|
|
306
|
+
await record();
|
|
307
|
+
const result = await withCredential(() => new SkyrampClient().generateRestTest({
|
|
308
|
+
testType: "ui",
|
|
309
|
+
language: opts.language || "typescript",
|
|
310
|
+
framework: "playwright",
|
|
311
|
+
outputDir: path.resolve(opts.outputDir || "."),
|
|
312
|
+
output: opts.output,
|
|
313
|
+
playwrightInput: zip,
|
|
314
|
+
generateInclude: hosts,
|
|
315
|
+
force: true,
|
|
316
|
+
}));
|
|
317
|
+
if (!/success/i.test(String(result))) throw new Error(`Skyramp generation failed: ${result}`);
|
|
318
|
+
return { zip, test: path.resolve(opts.outputDir || ".", opts.output), actions: actions.length, ...(leftOut.length ? { leftOut } : {}) };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
module.exports = { walk, _internal: { withCredential, retryUntil, apiRoute, stepFailure, forkLocator, exportable, patchAssertLocators, loadPlaywright } };
|
package/dist/VERSION.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.13.0-v3.
|
|
1
|
+
{"version":"0.13.0-v3.181","commit":"301a6fa10fd353e0dd1a5c52f8f0d3bac25d952e"}
|