@guanlin4924/devflow-pi 0.1.96 → 0.1.98
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/README.md +11 -1
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +697 -91
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +18 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +6 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +64 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +52 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +2 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/explore_preflight.py +58 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/locator_map.py +156 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +50 -15
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +60 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +167 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +5 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +262 -56
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +126 -0
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +2 -0
- package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +103 -88
- package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +1015 -0
- package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +297 -15
- package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +3 -2
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +4 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +12 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +6 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +3 -1
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +4 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +98 -15
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py +73 -0
- package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +72 -0
- package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +1 -1
- package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +1 -1
- package/src/constants.ts +1 -1
- package/src/startup-briefing.ts +109 -0
|
@@ -0,0 +1,1015 @@
|
|
|
1
|
+
// plugins/devflow-quality/runtime/playwright/src/explore/host.ts
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
import fs2 from "node:fs";
|
|
4
|
+
import path2 from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { chromium } from "@playwright/test";
|
|
7
|
+
|
|
8
|
+
// plugins/devflow-quality/runtime/playwright/src/network.ts
|
|
9
|
+
var SUBRESOURCE_POLICIES = new Set(["same-site-plus-declared", "declared-only"]);
|
|
10
|
+
var IMPLICIT_SCHEMES = new Set(["data:", "blob:", "about:"]);
|
|
11
|
+
|
|
12
|
+
class ProfileError extends Error {
|
|
13
|
+
code = "environment_profile_invalid";
|
|
14
|
+
}
|
|
15
|
+
function normalizeOrigin(value, field) {
|
|
16
|
+
let u;
|
|
17
|
+
try {
|
|
18
|
+
u = new URL(value);
|
|
19
|
+
} catch {
|
|
20
|
+
throw new ProfileError(`${field}: not an absolute URL: ${value}`);
|
|
21
|
+
}
|
|
22
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
23
|
+
throw new ProfileError(`${field}: only http(s) origins allowed: ${value}`);
|
|
24
|
+
return u.origin;
|
|
25
|
+
}
|
|
26
|
+
function buildPolicy(profile) {
|
|
27
|
+
if (!profile || typeof profile !== "object")
|
|
28
|
+
throw new ProfileError("profile must be a JSON object");
|
|
29
|
+
if (typeof profile.base_url !== "string")
|
|
30
|
+
throw new ProfileError("base_url is required");
|
|
31
|
+
const listOf = (value, field) => {
|
|
32
|
+
if (value === undefined)
|
|
33
|
+
return [];
|
|
34
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string"))
|
|
35
|
+
throw new ProfileError(`${field} must be an array of strings`);
|
|
36
|
+
return value;
|
|
37
|
+
};
|
|
38
|
+
const navigation = new Set(listOf(profile.navigation_origins, "navigation_origins").map((o) => normalizeOrigin(o, "navigation_origins")));
|
|
39
|
+
const api = new Set(listOf(profile.api_origins, "api_origins").map((o) => normalizeOrigin(o, "api_origins")));
|
|
40
|
+
if (navigation.size === 0)
|
|
41
|
+
throw new ProfileError("navigation_origins must not be empty");
|
|
42
|
+
const policy = profile.subresources?.policy ?? "same-site-plus-declared";
|
|
43
|
+
if (!SUBRESOURCE_POLICIES.has(policy))
|
|
44
|
+
throw new ProfileError(`unknown subresources.policy ${policy}`);
|
|
45
|
+
const subresourceOrigins = new Set(listOf(profile.subresources?.allowed_origins, "subresources.allowed_origins").map((o) => normalizeOrigin(o, "subresources.allowed_origins")));
|
|
46
|
+
const baseUrl = new URL(profile.base_url).toString();
|
|
47
|
+
if (!navigation.has(new URL(baseUrl).origin))
|
|
48
|
+
throw new ProfileError("base_url origin must be listed in navigation_origins");
|
|
49
|
+
const apiBaseUrl = profile.api_base_url ? new URL(profile.api_base_url).toString() : baseUrl;
|
|
50
|
+
return { baseUrl, apiBaseUrl, navigation, api, subresourcePolicy: policy, subresourceOrigins };
|
|
51
|
+
}
|
|
52
|
+
function sameSite(a, b) {
|
|
53
|
+
if (a.protocol !== b.protocol)
|
|
54
|
+
return false;
|
|
55
|
+
const ha = a.hostname;
|
|
56
|
+
const hb = b.hostname;
|
|
57
|
+
return ha === hb || ha.endsWith(`.${hb}`) || hb.endsWith(`.${ha}`);
|
|
58
|
+
}
|
|
59
|
+
function isAllowed(policy, rawUrl, kind) {
|
|
60
|
+
let u;
|
|
61
|
+
try {
|
|
62
|
+
u = new URL(rawUrl);
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
if (IMPLICIT_SCHEMES.has(u.protocol))
|
|
67
|
+
return kind === "subresource";
|
|
68
|
+
if (kind === "navigation")
|
|
69
|
+
return policy.navigation.has(u.origin);
|
|
70
|
+
if (kind === "api")
|
|
71
|
+
return policy.api.has(u.origin);
|
|
72
|
+
if (policy.navigation.has(u.origin) || policy.api.has(u.origin) || policy.subresourceOrigins.has(u.origin))
|
|
73
|
+
return true;
|
|
74
|
+
if (policy.subresourcePolicy === "same-site-plus-declared") {
|
|
75
|
+
for (const origin of policy.navigation)
|
|
76
|
+
if (sameSite(u, new URL(origin)))
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
function isTopLevelNavigation(request) {
|
|
82
|
+
try {
|
|
83
|
+
return request.isNavigationRequest() && request.frame().parentFrame() === null;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
class NetworkRecorder {
|
|
90
|
+
redactor;
|
|
91
|
+
entries = [];
|
|
92
|
+
violations = [];
|
|
93
|
+
starts = new WeakMap;
|
|
94
|
+
constructor(redactor) {
|
|
95
|
+
this.redactor = redactor;
|
|
96
|
+
}
|
|
97
|
+
api(entry) {
|
|
98
|
+
this.entries.push({ source: "api", ...entry, url: this.redactor.url(entry.url) });
|
|
99
|
+
}
|
|
100
|
+
violation(url, kind) {
|
|
101
|
+
this.violations.push({ url: this.redactor.url(url), kind, at: Date.now() });
|
|
102
|
+
}
|
|
103
|
+
async install(context, policy) {
|
|
104
|
+
await context.route("**/*", async (route) => {
|
|
105
|
+
const request = route.request();
|
|
106
|
+
const topLevel = isTopLevelNavigation(request);
|
|
107
|
+
const kind = topLevel ? "navigation" : "subresource";
|
|
108
|
+
if (isAllowed(policy, request.url(), kind)) {
|
|
109
|
+
await route.continue();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
this.violation(request.url(), kind);
|
|
113
|
+
this.entries.push({
|
|
114
|
+
source: "page",
|
|
115
|
+
method: request.method(),
|
|
116
|
+
url: this.redactor.url(request.url()),
|
|
117
|
+
status: null,
|
|
118
|
+
duration_ms: null,
|
|
119
|
+
blocked: true,
|
|
120
|
+
resource_type: request.resourceType()
|
|
121
|
+
});
|
|
122
|
+
await route.abort("blockedbyclient");
|
|
123
|
+
});
|
|
124
|
+
context.on("request", (request) => {
|
|
125
|
+
this.starts.set(request, Date.now());
|
|
126
|
+
if (request.redirectedFrom()) {
|
|
127
|
+
const topLevel = isTopLevelNavigation(request);
|
|
128
|
+
const kind = topLevel ? "navigation" : "subresource";
|
|
129
|
+
if (!isAllowed(policy, request.url(), kind))
|
|
130
|
+
this.violation(request.url(), topLevel ? "navigation" : kind);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
const finish = async (request, failure) => {
|
|
134
|
+
const started = this.starts.get(request);
|
|
135
|
+
if (failure === "net::ERR_BLOCKED_BY_CLIENT")
|
|
136
|
+
return;
|
|
137
|
+
let status = null;
|
|
138
|
+
try {
|
|
139
|
+
status = (await request.response())?.status() ?? null;
|
|
140
|
+
} catch {
|
|
141
|
+
status = null;
|
|
142
|
+
}
|
|
143
|
+
this.entries.push({
|
|
144
|
+
source: "page",
|
|
145
|
+
method: request.method(),
|
|
146
|
+
url: this.redactor.url(request.url()),
|
|
147
|
+
status,
|
|
148
|
+
duration_ms: started ? Date.now() - started : null,
|
|
149
|
+
blocked: false,
|
|
150
|
+
resource_type: request.resourceType(),
|
|
151
|
+
...failure ? { failure: this.redactor.text(failure) } : {}
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
context.on("requestfinished", (request) => void finish(request));
|
|
155
|
+
context.on("requestfailed", (request) => void finish(request, request.failure()?.errorText ?? "failed"));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// plugins/devflow-quality/runtime/playwright/src/redact.ts
|
|
160
|
+
var REDACTED = "***";
|
|
161
|
+
var SENSITIVE_KEY = /(pass(word|wd)?|secret|token|auth|session|cookie|api[-_]?key|signature|sig|credential|private)/i;
|
|
162
|
+
var MAX_ERROR_LENGTH = 2000;
|
|
163
|
+
var ANSI = /\u001b\[[0-9;]*[A-Za-z]/g;
|
|
164
|
+
|
|
165
|
+
class Redactor {
|
|
166
|
+
values = [];
|
|
167
|
+
add(value) {
|
|
168
|
+
if (value === undefined || value === null)
|
|
169
|
+
return;
|
|
170
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
171
|
+
if (!text)
|
|
172
|
+
return;
|
|
173
|
+
if (!this.values.includes(text)) {
|
|
174
|
+
this.values.push(text);
|
|
175
|
+
this.values.sort((a, b) => b.length - a.length);
|
|
176
|
+
}
|
|
177
|
+
for (const encoded of [encodeURIComponent(text), new URLSearchParams({ v: text }).toString().slice(2)]) {
|
|
178
|
+
if (encoded !== text && !this.values.includes(encoded))
|
|
179
|
+
this.values.push(encoded);
|
|
180
|
+
}
|
|
181
|
+
this.values.sort((a, b) => b.length - a.length);
|
|
182
|
+
}
|
|
183
|
+
list() {
|
|
184
|
+
return [...this.values];
|
|
185
|
+
}
|
|
186
|
+
text(input) {
|
|
187
|
+
let out = input;
|
|
188
|
+
for (const v of this.values)
|
|
189
|
+
out = out.split(v).join(REDACTED);
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
url(input) {
|
|
193
|
+
let masked = input;
|
|
194
|
+
try {
|
|
195
|
+
const u = new URL(input);
|
|
196
|
+
if (u.username || u.password) {
|
|
197
|
+
u.username = u.username ? REDACTED : "";
|
|
198
|
+
u.password = u.password ? REDACTED : "";
|
|
199
|
+
}
|
|
200
|
+
for (const key of [...u.searchParams.keys()]) {
|
|
201
|
+
if (SENSITIVE_KEY.test(key))
|
|
202
|
+
u.searchParams.set(key, REDACTED);
|
|
203
|
+
}
|
|
204
|
+
masked = u.toString();
|
|
205
|
+
} catch {}
|
|
206
|
+
return this.text(masked);
|
|
207
|
+
}
|
|
208
|
+
value(input) {
|
|
209
|
+
if (typeof input === "string")
|
|
210
|
+
return this.text(input);
|
|
211
|
+
if (Array.isArray(input))
|
|
212
|
+
return input.map((v) => this.value(v));
|
|
213
|
+
if (input && typeof input === "object") {
|
|
214
|
+
return Object.fromEntries(Object.entries(input).map(([k, v]) => [k, SENSITIVE_KEY.test(k) ? REDACTED : this.value(v)]));
|
|
215
|
+
}
|
|
216
|
+
if (input !== undefined && input !== null && this.values.includes(String(input)))
|
|
217
|
+
return REDACTED;
|
|
218
|
+
return input;
|
|
219
|
+
}
|
|
220
|
+
error(err) {
|
|
221
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
222
|
+
const text = this.text(raw.replace(ANSI, ""));
|
|
223
|
+
return text.length > MAX_ERROR_LENGTH ? `${text.slice(0, MAX_ERROR_LENGTH)}…` : text;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// plugins/devflow-quality/runtime/playwright/src/explore/view.ts
|
|
228
|
+
var INTERACTIVE = new Set(["button", "link", "textbox", "searchbox", "checkbox", "radio", "combobox", "listbox", "option", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "switch", "slider", "spinbutton", "treeitem"]);
|
|
229
|
+
var NAMEABLE = new Set(["dialog", "alertdialog", "region", "form", "navigation", ...INTERACTIVE]);
|
|
230
|
+
var INFO = new Set(["heading", "alert", "status", "dialog", "alertdialog"]);
|
|
231
|
+
var CONTEXT = new Set(["dialog", "alertdialog", "form", "navigation", "banner", "main", "region", "table", "row", "tabpanel", "menu"]);
|
|
232
|
+
var TEXT_CONTAINERS = new Set(["dialog", "alertdialog", "alert", "status"]);
|
|
233
|
+
var LINE = /^(\s*)- (.*)$/;
|
|
234
|
+
function colonOutsideQuotes(s) {
|
|
235
|
+
let quoted = false;
|
|
236
|
+
for (let i = 0;i < s.length; i++) {
|
|
237
|
+
if (s[i] === "\\") {
|
|
238
|
+
i++;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (s[i] === '"')
|
|
242
|
+
quoted = !quoted;
|
|
243
|
+
else if (s[i] === ":" && !quoted && (i + 1 === s.length || s[i + 1] === " "))
|
|
244
|
+
return i;
|
|
245
|
+
}
|
|
246
|
+
return -1;
|
|
247
|
+
}
|
|
248
|
+
function parseSnapshot(yaml) {
|
|
249
|
+
const nodes = [];
|
|
250
|
+
const stack = [];
|
|
251
|
+
for (const raw of yaml.split(`
|
|
252
|
+
`)) {
|
|
253
|
+
const match = LINE.exec(raw);
|
|
254
|
+
if (!match)
|
|
255
|
+
continue;
|
|
256
|
+
const indent = match[1].length;
|
|
257
|
+
while (stack.length && stack[stack.length - 1].indent >= indent)
|
|
258
|
+
stack.pop();
|
|
259
|
+
const parent = stack.length ? stack[stack.length - 1].node : null;
|
|
260
|
+
let body = match[2];
|
|
261
|
+
if (body.startsWith("/"))
|
|
262
|
+
continue;
|
|
263
|
+
const node = { role: "", name: "", attrs: {}, value: "", ref: "", parent, ctx: "" };
|
|
264
|
+
if (body.startsWith("text:")) {
|
|
265
|
+
node.role = "text";
|
|
266
|
+
node.value = body.slice(5).trim();
|
|
267
|
+
} else {
|
|
268
|
+
const colon = colonOutsideQuotes(body);
|
|
269
|
+
if (colon >= 0) {
|
|
270
|
+
node.value = body.slice(colon + 1).trim().replace(/^"|"$/g, "");
|
|
271
|
+
body = body.slice(0, colon);
|
|
272
|
+
}
|
|
273
|
+
node.role = body.split(/[\s[]/, 1)[0];
|
|
274
|
+
const name = body.match(/^\S+ "((?:[^"\\]|\\.)*)"/);
|
|
275
|
+
if (name)
|
|
276
|
+
node.name = name[1].replace(/\\"/g, '"');
|
|
277
|
+
for (const [, key, value] of body.matchAll(/\[([\w-]+)(?:=([^\]]*))?\]/g))
|
|
278
|
+
node.attrs[key] = value ?? true;
|
|
279
|
+
node.ref = typeof node.attrs.ref === "string" ? node.attrs.ref : "";
|
|
280
|
+
delete node.attrs.ref;
|
|
281
|
+
delete node.attrs.cursor;
|
|
282
|
+
delete node.attrs.active;
|
|
283
|
+
}
|
|
284
|
+
nodes.push(node);
|
|
285
|
+
stack.push({ indent, node });
|
|
286
|
+
}
|
|
287
|
+
return nodes;
|
|
288
|
+
}
|
|
289
|
+
function ancestors(node) {
|
|
290
|
+
const out = [];
|
|
291
|
+
for (let p = node.parent;p; p = p.parent)
|
|
292
|
+
out.push(p);
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
function prune(nodes, redactor, passwordRefs = new Set) {
|
|
296
|
+
const view = new Map;
|
|
297
|
+
let textSeq = 0;
|
|
298
|
+
const mask = (value) => redactor ? redactor.text(value) : value;
|
|
299
|
+
for (const node of nodes) {
|
|
300
|
+
const anc = ancestors(node);
|
|
301
|
+
const inText = anc.some((a) => TEXT_CONTAINERS.has(a.role));
|
|
302
|
+
if (!(INTERACTIVE.has(node.role) || INFO.has(node.role) || inText && (node.role === "text" || node.role === "paragraph") && node.value))
|
|
303
|
+
continue;
|
|
304
|
+
const ctx = anc.filter((a) => CONTEXT.has(a.role)).reverse().map((a) => a.name ? `${a.role} "${mask(a.name)}"` : a.role).join(" > ");
|
|
305
|
+
const copy = { ...node, attrs: { ...node.attrs }, name: mask(node.name), value: mask(node.value), ctx };
|
|
306
|
+
if (passwordRefs.has(node.ref))
|
|
307
|
+
copy.value = REDACTED;
|
|
308
|
+
if (redactor)
|
|
309
|
+
copy.value = redactor.text(copy.value);
|
|
310
|
+
view.set(node.ref || `text#${anc[0]?.ref ?? "root"}#${textSeq++}`, copy);
|
|
311
|
+
}
|
|
312
|
+
return view;
|
|
313
|
+
}
|
|
314
|
+
function format(node, redactor) {
|
|
315
|
+
const mask = (value) => redactor?.text(value) ?? value;
|
|
316
|
+
const attrs = Object.entries(node.attrs).map(([key, value]) => value === true ? `[${key}]` : `[${key}=${mask(value)}]`).join(" ");
|
|
317
|
+
const parts = [node.role];
|
|
318
|
+
if (node.name)
|
|
319
|
+
parts.push(`"${mask(node.name)}"`);
|
|
320
|
+
if (attrs)
|
|
321
|
+
parts.push(attrs);
|
|
322
|
+
if (node.ref)
|
|
323
|
+
parts.push(`[ref=${node.ref}]`);
|
|
324
|
+
let result = parts.join(" ");
|
|
325
|
+
if (node.value)
|
|
326
|
+
result += `: ${mask(node.value)}`;
|
|
327
|
+
if (node.ctx)
|
|
328
|
+
result += ` (in ${mask(node.ctx)})`;
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
var signature = (node) => JSON.stringify([node.role, node.name, node.attrs, node.value, node.ctx]);
|
|
332
|
+
function diffViews(previous, next, redactor) {
|
|
333
|
+
const lines = [];
|
|
334
|
+
const added = [];
|
|
335
|
+
for (const [key, node] of next) {
|
|
336
|
+
const old = previous.get(key);
|
|
337
|
+
if (!old)
|
|
338
|
+
added.push(node);
|
|
339
|
+
else if (signature(old) !== signature(node))
|
|
340
|
+
lines.push(`~ ${format(old, redactor)}
|
|
341
|
+
→ ${format(node, redactor)}`);
|
|
342
|
+
}
|
|
343
|
+
const removed = [...previous].filter(([key]) => !next.has(key)).map(([, node]) => node);
|
|
344
|
+
for (const node of added) {
|
|
345
|
+
const i = removed.findIndex((old) => old.role === node.role && old.ctx === node.ctx && old.role !== "text");
|
|
346
|
+
if (i >= 0)
|
|
347
|
+
lines.push(`~ ${format(removed.splice(i, 1)[0], redactor)}
|
|
348
|
+
→ ${format(node, redactor)}`);
|
|
349
|
+
else
|
|
350
|
+
lines.push(`+ ${format(node, redactor)}`);
|
|
351
|
+
}
|
|
352
|
+
for (const node of removed)
|
|
353
|
+
lines.push(`- ${format(node, redactor)}`);
|
|
354
|
+
return lines;
|
|
355
|
+
}
|
|
356
|
+
async function passwordRefs(page, nodes) {
|
|
357
|
+
const out = new Set;
|
|
358
|
+
for (const node of nodes)
|
|
359
|
+
if (node.ref && (node.role === "textbox" || node.role === "searchbox")) {
|
|
360
|
+
const locator = page.locator(`aria-ref=${node.ref}`);
|
|
361
|
+
if (await locator.count() === 1 && (await locator.getAttribute("type"))?.toLowerCase() === "password")
|
|
362
|
+
out.add(node.ref);
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
async function nameRows(page, nodes) {
|
|
367
|
+
for (const node of nodes.filter((item) => item.role === "row" && item.ref)) {
|
|
368
|
+
const row = page.locator(`aria-ref=${node.ref}`);
|
|
369
|
+
if (await row.count() !== 1)
|
|
370
|
+
continue;
|
|
371
|
+
const cells = row.locator(":scope > td:visible, :scope > th:visible");
|
|
372
|
+
for (let i = 0;i < await cells.count(); i++) {
|
|
373
|
+
const text = (await cells.nth(i).innerText()).replace(/\s+/g, " ").trim();
|
|
374
|
+
if (text) {
|
|
375
|
+
node.name = text.slice(0, 40);
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
for (const node of nodes.filter((item) => item.ref)) {
|
|
381
|
+
const rowNode = [node, ...ancestors(node)].find((item) => item.role === "row");
|
|
382
|
+
if (!rowNode || rowNode.name)
|
|
383
|
+
continue;
|
|
384
|
+
const row = page.locator(`aria-ref=${node.ref}`).locator("xpath=ancestor::tr[1]");
|
|
385
|
+
if (await row.count() !== 1)
|
|
386
|
+
continue;
|
|
387
|
+
const cells = row.locator(":scope > td:visible, :scope > th:visible");
|
|
388
|
+
for (let i = 0;i < await cells.count(); i++) {
|
|
389
|
+
const text = (await cells.nth(i).innerText()).replace(/\s+/g, " ").trim();
|
|
390
|
+
if (text) {
|
|
391
|
+
rowNode.name = text.slice(0, 40);
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async function nameContainers(page, nodes) {
|
|
398
|
+
for (const node of nodes.filter((item) => item.ref && !item.name && NAMEABLE.has(item.role))) {
|
|
399
|
+
const element = page.locator(`aria-ref=${node.ref}`);
|
|
400
|
+
if (await element.count() !== 1)
|
|
401
|
+
continue;
|
|
402
|
+
const name = await element.evaluate((item) => {
|
|
403
|
+
const ariaLabel = item.getAttribute("aria-label")?.trim();
|
|
404
|
+
if (ariaLabel)
|
|
405
|
+
return ariaLabel.replace(/\s+/g, " ").trim();
|
|
406
|
+
const ids = item.getAttribute("aria-labelledby")?.trim().split(/\s+/).filter(Boolean) ?? [];
|
|
407
|
+
return ids.map((id) => document.getElementById(id)?.textContent ?? "").join(" ").replace(/\s+/g, " ").trim();
|
|
408
|
+
});
|
|
409
|
+
if (!name)
|
|
410
|
+
continue;
|
|
411
|
+
const roleLocator = page.getByRole(node.role, { name, exact: true });
|
|
412
|
+
if (await sameUniqueElement(roleLocator, element))
|
|
413
|
+
node.name = name;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async function sameUniqueElement(candidate, element) {
|
|
417
|
+
if (await candidate.count() !== 1)
|
|
418
|
+
return false;
|
|
419
|
+
const candidateHandle = await candidate.elementHandle();
|
|
420
|
+
if (!candidateHandle)
|
|
421
|
+
return false;
|
|
422
|
+
try {
|
|
423
|
+
return await element.evaluate((node, other) => node === other, candidateHandle);
|
|
424
|
+
} finally {
|
|
425
|
+
await candidateHandle.dispose();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
class ExplorerView {
|
|
430
|
+
page;
|
|
431
|
+
redactor;
|
|
432
|
+
previous = null;
|
|
433
|
+
previousUrl = null;
|
|
434
|
+
last = null;
|
|
435
|
+
constructor(page, redactor) {
|
|
436
|
+
this.page = page;
|
|
437
|
+
this.redactor = redactor;
|
|
438
|
+
}
|
|
439
|
+
hasRef(ref) {
|
|
440
|
+
return Boolean(this.last?.has(ref));
|
|
441
|
+
}
|
|
442
|
+
node(ref) {
|
|
443
|
+
return this.last?.get(ref);
|
|
444
|
+
}
|
|
445
|
+
async observe(options = {}, settled = true) {
|
|
446
|
+
let scope = this.page.locator("body");
|
|
447
|
+
if (options.scope) {
|
|
448
|
+
const rawScope = options.scope;
|
|
449
|
+
const ref = /^(?:aria-ref=)?(e\d+)$/.exec(rawScope);
|
|
450
|
+
if (ref) {
|
|
451
|
+
if (!this.hasRef(ref[1]))
|
|
452
|
+
throw Object.assign(new Error(`unknown or stale ref ${ref[1]}`), { code: "stale_ref" });
|
|
453
|
+
scope = this.page.locator(`aria-ref=${ref[1]}`);
|
|
454
|
+
} else if (rawScope.startsWith("role=")) {
|
|
455
|
+
const value = rawScope.slice(5);
|
|
456
|
+
const [role, ...nameParts] = value.split(":");
|
|
457
|
+
if (!/^[-a-z]+$/.test(role) || nameParts.length && !nameParts.join(":").length)
|
|
458
|
+
throw Object.assign(new Error("scope must be eN, aria-ref=eN, role=<role>[:<name>], or testid=<id>"), { code: "invalid_params" });
|
|
459
|
+
scope = nameParts.length ? this.page.getByRole(role, { name: nameParts.join(":"), exact: true }) : this.page.getByRole(role);
|
|
460
|
+
} else if (rawScope.startsWith("testid=") && rawScope.slice(7))
|
|
461
|
+
scope = this.page.getByTestId(rawScope.slice(7));
|
|
462
|
+
else
|
|
463
|
+
throw Object.assign(new Error("scope must be eN, aria-ref=eN, role=<role>[:<name>], or testid=<id>"), { code: "invalid_params" });
|
|
464
|
+
const scopeCount = await scope.count();
|
|
465
|
+
if (scopeCount === 0)
|
|
466
|
+
throw Object.assign(new Error("scope does not match exactly one element"), { code: "scope_not_found" });
|
|
467
|
+
if (scopeCount > 1)
|
|
468
|
+
throw Object.assign(new Error("scope matches more than one element"), { code: "scope_ambiguous" });
|
|
469
|
+
}
|
|
470
|
+
const raw = await scope.ariaSnapshot({ mode: "ai" });
|
|
471
|
+
const nodes = parseSnapshot(raw);
|
|
472
|
+
await nameRows(this.page, nodes);
|
|
473
|
+
await nameContainers(this.page, nodes);
|
|
474
|
+
const view = prune(nodes, this.redactor, await passwordRefs(this.page, nodes));
|
|
475
|
+
const fullText = [...view.values()].map((node) => ` ${format(node, this.redactor)}`).join(`
|
|
476
|
+
`);
|
|
477
|
+
const url = this.page.url();
|
|
478
|
+
let mode = "full";
|
|
479
|
+
let body = fullText;
|
|
480
|
+
if (!options.full && !options.scope && this.previous && this.previousUrl === url) {
|
|
481
|
+
const diff = diffViews(this.previous, view, this.redactor);
|
|
482
|
+
const text2 = diff.length ? diff.join(`
|
|
483
|
+
`) : "(无变化)";
|
|
484
|
+
if (text2.length < fullText.length) {
|
|
485
|
+
mode = "diff";
|
|
486
|
+
body = text2;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (!options.scope) {
|
|
490
|
+
this.previous = view;
|
|
491
|
+
this.previousUrl = url;
|
|
492
|
+
this.last = view;
|
|
493
|
+
}
|
|
494
|
+
if (options.table_text)
|
|
495
|
+
body += `
|
|
496
|
+
${await this.tableText(scope)}`;
|
|
497
|
+
const text = `[page] ${this.redactor.url(url)} · ${this.redactor.text(await this.page.title())} [${mode}]
|
|
498
|
+
${body}`;
|
|
499
|
+
return { mode, text, settled, stats: { raw: raw.length, pruned: fullText.length, sent: body.length } };
|
|
500
|
+
}
|
|
501
|
+
async tableText(scope) {
|
|
502
|
+
const rows = scope.locator("table tbody tr:visible, .el-table__body-wrapper tbody tr:visible");
|
|
503
|
+
const total = await rows.count();
|
|
504
|
+
const count = Math.min(total, 50);
|
|
505
|
+
const lines = [];
|
|
506
|
+
for (let i = 0;i < count; i++)
|
|
507
|
+
lines.push(` table row: ${this.redactor.text((await rows.nth(i).innerText()).replace(/\s+/g, " ").trim())}`);
|
|
508
|
+
if (total > count)
|
|
509
|
+
lines.push(` …(已截断,最多显示 50 行,共 ${total} 行)`);
|
|
510
|
+
return lines.length ? `[table_text]
|
|
511
|
+
${lines.join(`
|
|
512
|
+
`)}` : `[table_text]
|
|
513
|
+
(无可见数据行)`;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// plugins/devflow-quality/runtime/playwright/src/explore/recorder.ts
|
|
518
|
+
import fs from "node:fs/promises";
|
|
519
|
+
import path from "node:path";
|
|
520
|
+
var safe = (value, label) => {
|
|
521
|
+
if (!/^[^/\\\x00-\x1f\x7f]+$/.test(value))
|
|
522
|
+
throw new Error(`${label} is invalid`);
|
|
523
|
+
return value;
|
|
524
|
+
};
|
|
525
|
+
function inferredRole(tag, type) {
|
|
526
|
+
if (tag === "button")
|
|
527
|
+
return "button";
|
|
528
|
+
if (tag === "a")
|
|
529
|
+
return "link";
|
|
530
|
+
if (tag === "select")
|
|
531
|
+
return "combobox";
|
|
532
|
+
if (tag === "textarea")
|
|
533
|
+
return "textbox";
|
|
534
|
+
if (tag !== "input")
|
|
535
|
+
return;
|
|
536
|
+
return { checkbox: "checkbox", radio: "radio", range: "slider" }[type ?? ""] ?? "textbox";
|
|
537
|
+
}
|
|
538
|
+
async function sameUnique(candidate, element) {
|
|
539
|
+
if (await candidate.count() !== 1)
|
|
540
|
+
return false;
|
|
541
|
+
const candidateHandle = await candidate.elementHandle();
|
|
542
|
+
if (!candidateHandle)
|
|
543
|
+
return false;
|
|
544
|
+
try {
|
|
545
|
+
return await element.evaluate((node, other) => node === other, candidateHandle);
|
|
546
|
+
} finally {
|
|
547
|
+
await candidateHandle.dispose();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
class Recorder {
|
|
552
|
+
page;
|
|
553
|
+
view;
|
|
554
|
+
redactor;
|
|
555
|
+
feature;
|
|
556
|
+
environmentProfile;
|
|
557
|
+
startUrl;
|
|
558
|
+
steps = [];
|
|
559
|
+
explorationSteps = [];
|
|
560
|
+
secretVariables = new Set;
|
|
561
|
+
constructor(page, view, redactor, feature, environmentProfile, startUrl) {
|
|
562
|
+
this.page = page;
|
|
563
|
+
this.view = view;
|
|
564
|
+
this.redactor = redactor;
|
|
565
|
+
this.feature = feature;
|
|
566
|
+
this.environmentProfile = environmentProfile;
|
|
567
|
+
this.startUrl = startUrl;
|
|
568
|
+
const initial = new URL(startUrl);
|
|
569
|
+
this.steps.push({ action: "goto", url: `${initial.pathname}${initial.search}${initial.hash}` });
|
|
570
|
+
}
|
|
571
|
+
async unique(locator) {
|
|
572
|
+
return await locator.count() === 1;
|
|
573
|
+
}
|
|
574
|
+
async rowTarget(element, role, name) {
|
|
575
|
+
const row = element.locator("xpath=ancestor::tr[1]");
|
|
576
|
+
if (await row.count() !== 1)
|
|
577
|
+
return null;
|
|
578
|
+
const cells = row.locator(":scope > td:visible, :scope > th:visible");
|
|
579
|
+
for (let i = 0;i < await cells.count(); i++) {
|
|
580
|
+
const markerText = (await cells.nth(i).innerText()).replace(/\s+/g, " ").trim();
|
|
581
|
+
if (markerText.length < 2)
|
|
582
|
+
continue;
|
|
583
|
+
const rows = this.page.getByRole("row", { name: markerText, exact: false });
|
|
584
|
+
const inner = rows.getByRole(role, { name, exact: true });
|
|
585
|
+
if (await rows.count() === 1 && await sameUnique(inner, element)) {
|
|
586
|
+
return { locator: { within: { role: "row", name: markerText, exact: false }, role, name, exact: true }, data_dependent: true, reviewReasons: ["data_dependent_locator"] };
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
async contextTarget(node, role, name) {
|
|
592
|
+
const context = node.ctx.split(" > ").filter((item) => /^(dialog|alertdialog|form|navigation|banner|main|region)\b/.test(item)).pop();
|
|
593
|
+
if (!context)
|
|
594
|
+
return null;
|
|
595
|
+
const match = /^(\w+)(?: "([\s\S]*)")?$/.exec(context);
|
|
596
|
+
if (!match)
|
|
597
|
+
return null;
|
|
598
|
+
const contextRole = match[1];
|
|
599
|
+
const contextName = match[2];
|
|
600
|
+
const within = contextName ? { role: contextRole, name: contextName, exact: true } : { role: contextRole };
|
|
601
|
+
const root = contextName ? this.page.getByRole(contextRole, { name: contextName, exact: true }) : this.page.getByRole(contextRole);
|
|
602
|
+
const inner = root.getByRole(role, { name, exact: true });
|
|
603
|
+
if (await this.unique(root) && await sameUnique(inner, this.page.locator(`aria-ref=${node.ref}`)))
|
|
604
|
+
return { locator: { within, role, name, exact: true } };
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
async targetFor(ref) {
|
|
608
|
+
if (!this.view.hasRef(ref))
|
|
609
|
+
throw Object.assign(new Error(`unknown or stale ref ${ref}`), { code: "stale_ref" });
|
|
610
|
+
const node = this.view.node(ref);
|
|
611
|
+
if (!node)
|
|
612
|
+
throw Object.assign(new Error(`unknown or stale ref ${ref}`), { code: "stale_ref" });
|
|
613
|
+
const element = this.page.locator(`aria-ref=${ref}`);
|
|
614
|
+
if (await element.count() !== 1)
|
|
615
|
+
throw Object.assign(new Error(`ref ${ref} is not unique`), { code: "stale_ref" });
|
|
616
|
+
const testid = await element.getAttribute("data-testid");
|
|
617
|
+
if (testid && await sameUnique(this.page.getByTestId(testid), element))
|
|
618
|
+
return { locator: `testid:${testid}` };
|
|
619
|
+
const tag = await element.evaluate((item) => item.tagName.toLowerCase());
|
|
620
|
+
const role = node.role || inferredRole(tag, await element.getAttribute("type"));
|
|
621
|
+
const name = node.name;
|
|
622
|
+
if (role && name) {
|
|
623
|
+
const roleLocator = this.page.getByRole(role, { name, exact: true });
|
|
624
|
+
const rowTarget = await this.rowTarget(element, role, name);
|
|
625
|
+
if (rowTarget)
|
|
626
|
+
return rowTarget;
|
|
627
|
+
if (await sameUnique(roleLocator, element)) {
|
|
628
|
+
const inRow2 = await element.locator("xpath=ancestor::tr[1]").count() === 1;
|
|
629
|
+
return { locator: `role:${role}:${name}`, ...inRow2 ? { data_dependent: true, reviewReasons: ["row_scope_missing"] } : {} };
|
|
630
|
+
}
|
|
631
|
+
const within = await this.contextTarget(node, role, name);
|
|
632
|
+
if (within)
|
|
633
|
+
return within;
|
|
634
|
+
}
|
|
635
|
+
const ariaLabel = await element.getAttribute("aria-label");
|
|
636
|
+
if (ariaLabel && await sameUnique(this.page.getByLabel(ariaLabel, { exact: true }), element))
|
|
637
|
+
return { locator: `label:${ariaLabel}` };
|
|
638
|
+
const placeholder = await element.getAttribute("placeholder");
|
|
639
|
+
if (placeholder && await sameUnique(this.page.getByPlaceholder(placeholder, { exact: true }), element))
|
|
640
|
+
return { locator: `placeholder:${placeholder}` };
|
|
641
|
+
const candidates = [];
|
|
642
|
+
if (testid)
|
|
643
|
+
candidates.push(`testid:${testid}`);
|
|
644
|
+
if (role && name)
|
|
645
|
+
candidates.push(`role:${role}:${name}`);
|
|
646
|
+
if (placeholder)
|
|
647
|
+
candidates.push(`placeholder:${placeholder}`);
|
|
648
|
+
const inRow = await element.locator("xpath=ancestor::tr[1]").count() === 1;
|
|
649
|
+
return { needs_locator: true, candidates: candidates.map((candidate) => this.redactor.value(candidate)), ...inRow ? { data_dependent: true, reviewReasons: ["row_scope_missing"] } : {} };
|
|
650
|
+
}
|
|
651
|
+
recordPrepared(id, action, target, extra = {}) {
|
|
652
|
+
const step = { action, ...extra, locator: target.locator ?? "" };
|
|
653
|
+
this.steps.push(step);
|
|
654
|
+
this.explorationSteps.push({
|
|
655
|
+
id,
|
|
656
|
+
action,
|
|
657
|
+
role: typeof target.locator === "object" && target.locator ? target.locator.role : undefined,
|
|
658
|
+
...target.needs_locator ? { needs_locator: true } : {},
|
|
659
|
+
...target.candidates ? { candidates: target.candidates } : {},
|
|
660
|
+
...target.data_dependent ? { data_dependent: true } : {},
|
|
661
|
+
...target.reviewReasons ? { reviewReasons: target.reviewReasons } : {},
|
|
662
|
+
...action.startsWith("assert_") ? { oracle_hint: "characterization" } : {}
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
recordAssertionReview(id, kind, expectedFromPage, circular, targetName, weak = false) {
|
|
666
|
+
const item = this.explorationSteps.find((step) => step.id === id);
|
|
667
|
+
if (item)
|
|
668
|
+
Object.assign(item, { ...expectedFromPage ? { expected_from_page: true } : {}, ...circular ? { circular: true } : {}, ...weak ? { reviewReasons: [...item.reviewReasons ?? [], "weak_assertion"] } : {} });
|
|
669
|
+
}
|
|
670
|
+
noteSecretVariable(name) {
|
|
671
|
+
this.secretVariables.add(name);
|
|
672
|
+
}
|
|
673
|
+
async writeDraft(name, tcId) {
|
|
674
|
+
const fileName = safe(name, "draft name").endsWith(".json") ? safe(name, "draft name") : `${safe(name, "draft name")}.json`;
|
|
675
|
+
const root = path.resolve(".devflow/planning/generated-cases", safe(this.feature, "feature"), "drafts");
|
|
676
|
+
await fs.mkdir(root, { recursive: true, mode: 448 });
|
|
677
|
+
const steps = this.steps.map((step, index) => ({ id: `s${String(index + 1).padStart(2, "0")}`, ...step }));
|
|
678
|
+
const metadata = this.explorationSteps.map((item, index) => ({ ...item, id: `s${String(index + 2).padStart(2, "0")}` }));
|
|
679
|
+
const review_items = [];
|
|
680
|
+
const reviewKeys = new Set;
|
|
681
|
+
const addReview = (item) => {
|
|
682
|
+
const key = `${item.step}\x00${item.reason}`;
|
|
683
|
+
if (!reviewKeys.has(key)) {
|
|
684
|
+
reviewKeys.add(key);
|
|
685
|
+
review_items.push(item);
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
for (const item of metadata) {
|
|
689
|
+
for (const reason of item.reviewReasons ?? [])
|
|
690
|
+
addReview({ step: item.id, reason });
|
|
691
|
+
if (item.needs_locator)
|
|
692
|
+
addReview({ step: item.id, reason: "needs_locator" });
|
|
693
|
+
if (item.expected_from_page)
|
|
694
|
+
addReview({ step: item.id, reason: "expected_from_page" });
|
|
695
|
+
if (item.circular)
|
|
696
|
+
addReview({ step: item.id, reason: "circular_assertion" });
|
|
697
|
+
if (item.action === "assert_visible" && ["button", "link"].includes(item.role ?? ""))
|
|
698
|
+
addReview({ step: item.id, reason: "weak_assertion" });
|
|
699
|
+
}
|
|
700
|
+
const business = metadata.some((item) => item.action?.startsWith("assert_") && item.action !== "assert_visible" && !item.circular);
|
|
701
|
+
if (!business)
|
|
702
|
+
addReview({ step: "case", reason: "no_business_assertion" });
|
|
703
|
+
const manual = [...new Set(review_items.filter((item) => item.reason !== "expected_from_page" && item.reason !== "data_dependent_locator").map((item) => item.step))];
|
|
704
|
+
const draft = {
|
|
705
|
+
cases: [{ tc_id: tcId ?? "TC-0000", name: name.replace(/\.json$/, ""), variables: Object.fromEntries([...this.secretVariables].map((key) => [key, { secret: key }])), data: { setup: [], cleanup: [] }, steps }],
|
|
706
|
+
exploration: { environment_profile: this.environmentProfile, start_url: this.redactor.url(this.startUrl), recorded_steps: steps.length, needs_manual: manual, review_items, steps: metadata }
|
|
707
|
+
};
|
|
708
|
+
const output = path.join(root, fileName);
|
|
709
|
+
const serialized = this.redactor.text(JSON.stringify(draft, null, 2));
|
|
710
|
+
await fs.writeFile(output, `${serialized}
|
|
711
|
+
`, { mode: 384 });
|
|
712
|
+
return { path: output, manual, review_items };
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// plugins/devflow-quality/runtime/playwright/src/explore/host.ts
|
|
717
|
+
var browser = null;
|
|
718
|
+
var context = null;
|
|
719
|
+
var page = null;
|
|
720
|
+
var view = null;
|
|
721
|
+
var recorder = null;
|
|
722
|
+
var redactor = null;
|
|
723
|
+
var profileMemory = null;
|
|
724
|
+
var idleTimer = null;
|
|
725
|
+
var totalTimer = null;
|
|
726
|
+
var inFlightRequests = 0;
|
|
727
|
+
var requestStarted = null;
|
|
728
|
+
var requestFinished = null;
|
|
729
|
+
function installRequestCounter(currentContext) {
|
|
730
|
+
inFlightRequests = 0;
|
|
731
|
+
requestStarted = (request) => {
|
|
732
|
+
if (["fetch", "xhr", "document"].includes(request.resourceType()))
|
|
733
|
+
inFlightRequests++;
|
|
734
|
+
};
|
|
735
|
+
requestFinished = () => {
|
|
736
|
+
inFlightRequests = Math.max(0, inFlightRequests - 1);
|
|
737
|
+
};
|
|
738
|
+
currentContext.on("request", requestStarted);
|
|
739
|
+
currentContext.on("requestfinished", requestFinished);
|
|
740
|
+
currentContext.on("requestfailed", requestFinished);
|
|
741
|
+
}
|
|
742
|
+
function removeRequestCounter() {
|
|
743
|
+
if (context && requestStarted && requestFinished) {
|
|
744
|
+
context.off("request", requestStarted);
|
|
745
|
+
context.off("requestfinished", requestFinished);
|
|
746
|
+
context.off("requestfailed", requestFinished);
|
|
747
|
+
}
|
|
748
|
+
requestStarted = null;
|
|
749
|
+
requestFinished = null;
|
|
750
|
+
inFlightRequests = 0;
|
|
751
|
+
}
|
|
752
|
+
function reply(id, result) {
|
|
753
|
+
process.stdout.write(`${JSON.stringify({ id, ok: true, result })}
|
|
754
|
+
`);
|
|
755
|
+
}
|
|
756
|
+
function failure(id, error) {
|
|
757
|
+
const item = error;
|
|
758
|
+
const code = item?.code ?? "host_error";
|
|
759
|
+
const message = redactor?.error(error) ?? (error instanceof Error ? error.message : String(error));
|
|
760
|
+
process.stdout.write(`${JSON.stringify({ id, ok: false, error: { code, message } })}
|
|
761
|
+
`);
|
|
762
|
+
}
|
|
763
|
+
async function shutdown() {
|
|
764
|
+
if (idleTimer)
|
|
765
|
+
clearTimeout(idleTimer);
|
|
766
|
+
if (totalTimer)
|
|
767
|
+
clearTimeout(totalTimer);
|
|
768
|
+
idleTimer = null;
|
|
769
|
+
totalTimer = null;
|
|
770
|
+
removeRequestCounter();
|
|
771
|
+
await browser?.close().catch(() => {});
|
|
772
|
+
browser = null;
|
|
773
|
+
context = null;
|
|
774
|
+
page = null;
|
|
775
|
+
view = null;
|
|
776
|
+
recorder = null;
|
|
777
|
+
profileMemory = null;
|
|
778
|
+
}
|
|
779
|
+
function touch() {
|
|
780
|
+
if (idleTimer)
|
|
781
|
+
clearTimeout(idleTimer);
|
|
782
|
+
idleTimer = setTimeout(() => void shutdown(), 10 * 60 * 1000);
|
|
783
|
+
}
|
|
784
|
+
function session() {
|
|
785
|
+
if (!page || !view || !recorder || !redactor) {
|
|
786
|
+
throw Object.assign(new Error("explore session has not started"), { code: "session_not_started" });
|
|
787
|
+
}
|
|
788
|
+
return { page, view, recorder, redactor };
|
|
789
|
+
}
|
|
790
|
+
function absoluteUrl(raw, profile) {
|
|
791
|
+
return raw.startsWith("/") ? new URL(raw, profile.base_url).toString() : new URL(raw).toString();
|
|
792
|
+
}
|
|
793
|
+
function secretValue(key, profile) {
|
|
794
|
+
const envName = profile.secret_env?.[String(key)];
|
|
795
|
+
const value = envName ? process.env[envName] : undefined;
|
|
796
|
+
if (!value)
|
|
797
|
+
throw Object.assign(new Error(`secret ${String(key)} is unavailable`), { code: "secret_unavailable" });
|
|
798
|
+
return value;
|
|
799
|
+
}
|
|
800
|
+
async function settle(currentPage) {
|
|
801
|
+
const deadline = Date.now() + 8000;
|
|
802
|
+
await currentPage.waitForLoadState("domcontentloaded").catch(() => {});
|
|
803
|
+
if (!context)
|
|
804
|
+
return true;
|
|
805
|
+
let quietSince = 0;
|
|
806
|
+
let previousSnapshot = null;
|
|
807
|
+
while (Date.now() < deadline) {
|
|
808
|
+
if (inFlightRequests === 0) {
|
|
809
|
+
if (!quietSince)
|
|
810
|
+
quietSince = Date.now();
|
|
811
|
+
if (Date.now() - quietSince >= 500) {
|
|
812
|
+
const snapshot = await currentPage.ariaSnapshot({ mode: "ai" }).catch(() => "");
|
|
813
|
+
if (previousSnapshot !== null && snapshot === previousSnapshot)
|
|
814
|
+
return true;
|
|
815
|
+
previousSnapshot = snapshot;
|
|
816
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
} else {
|
|
820
|
+
quietSince = 0;
|
|
821
|
+
previousSnapshot = null;
|
|
822
|
+
}
|
|
823
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
824
|
+
}
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
async function doStart(request) {
|
|
828
|
+
if (page)
|
|
829
|
+
throw Object.assign(new Error("only one explore session is allowed"), { code: "session_exists" });
|
|
830
|
+
const environmentFile = process.env.DEVFLOW_EXPLORE_ENVIRONMENT_FILE;
|
|
831
|
+
if (!environmentFile || !path2.isAbsolute(environmentFile)) {
|
|
832
|
+
throw Object.assign(new Error("environment profile path is missing"), { code: "environment_profile_invalid" });
|
|
833
|
+
}
|
|
834
|
+
try {
|
|
835
|
+
const profile = JSON.parse(fs2.readFileSync(environmentFile, "utf8"));
|
|
836
|
+
profileMemory = profile;
|
|
837
|
+
const policy = buildPolicy(profile);
|
|
838
|
+
redactor = new Redactor;
|
|
839
|
+
for (const envName of Object.values(profile.secret_env ?? {})) {
|
|
840
|
+
const value = process.env[envName];
|
|
841
|
+
if (value)
|
|
842
|
+
redactor.add(value);
|
|
843
|
+
}
|
|
844
|
+
browser = await chromium.launch();
|
|
845
|
+
context = await browser.newContext();
|
|
846
|
+
installRequestCounter(context);
|
|
847
|
+
await new NetworkRecorder(redactor).install(context, policy);
|
|
848
|
+
page = await context.newPage();
|
|
849
|
+
const startUrl = absoluteUrl(String(request.params?.url ?? "/"), profile);
|
|
850
|
+
await page.goto(startUrl, { waitUntil: "domcontentloaded" });
|
|
851
|
+
const settled = await settle(page);
|
|
852
|
+
view = new ExplorerView(page, redactor);
|
|
853
|
+
recorder = new Recorder(page, view, redactor, String(request.params?.feature ?? "explore"), profile.profile ?? path2.basename(environmentFile, path2.extname(environmentFile)), startUrl);
|
|
854
|
+
totalTimer = setTimeout(() => void shutdown(), 60 * 60 * 1000);
|
|
855
|
+
reply(request.id, await view.observe({ full: true }, settled));
|
|
856
|
+
} catch (error) {
|
|
857
|
+
await shutdown();
|
|
858
|
+
throw error;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
async function doAct(request) {
|
|
862
|
+
const current = session();
|
|
863
|
+
const ref = request.params?.ref;
|
|
864
|
+
const action = request.params?.action;
|
|
865
|
+
if (typeof ref !== "string" || typeof action !== "string") {
|
|
866
|
+
throw Object.assign(new Error("act requires ref and action"), { code: "invalid_params" });
|
|
867
|
+
}
|
|
868
|
+
const prepared = await current.recorder.targetFor(ref);
|
|
869
|
+
let value = request.params?.value;
|
|
870
|
+
let secretKey;
|
|
871
|
+
if (request.params?.value_from_secret !== undefined) {
|
|
872
|
+
secretKey = request.params.value_from_secret;
|
|
873
|
+
if (!profileMemory)
|
|
874
|
+
throw Object.assign(new Error("explore profile is unavailable"), { code: "environment_profile_invalid" });
|
|
875
|
+
value = secretValue(secretKey, profileMemory);
|
|
876
|
+
current.redactor.add(value);
|
|
877
|
+
current.recorder.noteSecretVariable(String(secretKey));
|
|
878
|
+
}
|
|
879
|
+
const locator = current.page.locator(`aria-ref=${ref}`);
|
|
880
|
+
switch (action) {
|
|
881
|
+
case "click":
|
|
882
|
+
await locator.click();
|
|
883
|
+
break;
|
|
884
|
+
case "fill":
|
|
885
|
+
await locator.fill(String(value ?? ""));
|
|
886
|
+
break;
|
|
887
|
+
case "check":
|
|
888
|
+
await locator.check();
|
|
889
|
+
break;
|
|
890
|
+
case "uncheck":
|
|
891
|
+
await locator.uncheck();
|
|
892
|
+
break;
|
|
893
|
+
case "select":
|
|
894
|
+
await locator.selectOption(value);
|
|
895
|
+
break;
|
|
896
|
+
case "press":
|
|
897
|
+
await locator.press(String(value ?? ""));
|
|
898
|
+
break;
|
|
899
|
+
default:
|
|
900
|
+
throw Object.assign(new Error(`unknown action ${action}`), { code: "invalid_params" });
|
|
901
|
+
}
|
|
902
|
+
const settled = await settle(current.page);
|
|
903
|
+
const id = `s${String(current.recorder.steps.length + 1).padStart(2, "0")}`;
|
|
904
|
+
const extra = {};
|
|
905
|
+
if (action === "fill" || action === "select" || action === "press") {
|
|
906
|
+
extra.value = secretKey === undefined ? current.redactor.text(String(value ?? "")) : `\${${String(secretKey)}}`;
|
|
907
|
+
}
|
|
908
|
+
current.recorder.recordPrepared(id, action, prepared, extra);
|
|
909
|
+
reply(request.id, { ...await current.view.observe({}, settled), settled });
|
|
910
|
+
}
|
|
911
|
+
async function doAssert(request) {
|
|
912
|
+
const current = session();
|
|
913
|
+
const ref = request.params?.ref;
|
|
914
|
+
const kind = request.params?.kind;
|
|
915
|
+
if (typeof ref !== "string" || typeof kind !== "string") {
|
|
916
|
+
throw Object.assign(new Error("assert requires ref and kind"), { code: "invalid_params" });
|
|
917
|
+
}
|
|
918
|
+
const prepared = await current.recorder.targetFor(ref);
|
|
919
|
+
const locator = current.page.locator(`aria-ref=${ref}`);
|
|
920
|
+
const requested = request.params?.expected;
|
|
921
|
+
let expected;
|
|
922
|
+
const match = request.params?.match === "contains" ? "contains" : "exact";
|
|
923
|
+
const normalizeWhitespace = request.params?.normalize_whitespace !== false;
|
|
924
|
+
const caseSensitive = request.params?.case_sensitive !== false;
|
|
925
|
+
if (kind === "visible") {
|
|
926
|
+
if (!await locator.isVisible())
|
|
927
|
+
throw Object.assign(new Error("element is not visible"), { code: "assertion_failed" });
|
|
928
|
+
} else if (kind === "value") {
|
|
929
|
+
const actual = await locator.inputValue();
|
|
930
|
+
expected = String(requested ?? actual);
|
|
931
|
+
if (actual !== expected)
|
|
932
|
+
throw Object.assign(new Error(`value assertion failed: expected ${current.redactor.text(expected)}`), { code: "assertion_failed" });
|
|
933
|
+
} else if (kind === "text") {
|
|
934
|
+
const actual = await locator.innerText();
|
|
935
|
+
expected = String(requested ?? actual.trim());
|
|
936
|
+
const matches = match === "contains" ? actual.includes(expected) : actual.trim() === expected;
|
|
937
|
+
if (!matches)
|
|
938
|
+
throw Object.assign(new Error(`text assertion failed: expected ${current.redactor.text(expected)}`), { code: "assertion_failed" });
|
|
939
|
+
} else if (kind === "each_text") {
|
|
940
|
+
const actual = await locator.allInnerTexts();
|
|
941
|
+
expected = typeof requested === "string" ? requested : actual[0]?.trim() ?? "";
|
|
942
|
+
const matches = actual.every((item) => match === "contains" ? item.includes(expected) : item.trim() === expected);
|
|
943
|
+
if (!matches)
|
|
944
|
+
throw Object.assign(new Error("each_text assertion failed"), { code: "assertion_failed" });
|
|
945
|
+
} else {
|
|
946
|
+
throw Object.assign(new Error(`unknown assertion kind ${kind}`), { code: "invalid_params" });
|
|
947
|
+
}
|
|
948
|
+
const action = kind === "visible" ? "assert_visible" : `assert_${kind}`;
|
|
949
|
+
const extra = {};
|
|
950
|
+
if (expected !== undefined)
|
|
951
|
+
extra.expected = expected;
|
|
952
|
+
if (kind !== "visible")
|
|
953
|
+
extra.match = match;
|
|
954
|
+
if (kind !== "visible") {
|
|
955
|
+
extra.normalize_whitespace = normalizeWhitespace;
|
|
956
|
+
extra.case_sensitive = caseSensitive;
|
|
957
|
+
}
|
|
958
|
+
current.recorder.recordPrepared(`s${String(current.recorder.steps.length + 1).padStart(2, "0")}`, action, prepared, extra);
|
|
959
|
+
const settled = await settle(current.page);
|
|
960
|
+
const targetName = current.view.node(ref)?.name ?? "";
|
|
961
|
+
const normalize = (value) => value.replace(/\s+/g, " ").trim();
|
|
962
|
+
const normalizedExpected = expected === undefined ? "" : normalize(expected);
|
|
963
|
+
const normalizedName = normalize(targetName);
|
|
964
|
+
const withinName = typeof prepared.locator === "object" && prepared.locator !== null ? String(prepared.locator.within?.name ?? "") : "";
|
|
965
|
+
const normalizedWithinName = normalize(withinName);
|
|
966
|
+
const circular = kind !== "visible" && Boolean(normalizedExpected && (normalizedName && (normalizedExpected.includes(normalizedName) || normalizedName.includes(normalizedExpected)) || normalizedWithinName && (normalizedExpected.includes(normalizedWithinName) || normalizedWithinName.includes(normalizedExpected))));
|
|
967
|
+
current.recorder.recordAssertionReview(`s${String(current.recorder.steps.length).padStart(2, "0")}`, kind, requested === undefined, circular, targetName, kind === "visible" && ["button", "link"].includes(current.view.node(ref)?.role ?? ""));
|
|
968
|
+
reply(request.id, { actual: current.redactor.value(kind === "each_text" ? await locator.allInnerTexts() : expected), view: await current.view.observe({}, settled), settled });
|
|
969
|
+
}
|
|
970
|
+
async function handle(request) {
|
|
971
|
+
touch();
|
|
972
|
+
try {
|
|
973
|
+
if (request.method === "start")
|
|
974
|
+
return await doStart(request);
|
|
975
|
+
if (request.method === "stop") {
|
|
976
|
+
await shutdown();
|
|
977
|
+
reply(request.id, { stopped: true });
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (request.method === "act")
|
|
981
|
+
return await doAct(request);
|
|
982
|
+
if (request.method === "assert")
|
|
983
|
+
return await doAssert(request);
|
|
984
|
+
const current = session();
|
|
985
|
+
if (request.method === "observe") {
|
|
986
|
+
reply(request.id, await current.view.observe({ scope: request.params?.scope, full: request.params?.full, table_text: request.params?.table_text }));
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
if (request.method === "draft") {
|
|
990
|
+
reply(request.id, await current.recorder.writeDraft(String(request.params?.name ?? "exploration"), request.params?.tc_id));
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
throw Object.assign(new Error(`unknown method ${request.method}`), { code: "invalid_method" });
|
|
994
|
+
} catch (error) {
|
|
995
|
+
failure(request.id, error);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
var input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
999
|
+
var requestQueue = Promise.resolve();
|
|
1000
|
+
input.on("line", (line) => {
|
|
1001
|
+
try {
|
|
1002
|
+
const request = JSON.parse(line);
|
|
1003
|
+
requestQueue = requestQueue.then(() => handle(request)).catch((error) => process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
1004
|
+
`));
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
1007
|
+
`);
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
process.on("SIGTERM", () => {
|
|
1011
|
+
shutdown().finally(() => process.exit(0));
|
|
1012
|
+
});
|
|
1013
|
+
process.on("SIGINT", () => {
|
|
1014
|
+
shutdown().finally(() => process.exit(0));
|
|
1015
|
+
});
|