@guanlin4924/devflow-pi 0.1.96 → 0.1.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +11 -1
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +678 -91
  4. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +18 -3
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +6 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +64 -8
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +3 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +52 -2
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +2 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/explore_preflight.py +58 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +1 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/locator_map.py +156 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +1 -1
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +50 -15
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +60 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +167 -0
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +5 -0
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +262 -56
  21. package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +126 -0
  22. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +2 -0
  23. package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +103 -88
  24. package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +879 -0
  25. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +297 -15
  26. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +3 -2
  27. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +4 -0
  28. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +10 -2
  29. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +6 -2
  30. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +3 -1
  31. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +4 -2
  32. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +98 -15
  33. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py +73 -0
  34. package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +69 -0
  35. package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +1 -1
  36. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +1 -1
  37. package/src/constants.ts +1 -1
  38. package/src/startup-briefing.ts +109 -0
@@ -0,0 +1,879 @@
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 INFO = new Set(["heading", "alert", "status", "dialog", "alertdialog"]);
230
+ var CONTEXT = new Set(["dialog", "alertdialog", "form", "navigation", "banner", "main", "region", "table", "row", "tabpanel", "menu"]);
231
+ var TEXT_CONTAINERS = new Set(["dialog", "alertdialog", "alert", "status"]);
232
+ var LINE = /^(\s*)- (.*)$/;
233
+ function colonOutsideQuotes(s) {
234
+ let quoted = false;
235
+ for (let i = 0;i < s.length; i++) {
236
+ if (s[i] === "\\") {
237
+ i++;
238
+ continue;
239
+ }
240
+ if (s[i] === '"')
241
+ quoted = !quoted;
242
+ else if (s[i] === ":" && !quoted && (i + 1 === s.length || s[i + 1] === " "))
243
+ return i;
244
+ }
245
+ return -1;
246
+ }
247
+ function parseSnapshot(yaml) {
248
+ const nodes = [];
249
+ const stack = [];
250
+ for (const raw of yaml.split(`
251
+ `)) {
252
+ const match = LINE.exec(raw);
253
+ if (!match)
254
+ continue;
255
+ const indent = match[1].length;
256
+ while (stack.length && stack[stack.length - 1].indent >= indent)
257
+ stack.pop();
258
+ const parent = stack.length ? stack[stack.length - 1].node : null;
259
+ let body = match[2];
260
+ if (body.startsWith("/"))
261
+ continue;
262
+ const node = { role: "", name: "", attrs: {}, value: "", ref: "", parent, ctx: "" };
263
+ if (body.startsWith("text:")) {
264
+ node.role = "text";
265
+ node.value = body.slice(5).trim();
266
+ } else {
267
+ const colon = colonOutsideQuotes(body);
268
+ if (colon >= 0) {
269
+ node.value = body.slice(colon + 1).trim().replace(/^"|"$/g, "");
270
+ body = body.slice(0, colon);
271
+ }
272
+ node.role = body.split(/[\s[]/, 1)[0];
273
+ const name = body.match(/^\S+ "((?:[^"\\]|\\.)*)"/);
274
+ if (name)
275
+ node.name = name[1].replace(/\\"/g, '"');
276
+ for (const [, key, value] of body.matchAll(/\[([\w-]+)(?:=([^\]]*))?\]/g))
277
+ node.attrs[key] = value ?? true;
278
+ node.ref = typeof node.attrs.ref === "string" ? node.attrs.ref : "";
279
+ delete node.attrs.ref;
280
+ delete node.attrs.cursor;
281
+ delete node.attrs.active;
282
+ }
283
+ nodes.push(node);
284
+ stack.push({ indent, node });
285
+ }
286
+ return nodes;
287
+ }
288
+ function ancestors(node) {
289
+ const out = [];
290
+ for (let p = node.parent;p; p = p.parent)
291
+ out.push(p);
292
+ return out;
293
+ }
294
+ function prune(nodes, redactor, passwordRefs = new Set) {
295
+ const view = new Map;
296
+ let textSeq = 0;
297
+ const mask = (value) => redactor ? redactor.text(value) : value;
298
+ for (const node of nodes) {
299
+ const anc = ancestors(node);
300
+ const inText = anc.some((a) => TEXT_CONTAINERS.has(a.role));
301
+ if (!(INTERACTIVE.has(node.role) || INFO.has(node.role) || inText && (node.role === "text" || node.role === "paragraph") && node.value))
302
+ continue;
303
+ const ctx = anc.filter((a) => CONTEXT.has(a.role)).reverse().map((a) => a.name ? `${a.role} "${mask(a.name)}"` : a.role).join(" > ");
304
+ const copy = { ...node, attrs: { ...node.attrs }, name: mask(node.name), value: mask(node.value), ctx };
305
+ if (passwordRefs.has(node.ref))
306
+ copy.value = REDACTED;
307
+ if (redactor)
308
+ copy.value = redactor.text(copy.value);
309
+ view.set(node.ref || `text#${anc[0]?.ref ?? "root"}#${textSeq++}`, copy);
310
+ }
311
+ return view;
312
+ }
313
+ function format(node, redactor) {
314
+ const mask = (value) => redactor?.text(value) ?? value;
315
+ const attrs = Object.entries(node.attrs).map(([key, value]) => value === true ? `[${key}]` : `[${key}=${mask(value)}]`).join(" ");
316
+ const parts = [node.role];
317
+ if (node.name)
318
+ parts.push(`"${mask(node.name)}"`);
319
+ if (attrs)
320
+ parts.push(attrs);
321
+ if (node.ref)
322
+ parts.push(`[ref=${node.ref}]`);
323
+ let result = parts.join(" ");
324
+ if (node.value)
325
+ result += `: ${mask(node.value)}`;
326
+ if (node.ctx)
327
+ result += ` (in ${mask(node.ctx)})`;
328
+ return result;
329
+ }
330
+ var signature = (node) => JSON.stringify([node.role, node.name, node.attrs, node.value, node.ctx]);
331
+ function diffViews(previous, next, redactor) {
332
+ const lines = [];
333
+ const added = [];
334
+ for (const [key, node] of next) {
335
+ const old = previous.get(key);
336
+ if (!old)
337
+ added.push(node);
338
+ else if (signature(old) !== signature(node))
339
+ lines.push(`~ ${format(old, redactor)}
340
+ → ${format(node, redactor)}`);
341
+ }
342
+ const removed = [...previous].filter(([key]) => !next.has(key)).map(([, node]) => node);
343
+ for (const node of added) {
344
+ const i = removed.findIndex((old) => old.role === node.role && old.ctx === node.ctx && old.role !== "text");
345
+ if (i >= 0)
346
+ lines.push(`~ ${format(removed.splice(i, 1)[0], redactor)}
347
+ → ${format(node, redactor)}`);
348
+ else
349
+ lines.push(`+ ${format(node, redactor)}`);
350
+ }
351
+ for (const node of removed)
352
+ lines.push(`- ${format(node, redactor)}`);
353
+ return lines;
354
+ }
355
+ async function passwordRefs(page, nodes) {
356
+ const out = new Set;
357
+ for (const node of nodes)
358
+ if (node.ref && (node.role === "textbox" || node.role === "searchbox")) {
359
+ const locator = page.locator(`aria-ref=${node.ref}`);
360
+ if (await locator.count() === 1 && (await locator.getAttribute("type"))?.toLowerCase() === "password")
361
+ out.add(node.ref);
362
+ }
363
+ return out;
364
+ }
365
+ async function nameRows(page, nodes) {
366
+ for (const node of nodes.filter((item) => item.role === "row" && item.ref)) {
367
+ const row = page.locator(`aria-ref=${node.ref}`);
368
+ if (await row.count() !== 1)
369
+ continue;
370
+ const cells = row.locator(":scope > td:visible, :scope > th:visible");
371
+ for (let i = 0;i < await cells.count(); i++) {
372
+ const text = (await cells.nth(i).innerText()).replace(/\s+/g, " ").trim();
373
+ if (text) {
374
+ node.name = text.slice(0, 40);
375
+ break;
376
+ }
377
+ }
378
+ }
379
+ for (const node of nodes.filter((item) => item.ref)) {
380
+ const rowNode = [node, ...ancestors(node)].find((item) => item.role === "row");
381
+ if (!rowNode || rowNode.name)
382
+ continue;
383
+ const row = page.locator(`aria-ref=${node.ref}`).locator("xpath=ancestor::tr[1]");
384
+ if (await row.count() !== 1)
385
+ continue;
386
+ const cells = row.locator(":scope > td:visible, :scope > th:visible");
387
+ for (let i = 0;i < await cells.count(); i++) {
388
+ const text = (await cells.nth(i).innerText()).replace(/\s+/g, " ").trim();
389
+ if (text) {
390
+ rowNode.name = text.slice(0, 40);
391
+ break;
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ class ExplorerView {
398
+ page;
399
+ redactor;
400
+ previous = null;
401
+ previousUrl = null;
402
+ last = null;
403
+ constructor(page, redactor) {
404
+ this.page = page;
405
+ this.redactor = redactor;
406
+ }
407
+ hasRef(ref) {
408
+ return Boolean(this.last?.has(ref));
409
+ }
410
+ node(ref) {
411
+ return this.last?.get(ref);
412
+ }
413
+ async observe(options = {}) {
414
+ let scope = this.page.locator("body");
415
+ if (options.scope) {
416
+ const rawScope = options.scope;
417
+ if (/^aria-ref=e\d+$/.test(rawScope) || /^e\d+$/.test(rawScope))
418
+ scope = this.page.locator(rawScope.startsWith("aria-ref=") ? rawScope : `aria-ref=${rawScope}`);
419
+ else if (rawScope.startsWith("role="))
420
+ scope = this.page.getByRole(rawScope.slice(5));
421
+ else if (rawScope.startsWith("testid="))
422
+ scope = this.page.getByTestId(rawScope.slice(7));
423
+ else
424
+ scope = this.page.locator(rawScope);
425
+ }
426
+ const raw = await scope.ariaSnapshot({ mode: "ai" });
427
+ const nodes = parseSnapshot(raw);
428
+ await nameRows(this.page, nodes);
429
+ const view = prune(nodes, this.redactor, await passwordRefs(this.page, nodes));
430
+ const fullText = [...view.values()].map((node) => ` ${format(node, this.redactor)}`).join(`
431
+ `);
432
+ const url = this.page.url();
433
+ let mode = "full";
434
+ let body = fullText;
435
+ if (!options.full && !options.scope && this.previous && this.previousUrl === url) {
436
+ const diff = diffViews(this.previous, view, this.redactor);
437
+ const text2 = diff.length ? diff.join(`
438
+ `) : "(无变化)";
439
+ if (text2.length < fullText.length) {
440
+ mode = "diff";
441
+ body = text2;
442
+ }
443
+ }
444
+ if (!options.scope) {
445
+ this.previous = view;
446
+ this.previousUrl = url;
447
+ this.last = view;
448
+ }
449
+ if (options.table_text)
450
+ body += `
451
+ ${await this.tableText(scope)}`;
452
+ const text = `[page] ${this.redactor.url(url)} · ${this.redactor.text(await this.page.title())} [${mode}]
453
+ ${body}`;
454
+ return { mode, text, stats: { raw: raw.length, pruned: fullText.length, sent: body.length } };
455
+ }
456
+ async tableText(scope) {
457
+ const rows = scope.locator("table tbody tr:visible, .el-table__body-wrapper tbody tr:visible");
458
+ const total = await rows.count();
459
+ const count = Math.min(total, 50);
460
+ const lines = [];
461
+ for (let i = 0;i < count; i++)
462
+ lines.push(` table row: ${this.redactor.text((await rows.nth(i).innerText()).replace(/\s+/g, " ").trim())}`);
463
+ if (total > count)
464
+ lines.push(` …(已截断,最多显示 50 行,共 ${total} 行)`);
465
+ return lines.length ? `[table_text]
466
+ ${lines.join(`
467
+ `)}` : `[table_text]
468
+ (无可见数据行)`;
469
+ }
470
+ }
471
+
472
+ // plugins/devflow-quality/runtime/playwright/src/explore/recorder.ts
473
+ import fs from "node:fs/promises";
474
+ import path from "node:path";
475
+ var safe = (value, label) => {
476
+ if (!/^[^/\\\x00-\x1f\x7f]+$/.test(value))
477
+ throw new Error(`${label} is invalid`);
478
+ return value;
479
+ };
480
+ function inferredRole(tag, type) {
481
+ if (tag === "button")
482
+ return "button";
483
+ if (tag === "a")
484
+ return "link";
485
+ if (tag === "select")
486
+ return "combobox";
487
+ if (tag === "textarea")
488
+ return "textbox";
489
+ if (tag !== "input")
490
+ return;
491
+ return { checkbox: "checkbox", radio: "radio", range: "slider" }[type ?? ""] ?? "textbox";
492
+ }
493
+
494
+ class Recorder {
495
+ page;
496
+ view;
497
+ redactor;
498
+ feature;
499
+ environmentProfile;
500
+ startUrl;
501
+ steps = [];
502
+ explorationSteps = [];
503
+ secretVariables = new Set;
504
+ constructor(page, view, redactor, feature, environmentProfile, startUrl) {
505
+ this.page = page;
506
+ this.view = view;
507
+ this.redactor = redactor;
508
+ this.feature = feature;
509
+ this.environmentProfile = environmentProfile;
510
+ this.startUrl = startUrl;
511
+ this.steps.push({ action: "goto", url: new URL(startUrl).pathname });
512
+ }
513
+ async unique(locator) {
514
+ return await locator.count() === 1;
515
+ }
516
+ async contextTarget(node, role, name) {
517
+ const context = node.ctx.split(" > ").filter((item) => /^(dialog|alertdialog|form|navigation|banner|main|region)\b/.test(item)).pop();
518
+ if (!context)
519
+ return null;
520
+ const match = /^(\w+)(?: "([\s\S]*)")?$/.exec(context);
521
+ if (!match)
522
+ return null;
523
+ const contextRole = match[1];
524
+ const contextName = match[2];
525
+ const within = contextName ? { role: contextRole, name: contextName, exact: true } : { role: contextRole };
526
+ const root = contextName ? this.page.getByRole(contextRole, { name: contextName, exact: true }) : this.page.getByRole(contextRole);
527
+ const inner = root.getByRole(role, { name, exact: true });
528
+ if (await this.unique(root) && await this.unique(inner))
529
+ return { locator: { within, role, name, exact: true } };
530
+ return null;
531
+ }
532
+ async elementPlusTarget(element, role, name) {
533
+ const row = element.locator("xpath=ancestor::tr[1]");
534
+ if (await row.count() !== 1)
535
+ return null;
536
+ const root = row.locator("xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' el-table ')][1]");
537
+ if (await root.count() !== 1)
538
+ return null;
539
+ const headers = root.locator(".el-table__header-wrapper th:visible");
540
+ const headerByClass = new Map;
541
+ for (let i = 0;i < await headers.count(); i++) {
542
+ const header = headers.nth(i);
543
+ const title = (await header.innerText()).replace(/\s+/g, " ").trim();
544
+ for (const className of (await header.getAttribute("class") ?? "").split(/\s+/)) {
545
+ if (/^el-table_\d+_column_\d+$/.test(className) && title)
546
+ headerByClass.set(className, title);
547
+ }
548
+ }
549
+ const cells = row.locator(":scope > td:visible");
550
+ let actionColumn = "";
551
+ let markerColumn = "";
552
+ let markerText = "";
553
+ for (let i = 0;i < await cells.count(); i++) {
554
+ const cell = cells.nth(i);
555
+ const text = (await cell.innerText()).replace(/\s+/g, " ").trim();
556
+ const className = (await cell.getAttribute("class") ?? "").split(/\s+/).find((item) => headerByClass.has(item));
557
+ const column = className ? headerByClass.get(className) ?? "" : "";
558
+ if (text && !markerText) {
559
+ markerText = text;
560
+ markerColumn = column;
561
+ }
562
+ if (column && await cell.getByRole(role, { name, exact: true }).count() > 0)
563
+ actionColumn = column;
564
+ }
565
+ if (!actionColumn || !markerColumn || !markerText)
566
+ return null;
567
+ const target = {
568
+ within: {
569
+ role: "row",
570
+ name: markerText,
571
+ exact: false
572
+ },
573
+ role,
574
+ name,
575
+ exact: true
576
+ };
577
+ const matchingRows = root.locator(".el-table__body-wrapper tbody tr:visible").filter({ hasText: markerText });
578
+ if (await matchingRows.count() !== 1 || !await this.unique(matchingRows.getByRole(role, { name, exact: true })))
579
+ return null;
580
+ return { locator: target, data_dependent: true };
581
+ }
582
+ async targetFor(ref) {
583
+ if (!this.view.hasRef(ref))
584
+ throw Object.assign(new Error(`unknown or stale ref ${ref}`), { code: "stale_ref" });
585
+ const node = this.view.node(ref);
586
+ if (!node)
587
+ throw Object.assign(new Error(`unknown or stale ref ${ref}`), { code: "stale_ref" });
588
+ const element = this.page.locator(`aria-ref=${ref}`);
589
+ if (await element.count() !== 1)
590
+ throw Object.assign(new Error(`ref ${ref} is not unique`), { code: "stale_ref" });
591
+ const testid = await element.getAttribute("data-testid");
592
+ if (testid && await this.unique(this.page.getByTestId(testid)))
593
+ return { locator: `testid:${testid}` };
594
+ const tag = await element.evaluate((item) => item.tagName.toLowerCase());
595
+ const role = node.role || inferredRole(tag, await element.getAttribute("type"));
596
+ const name = node.name;
597
+ if (role && name) {
598
+ const roleLocator = this.page.getByRole(role, { name, exact: true });
599
+ if (await this.unique(roleLocator))
600
+ return { locator: `role:${role}:${name}` };
601
+ const within = await this.contextTarget(node, role, name);
602
+ if (within)
603
+ return within;
604
+ const tableTarget = await this.elementPlusTarget(element, role, name);
605
+ if (tableTarget)
606
+ return tableTarget;
607
+ }
608
+ const ariaLabel = await element.getAttribute("aria-label");
609
+ if (ariaLabel && await this.unique(this.page.getByLabel(ariaLabel, { exact: true })))
610
+ return { locator: `label:${ariaLabel}` };
611
+ const placeholder = await element.getAttribute("placeholder");
612
+ if (placeholder && await this.unique(this.page.getByPlaceholder(placeholder, { exact: true })))
613
+ return { locator: `placeholder:${placeholder}` };
614
+ const candidates = [];
615
+ if (testid)
616
+ candidates.push(`testid:${testid}`);
617
+ if (role && name)
618
+ candidates.push(`role:${role}:${name}`);
619
+ if (placeholder)
620
+ candidates.push(`placeholder:${placeholder}`);
621
+ return { needs_locator: true, candidates: candidates.map((candidate) => this.redactor.value(candidate)) };
622
+ }
623
+ recordPrepared(id, action, target, extra = {}) {
624
+ const step = { action, ...extra, locator: target.locator ?? "" };
625
+ this.steps.push(step);
626
+ this.explorationSteps.push({
627
+ id,
628
+ ...target.needs_locator ? { needs_locator: true } : {},
629
+ ...target.candidates ? { candidates: target.candidates } : {},
630
+ ...target.data_dependent ? { data_dependent: true } : {},
631
+ ...action.startsWith("assert_") ? { oracle_hint: "characterization" } : {}
632
+ });
633
+ }
634
+ noteSecretVariable(name) {
635
+ this.secretVariables.add(name);
636
+ }
637
+ async writeDraft(name, tcId) {
638
+ const fileName = safe(name, "draft name").endsWith(".json") ? safe(name, "draft name") : `${safe(name, "draft name")}.json`;
639
+ const root = path.resolve(".devflow/planning/generated-cases", safe(this.feature, "feature"), "drafts");
640
+ await fs.mkdir(root, { recursive: true, mode: 448 });
641
+ const steps = this.steps.map((step, index) => ({ id: `s${String(index + 1).padStart(2, "0")}`, ...step }));
642
+ const metadata = this.explorationSteps.map((item, index) => ({ ...item, id: `s${String(index + 2).padStart(2, "0")}` }));
643
+ const manual = metadata.filter((item) => item.needs_locator).map((item) => item.id);
644
+ const draft = {
645
+ cases: [{ tc_id: tcId ?? "TC-0000", name: name.replace(/\.json$/, ""), variables: Object.fromEntries([...this.secretVariables].map((key) => [key, { secret: key }])), data: { setup: [], cleanup: [] }, steps }],
646
+ exploration: { environment_profile: this.environmentProfile, start_url: this.redactor.url(this.startUrl), recorded_steps: steps.length, needs_manual: manual, steps: metadata }
647
+ };
648
+ const output = path.join(root, fileName);
649
+ const serialized = this.redactor.text(JSON.stringify(draft, null, 2));
650
+ await fs.writeFile(output, `${serialized}
651
+ `, { mode: 384 });
652
+ return { path: output, manual };
653
+ }
654
+ }
655
+
656
+ // plugins/devflow-quality/runtime/playwright/src/explore/host.ts
657
+ var browser = null;
658
+ var context = null;
659
+ var page = null;
660
+ var view = null;
661
+ var recorder = null;
662
+ var redactor = null;
663
+ var idleTimer = null;
664
+ var totalTimer = null;
665
+ function reply(id, result) {
666
+ process.stdout.write(`${JSON.stringify({ id, ok: true, result })}
667
+ `);
668
+ }
669
+ function failure(id, error) {
670
+ const item = error;
671
+ const code = item?.code ?? "host_error";
672
+ const message = redactor?.error(error) ?? (error instanceof Error ? error.message : String(error));
673
+ process.stdout.write(`${JSON.stringify({ id, ok: false, error: { code, message } })}
674
+ `);
675
+ }
676
+ async function shutdown() {
677
+ if (idleTimer)
678
+ clearTimeout(idleTimer);
679
+ if (totalTimer)
680
+ clearTimeout(totalTimer);
681
+ idleTimer = null;
682
+ totalTimer = null;
683
+ await browser?.close().catch(() => {});
684
+ browser = null;
685
+ context = null;
686
+ page = null;
687
+ view = null;
688
+ recorder = null;
689
+ }
690
+ function touch() {
691
+ if (idleTimer)
692
+ clearTimeout(idleTimer);
693
+ idleTimer = setTimeout(() => void shutdown(), 10 * 60 * 1000);
694
+ }
695
+ function session() {
696
+ if (!page || !view || !recorder || !redactor) {
697
+ throw Object.assign(new Error("explore session has not started"), { code: "session_not_started" });
698
+ }
699
+ return { page, view, recorder, redactor };
700
+ }
701
+ function absoluteUrl(raw, profile) {
702
+ return raw.startsWith("/") ? new URL(raw, profile.base_url).toString() : new URL(raw).toString();
703
+ }
704
+ function secretValue(key, profile) {
705
+ const envName = profile.secret_env?.[String(key)];
706
+ const value = envName ? process.env[envName] : undefined;
707
+ if (!value)
708
+ throw Object.assign(new Error(`secret ${String(key)} is unavailable`), { code: "secret_unavailable" });
709
+ return value;
710
+ }
711
+ async function doStart(request) {
712
+ if (page)
713
+ throw Object.assign(new Error("only one explore session is allowed"), { code: "session_exists" });
714
+ const environmentFile = process.env.DEVFLOW_EXPLORE_ENVIRONMENT_FILE;
715
+ if (!environmentFile || !path2.isAbsolute(environmentFile)) {
716
+ throw Object.assign(new Error("environment profile path is missing"), { code: "environment_profile_invalid" });
717
+ }
718
+ const profile = JSON.parse(fs2.readFileSync(environmentFile, "utf8"));
719
+ const policy = buildPolicy(profile);
720
+ redactor = new Redactor;
721
+ for (const envName of Object.values(profile.secret_env ?? {})) {
722
+ const value = process.env[envName];
723
+ if (value)
724
+ redactor.add(value);
725
+ }
726
+ browser = await chromium.launch();
727
+ context = await browser.newContext();
728
+ await new NetworkRecorder(redactor).install(context, policy);
729
+ page = await context.newPage();
730
+ const startUrl = absoluteUrl(String(request.params?.url ?? "/"), profile);
731
+ await page.goto(startUrl, { waitUntil: "domcontentloaded" });
732
+ view = new ExplorerView(page, redactor);
733
+ recorder = new Recorder(page, view, redactor, String(request.params?.feature ?? "explore"), profile.profile ?? path2.basename(environmentFile, path2.extname(environmentFile)), startUrl);
734
+ totalTimer = setTimeout(() => void shutdown(), 60 * 60 * 1000);
735
+ reply(request.id, await view.observe({ full: true }));
736
+ }
737
+ async function doAct(request) {
738
+ const current = session();
739
+ const ref = request.params?.ref;
740
+ const action = request.params?.action;
741
+ if (typeof ref !== "string" || typeof action !== "string") {
742
+ throw Object.assign(new Error("act requires ref and action"), { code: "invalid_params" });
743
+ }
744
+ const prepared = await current.recorder.targetFor(ref);
745
+ let value = request.params?.value;
746
+ let secretKey;
747
+ if (request.params?.value_from_secret !== undefined) {
748
+ const environmentFile = process.env.DEVFLOW_EXPLORE_ENVIRONMENT_FILE;
749
+ const profile = JSON.parse(fs2.readFileSync(environmentFile, "utf8"));
750
+ secretKey = request.params.value_from_secret;
751
+ value = secretValue(secretKey, profile);
752
+ current.redactor.add(value);
753
+ current.recorder.noteSecretVariable(String(secretKey));
754
+ }
755
+ const locator = current.page.locator(`aria-ref=${ref}`);
756
+ switch (action) {
757
+ case "click":
758
+ await locator.click();
759
+ break;
760
+ case "fill":
761
+ await locator.fill(String(value ?? ""));
762
+ break;
763
+ case "check":
764
+ await locator.check();
765
+ break;
766
+ case "uncheck":
767
+ await locator.uncheck();
768
+ break;
769
+ case "select":
770
+ await locator.selectOption(value);
771
+ break;
772
+ case "press":
773
+ await locator.press(String(value ?? ""));
774
+ break;
775
+ default:
776
+ throw Object.assign(new Error(`unknown action ${action}`), { code: "invalid_params" });
777
+ }
778
+ await current.page.waitForLoadState("domcontentloaded").catch(() => {});
779
+ const id = `s${String(current.recorder.steps.length + 1).padStart(2, "0")}`;
780
+ const extra = {};
781
+ if (action === "fill" || action === "select" || action === "press") {
782
+ extra.value = secretKey === undefined ? current.redactor.text(String(value ?? "")) : `\${${String(secretKey)}}`;
783
+ }
784
+ current.recorder.recordPrepared(id, action, prepared, extra);
785
+ reply(request.id, await current.view.observe());
786
+ }
787
+ async function doAssert(request) {
788
+ const current = session();
789
+ const ref = request.params?.ref;
790
+ const kind = request.params?.kind;
791
+ if (typeof ref !== "string" || typeof kind !== "string") {
792
+ throw Object.assign(new Error("assert requires ref and kind"), { code: "invalid_params" });
793
+ }
794
+ const prepared = await current.recorder.targetFor(ref);
795
+ const locator = current.page.locator(`aria-ref=${ref}`);
796
+ const requested = request.params?.expected;
797
+ let expected;
798
+ const match = request.params?.match === "contains" ? "contains" : "exact";
799
+ const normalizeWhitespace = request.params?.normalize_whitespace !== false;
800
+ const caseSensitive = request.params?.case_sensitive !== false;
801
+ if (kind === "visible") {
802
+ if (!await locator.isVisible())
803
+ throw Object.assign(new Error("element is not visible"), { code: "assertion_failed" });
804
+ } else if (kind === "value") {
805
+ const actual = await locator.inputValue();
806
+ expected = String(requested ?? actual);
807
+ if (actual !== expected)
808
+ throw Object.assign(new Error(`value assertion failed: expected ${current.redactor.text(expected)}`), { code: "assertion_failed" });
809
+ } else if (kind === "text") {
810
+ const actual = await locator.innerText();
811
+ expected = String(requested ?? actual.trim());
812
+ const matches = match === "contains" ? actual.includes(expected) : actual.trim() === expected;
813
+ if (!matches)
814
+ throw Object.assign(new Error(`text assertion failed: expected ${current.redactor.text(expected)}`), { code: "assertion_failed" });
815
+ } else if (kind === "each_text") {
816
+ const actual = await locator.allInnerTexts();
817
+ expected = typeof requested === "string" ? requested : actual[0]?.trim() ?? "";
818
+ const matches = actual.every((item) => match === "contains" ? item.includes(expected) : item.trim() === expected);
819
+ if (!matches)
820
+ throw Object.assign(new Error("each_text assertion failed"), { code: "assertion_failed" });
821
+ } else {
822
+ throw Object.assign(new Error(`unknown assertion kind ${kind}`), { code: "invalid_params" });
823
+ }
824
+ const action = kind === "visible" ? "assert_visible" : `assert_${kind}`;
825
+ const extra = {};
826
+ if (expected !== undefined)
827
+ extra.expected = expected;
828
+ if (kind !== "visible")
829
+ extra.match = match;
830
+ if (kind !== "visible") {
831
+ extra.normalize_whitespace = normalizeWhitespace;
832
+ extra.case_sensitive = caseSensitive;
833
+ }
834
+ current.recorder.recordPrepared(`s${String(current.recorder.steps.length + 1).padStart(2, "0")}`, action, prepared, extra);
835
+ reply(request.id, { actual: current.redactor.value(kind === "each_text" ? await locator.allInnerTexts() : expected), view: await current.view.observe() });
836
+ }
837
+ async function handle(request) {
838
+ touch();
839
+ try {
840
+ if (request.method === "start")
841
+ return await doStart(request);
842
+ if (request.method === "stop") {
843
+ await shutdown();
844
+ reply(request.id, { stopped: true });
845
+ return;
846
+ }
847
+ if (request.method === "act")
848
+ return await doAct(request);
849
+ if (request.method === "assert")
850
+ return await doAssert(request);
851
+ const current = session();
852
+ if (request.method === "observe") {
853
+ reply(request.id, await current.view.observe({ scope: request.params?.scope, full: request.params?.full, table_text: request.params?.table_text }));
854
+ return;
855
+ }
856
+ if (request.method === "draft") {
857
+ reply(request.id, await current.recorder.writeDraft(String(request.params?.name ?? "exploration"), request.params?.tc_id));
858
+ return;
859
+ }
860
+ throw Object.assign(new Error(`unknown method ${request.method}`), { code: "invalid_method" });
861
+ } catch (error) {
862
+ failure(request.id, error);
863
+ }
864
+ }
865
+ var input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
866
+ input.on("line", (line) => {
867
+ try {
868
+ handle(JSON.parse(line));
869
+ } catch (error) {
870
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}
871
+ `);
872
+ }
873
+ });
874
+ process.on("SIGTERM", () => {
875
+ shutdown().finally(() => process.exit(0));
876
+ });
877
+ process.on("SIGINT", () => {
878
+ shutdown().finally(() => process.exit(0));
879
+ });