@guanlin4924/devflow-pi 0.1.97 → 0.1.99
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 +1 -1
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +591 -274
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +6 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +3 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +3 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_traceability.py +31 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +6 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/environment_profile.py +11 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +14 -5
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +12 -5
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +8 -0
- package/plugins/devflow-intel/extensions/devflow-intel.ts +25 -3
- package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +264 -101
- package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +41 -14
- package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +2 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +2 -0
- package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +3 -0
- package/src/constants.ts +1 -1
- package/src/mcp/core.ts +23 -24
- package/src/mcp/server.ts +11 -12
- package/src/project-context.ts +9 -1
- package/src/project-initializer.ts +4 -4
- package/src/session-rollover.ts +10 -0
- package/src/workflow-diagnosis.ts +407 -0
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
// plugins/devflow-quality/runtime/playwright/src/text-match.ts
|
|
2
|
+
function normalizeText(value, normalizeWhitespace, caseSensitive) {
|
|
3
|
+
let out = normalizeWhitespace ? value.replace(/\s+/g, " ").trim() : value;
|
|
4
|
+
if (!caseSensitive)
|
|
5
|
+
out = out.toLowerCase();
|
|
6
|
+
return out;
|
|
7
|
+
}
|
|
8
|
+
function textMatches(actual, expected, match) {
|
|
9
|
+
return match === "exact" ? actual === expected : actual.includes(expected);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// plugins/devflow-quality/runtime/playwright/src/explore/request-tracker.ts
|
|
13
|
+
class RequestTracker {
|
|
14
|
+
pending = new Set;
|
|
15
|
+
started = (request) => {
|
|
16
|
+
if (["fetch", "xhr", "document"].includes(request.resourceType()))
|
|
17
|
+
this.pending.add(request);
|
|
18
|
+
};
|
|
19
|
+
finished = (request) => {
|
|
20
|
+
this.pending.delete(request);
|
|
21
|
+
};
|
|
22
|
+
get size() {
|
|
23
|
+
return this.pending.size;
|
|
24
|
+
}
|
|
25
|
+
clear() {
|
|
26
|
+
this.pending.clear();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
1
30
|
// plugins/devflow-quality/runtime/playwright/src/explore/host.ts
|
|
2
31
|
import readline from "node:readline";
|
|
3
32
|
import fs2 from "node:fs";
|
|
@@ -226,6 +255,7 @@ class Redactor {
|
|
|
226
255
|
|
|
227
256
|
// plugins/devflow-quality/runtime/playwright/src/explore/view.ts
|
|
228
257
|
var INTERACTIVE = new Set(["button", "link", "textbox", "searchbox", "checkbox", "radio", "combobox", "listbox", "option", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "switch", "slider", "spinbutton", "treeitem"]);
|
|
258
|
+
var NAMEABLE = new Set(["dialog", "alertdialog", "region", "form", "navigation", ...INTERACTIVE]);
|
|
229
259
|
var INFO = new Set(["heading", "alert", "status", "dialog", "alertdialog"]);
|
|
230
260
|
var CONTEXT = new Set(["dialog", "alertdialog", "form", "navigation", "banner", "main", "region", "table", "row", "tabpanel", "menu"]);
|
|
231
261
|
var TEXT_CONTAINERS = new Set(["dialog", "alertdialog", "alert", "status"]);
|
|
@@ -393,6 +423,37 @@ async function nameRows(page, nodes) {
|
|
|
393
423
|
}
|
|
394
424
|
}
|
|
395
425
|
}
|
|
426
|
+
async function nameContainers(page, nodes) {
|
|
427
|
+
for (const node of nodes.filter((item) => item.ref && !item.name && NAMEABLE.has(item.role))) {
|
|
428
|
+
const element = page.locator(`aria-ref=${node.ref}`);
|
|
429
|
+
if (await element.count() !== 1)
|
|
430
|
+
continue;
|
|
431
|
+
const name = await element.evaluate((item) => {
|
|
432
|
+
const ariaLabel = item.getAttribute("aria-label")?.trim();
|
|
433
|
+
if (ariaLabel)
|
|
434
|
+
return ariaLabel.replace(/\s+/g, " ").trim();
|
|
435
|
+
const ids = item.getAttribute("aria-labelledby")?.trim().split(/\s+/).filter(Boolean) ?? [];
|
|
436
|
+
return ids.map((id) => document.getElementById(id)?.textContent ?? "").join(" ").replace(/\s+/g, " ").trim();
|
|
437
|
+
});
|
|
438
|
+
if (!name)
|
|
439
|
+
continue;
|
|
440
|
+
const roleLocator = page.getByRole(node.role, { name, exact: true });
|
|
441
|
+
if (await sameUniqueElement(roleLocator, element))
|
|
442
|
+
node.name = name;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
async function sameUniqueElement(candidate, element) {
|
|
446
|
+
if (await candidate.count() !== 1)
|
|
447
|
+
return false;
|
|
448
|
+
const candidateHandle = await candidate.elementHandle();
|
|
449
|
+
if (!candidateHandle)
|
|
450
|
+
return false;
|
|
451
|
+
try {
|
|
452
|
+
return await element.evaluate((node, other) => node === other, candidateHandle);
|
|
453
|
+
} finally {
|
|
454
|
+
await candidateHandle.dispose();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
396
457
|
|
|
397
458
|
class ExplorerView {
|
|
398
459
|
page;
|
|
@@ -410,22 +471,35 @@ class ExplorerView {
|
|
|
410
471
|
node(ref) {
|
|
411
472
|
return this.last?.get(ref);
|
|
412
473
|
}
|
|
413
|
-
async observe(options = {}) {
|
|
474
|
+
async observe(options = {}, settled = true) {
|
|
414
475
|
let scope = this.page.locator("body");
|
|
415
476
|
if (options.scope) {
|
|
416
477
|
const rawScope = options.scope;
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
478
|
+
const ref = /^(?:aria-ref=)?(e\d+)$/.exec(rawScope);
|
|
479
|
+
if (ref) {
|
|
480
|
+
if (!this.hasRef(ref[1]))
|
|
481
|
+
throw Object.assign(new Error(`unknown or stale ref ${ref[1]}`), { code: "stale_ref" });
|
|
482
|
+
scope = this.page.locator(`aria-ref=${ref[1]}`);
|
|
483
|
+
} else if (rawScope.startsWith("role=")) {
|
|
484
|
+
const value = rawScope.slice(5);
|
|
485
|
+
const [role, ...nameParts] = value.split(":");
|
|
486
|
+
if (!/^[-a-z]+$/.test(role) || nameParts.length && !nameParts.join(":").length)
|
|
487
|
+
throw Object.assign(new Error("scope must be eN, aria-ref=eN, role=<role>[:<name>], or testid=<id>"), { code: "invalid_params" });
|
|
488
|
+
scope = nameParts.length ? this.page.getByRole(role, { name: nameParts.join(":"), exact: true }) : this.page.getByRole(role);
|
|
489
|
+
} else if (rawScope.startsWith("testid=") && rawScope.slice(7))
|
|
422
490
|
scope = this.page.getByTestId(rawScope.slice(7));
|
|
423
491
|
else
|
|
424
|
-
scope =
|
|
492
|
+
throw Object.assign(new Error("scope must be eN, aria-ref=eN, role=<role>[:<name>], or testid=<id>"), { code: "invalid_params" });
|
|
493
|
+
const scopeCount = await scope.count();
|
|
494
|
+
if (scopeCount === 0)
|
|
495
|
+
throw Object.assign(new Error("scope does not match exactly one element"), { code: "scope_not_found" });
|
|
496
|
+
if (scopeCount > 1)
|
|
497
|
+
throw Object.assign(new Error("scope matches more than one element"), { code: "scope_ambiguous" });
|
|
425
498
|
}
|
|
426
499
|
const raw = await scope.ariaSnapshot({ mode: "ai" });
|
|
427
500
|
const nodes = parseSnapshot(raw);
|
|
428
501
|
await nameRows(this.page, nodes);
|
|
502
|
+
await nameContainers(this.page, nodes);
|
|
429
503
|
const view = prune(nodes, this.redactor, await passwordRefs(this.page, nodes));
|
|
430
504
|
const fullText = [...view.values()].map((node) => ` ${format(node, this.redactor)}`).join(`
|
|
431
505
|
`);
|
|
@@ -451,7 +525,7 @@ class ExplorerView {
|
|
|
451
525
|
${await this.tableText(scope)}`;
|
|
452
526
|
const text = `[page] ${this.redactor.url(url)} · ${this.redactor.text(await this.page.title())} [${mode}]
|
|
453
527
|
${body}`;
|
|
454
|
-
return { mode, text, stats: { raw: raw.length, pruned: fullText.length, sent: body.length } };
|
|
528
|
+
return { mode, text, settled, stats: { raw: raw.length, pruned: fullText.length, sent: body.length } };
|
|
455
529
|
}
|
|
456
530
|
async tableText(scope) {
|
|
457
531
|
const rows = scope.locator("table tbody tr:visible, .el-table__body-wrapper tbody tr:visible");
|
|
@@ -490,6 +564,18 @@ function inferredRole(tag, type) {
|
|
|
490
564
|
return;
|
|
491
565
|
return { checkbox: "checkbox", radio: "radio", range: "slider" }[type ?? ""] ?? "textbox";
|
|
492
566
|
}
|
|
567
|
+
async function sameUnique(candidate, element) {
|
|
568
|
+
if (await candidate.count() !== 1)
|
|
569
|
+
return false;
|
|
570
|
+
const candidateHandle = await candidate.elementHandle();
|
|
571
|
+
if (!candidateHandle)
|
|
572
|
+
return false;
|
|
573
|
+
try {
|
|
574
|
+
return await element.evaluate((node, other) => node === other, candidateHandle);
|
|
575
|
+
} finally {
|
|
576
|
+
await candidateHandle.dispose();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
493
579
|
|
|
494
580
|
class Recorder {
|
|
495
581
|
page;
|
|
@@ -508,11 +594,29 @@ class Recorder {
|
|
|
508
594
|
this.feature = feature;
|
|
509
595
|
this.environmentProfile = environmentProfile;
|
|
510
596
|
this.startUrl = startUrl;
|
|
511
|
-
|
|
597
|
+
const initial = new URL(redactor.url(startUrl));
|
|
598
|
+
this.steps.push({ action: "goto", url: `${initial.pathname}${initial.search}${initial.hash}` });
|
|
512
599
|
}
|
|
513
600
|
async unique(locator) {
|
|
514
601
|
return await locator.count() === 1;
|
|
515
602
|
}
|
|
603
|
+
async rowTarget(element, role, name) {
|
|
604
|
+
const row = element.locator("xpath=ancestor::tr[1]");
|
|
605
|
+
if (await row.count() !== 1)
|
|
606
|
+
return null;
|
|
607
|
+
const cells = row.locator(":scope > td:visible, :scope > th:visible");
|
|
608
|
+
for (let i = 0;i < await cells.count(); i++) {
|
|
609
|
+
const markerText = (await cells.nth(i).innerText()).replace(/\s+/g, " ").trim();
|
|
610
|
+
if (markerText.length < 2)
|
|
611
|
+
continue;
|
|
612
|
+
const rows = this.page.getByRole("row", { name: markerText, exact: false });
|
|
613
|
+
const inner = rows.getByRole(role, { name, exact: true });
|
|
614
|
+
if (await rows.count() === 1 && await sameUnique(inner, element)) {
|
|
615
|
+
return { locator: { within: { role: "row", name: markerText, exact: false }, role, name, exact: true }, data_dependent: true, reviewReasons: ["data_dependent_locator"] };
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
516
620
|
async contextTarget(node, role, name) {
|
|
517
621
|
const context = node.ctx.split(" > ").filter((item) => /^(dialog|alertdialog|form|navigation|banner|main|region)\b/.test(item)).pop();
|
|
518
622
|
if (!context)
|
|
@@ -525,60 +629,10 @@ class Recorder {
|
|
|
525
629
|
const within = contextName ? { role: contextRole, name: contextName, exact: true } : { role: contextRole };
|
|
526
630
|
const root = contextName ? this.page.getByRole(contextRole, { name: contextName, exact: true }) : this.page.getByRole(contextRole);
|
|
527
631
|
const inner = root.getByRole(role, { name, exact: true });
|
|
528
|
-
if (await this.unique(root) && await this.
|
|
632
|
+
if (await this.unique(root) && await sameUnique(inner, this.page.locator(`aria-ref=${node.ref}`)))
|
|
529
633
|
return { locator: { within, role, name, exact: true } };
|
|
530
634
|
return null;
|
|
531
635
|
}
|
|
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
636
|
async targetFor(ref) {
|
|
583
637
|
if (!this.view.hasRef(ref))
|
|
584
638
|
throw Object.assign(new Error(`unknown or stale ref ${ref}`), { code: "stale_ref" });
|
|
@@ -589,27 +643,29 @@ class Recorder {
|
|
|
589
643
|
if (await element.count() !== 1)
|
|
590
644
|
throw Object.assign(new Error(`ref ${ref} is not unique`), { code: "stale_ref" });
|
|
591
645
|
const testid = await element.getAttribute("data-testid");
|
|
592
|
-
if (testid && await
|
|
646
|
+
if (testid && await sameUnique(this.page.getByTestId(testid), element))
|
|
593
647
|
return { locator: `testid:${testid}` };
|
|
594
648
|
const tag = await element.evaluate((item) => item.tagName.toLowerCase());
|
|
595
649
|
const role = node.role || inferredRole(tag, await element.getAttribute("type"));
|
|
596
650
|
const name = node.name;
|
|
597
651
|
if (role && name) {
|
|
598
652
|
const roleLocator = this.page.getByRole(role, { name, exact: true });
|
|
599
|
-
|
|
600
|
-
|
|
653
|
+
const rowTarget = await this.rowTarget(element, role, name);
|
|
654
|
+
if (rowTarget)
|
|
655
|
+
return rowTarget;
|
|
656
|
+
if (await sameUnique(roleLocator, element)) {
|
|
657
|
+
const inRow2 = await element.locator("xpath=ancestor::tr[1]").count() === 1;
|
|
658
|
+
return { locator: `role:${role}:${name}`, ...inRow2 ? { data_dependent: true, reviewReasons: ["row_scope_missing"] } : {} };
|
|
659
|
+
}
|
|
601
660
|
const within = await this.contextTarget(node, role, name);
|
|
602
661
|
if (within)
|
|
603
662
|
return within;
|
|
604
|
-
const tableTarget = await this.elementPlusTarget(element, role, name);
|
|
605
|
-
if (tableTarget)
|
|
606
|
-
return tableTarget;
|
|
607
663
|
}
|
|
608
664
|
const ariaLabel = await element.getAttribute("aria-label");
|
|
609
|
-
if (ariaLabel && await
|
|
665
|
+
if (ariaLabel && await sameUnique(this.page.getByLabel(ariaLabel, { exact: true }), element))
|
|
610
666
|
return { locator: `label:${ariaLabel}` };
|
|
611
667
|
const placeholder = await element.getAttribute("placeholder");
|
|
612
|
-
if (placeholder && await
|
|
668
|
+
if (placeholder && await sameUnique(this.page.getByPlaceholder(placeholder, { exact: true }), element))
|
|
613
669
|
return { locator: `placeholder:${placeholder}` };
|
|
614
670
|
const candidates = [];
|
|
615
671
|
if (testid)
|
|
@@ -618,19 +674,28 @@ class Recorder {
|
|
|
618
674
|
candidates.push(`role:${role}:${name}`);
|
|
619
675
|
if (placeholder)
|
|
620
676
|
candidates.push(`placeholder:${placeholder}`);
|
|
621
|
-
|
|
677
|
+
const inRow = await element.locator("xpath=ancestor::tr[1]").count() === 1;
|
|
678
|
+
return { needs_locator: true, candidates: candidates.map((candidate) => this.redactor.value(candidate)), ...inRow ? { data_dependent: true, reviewReasons: ["row_scope_missing"] } : {} };
|
|
622
679
|
}
|
|
623
680
|
recordPrepared(id, action, target, extra = {}) {
|
|
624
681
|
const step = { action, ...extra, locator: target.locator ?? "" };
|
|
625
682
|
this.steps.push(step);
|
|
626
683
|
this.explorationSteps.push({
|
|
627
684
|
id,
|
|
685
|
+
action,
|
|
686
|
+
role: typeof target.locator === "object" && target.locator ? target.locator.role : undefined,
|
|
628
687
|
...target.needs_locator ? { needs_locator: true } : {},
|
|
629
688
|
...target.candidates ? { candidates: target.candidates } : {},
|
|
630
689
|
...target.data_dependent ? { data_dependent: true } : {},
|
|
690
|
+
...target.reviewReasons ? { reviewReasons: target.reviewReasons } : {},
|
|
631
691
|
...action.startsWith("assert_") ? { oracle_hint: "characterization" } : {}
|
|
632
692
|
});
|
|
633
693
|
}
|
|
694
|
+
recordAssertionReview(id, kind, expectedFromPage, circular, targetName, weak = false) {
|
|
695
|
+
const item = this.explorationSteps.find((step) => step.id === id);
|
|
696
|
+
if (item)
|
|
697
|
+
Object.assign(item, { ...expectedFromPage ? { expected_from_page: true } : {}, ...circular ? { circular: true } : {}, ...weak ? { reviewReasons: [...item.reviewReasons ?? [], "weak_assertion"] } : {} });
|
|
698
|
+
}
|
|
634
699
|
noteSecretVariable(name) {
|
|
635
700
|
this.secretVariables.add(name);
|
|
636
701
|
}
|
|
@@ -640,16 +705,50 @@ class Recorder {
|
|
|
640
705
|
await fs.mkdir(root, { recursive: true, mode: 448 });
|
|
641
706
|
const steps = this.steps.map((step, index) => ({ id: `s${String(index + 1).padStart(2, "0")}`, ...step }));
|
|
642
707
|
const metadata = this.explorationSteps.map((item, index) => ({ ...item, id: `s${String(index + 2).padStart(2, "0")}` }));
|
|
643
|
-
const
|
|
708
|
+
const review_items = [];
|
|
709
|
+
const reviewKeys = new Set;
|
|
710
|
+
const addReview = (item) => {
|
|
711
|
+
const key = `${item.step}\x00${item.reason}`;
|
|
712
|
+
if (!reviewKeys.has(key)) {
|
|
713
|
+
reviewKeys.add(key);
|
|
714
|
+
review_items.push(item);
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
for (const item of metadata) {
|
|
718
|
+
for (const reason of item.reviewReasons ?? [])
|
|
719
|
+
addReview({ step: item.id, reason });
|
|
720
|
+
if (item.needs_locator)
|
|
721
|
+
addReview({ step: item.id, reason: "needs_locator" });
|
|
722
|
+
if (item.expected_from_page)
|
|
723
|
+
addReview({ step: item.id, reason: "expected_from_page" });
|
|
724
|
+
if (item.circular)
|
|
725
|
+
addReview({ step: item.id, reason: "circular_assertion" });
|
|
726
|
+
if (item.action === "assert_visible" && ["button", "link"].includes(item.role ?? ""))
|
|
727
|
+
addReview({ step: item.id, reason: "weak_assertion" });
|
|
728
|
+
}
|
|
729
|
+
const business = metadata.some((item) => item.action?.startsWith("assert_") && item.action !== "assert_visible" && !item.circular);
|
|
730
|
+
if (!business)
|
|
731
|
+
addReview({ step: "case", reason: "no_business_assertion" });
|
|
732
|
+
const manual = [...new Set(review_items.filter((item) => item.reason !== "expected_from_page" && item.reason !== "data_dependent_locator").map((item) => item.step))];
|
|
644
733
|
const draft = {
|
|
645
734
|
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 }
|
|
735
|
+
exploration: { environment_profile: this.environmentProfile, start_url: this.redactor.url(this.startUrl), recorded_steps: steps.length, needs_manual: manual, review_items, steps: metadata }
|
|
647
736
|
};
|
|
648
737
|
const output = path.join(root, fileName);
|
|
649
|
-
const
|
|
738
|
+
const redactLiterals = (value) => {
|
|
739
|
+
if (typeof value === "string")
|
|
740
|
+
return this.redactor.text(value);
|
|
741
|
+
if (Array.isArray(value))
|
|
742
|
+
return value.map(redactLiterals);
|
|
743
|
+
if (value && typeof value === "object") {
|
|
744
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactLiterals(item)]));
|
|
745
|
+
}
|
|
746
|
+
return value;
|
|
747
|
+
};
|
|
748
|
+
const serialized = JSON.stringify(redactLiterals(draft), null, 2);
|
|
650
749
|
await fs.writeFile(output, `${serialized}
|
|
651
750
|
`, { mode: 384 });
|
|
652
|
-
return { path: output, manual };
|
|
751
|
+
return { path: output, manual, review_items };
|
|
653
752
|
}
|
|
654
753
|
}
|
|
655
754
|
|
|
@@ -660,8 +759,22 @@ var page = null;
|
|
|
660
759
|
var view = null;
|
|
661
760
|
var recorder = null;
|
|
662
761
|
var redactor = null;
|
|
762
|
+
var profileMemory = null;
|
|
663
763
|
var idleTimer = null;
|
|
664
764
|
var totalTimer = null;
|
|
765
|
+
var requests = new RequestTracker;
|
|
766
|
+
function installRequestCounter(currentContext) {
|
|
767
|
+
requests.clear();
|
|
768
|
+
currentContext.on("request", requests.started);
|
|
769
|
+
currentContext.on("requestfinished", requests.finished);
|
|
770
|
+
currentContext.on("requestfailed", requests.finished);
|
|
771
|
+
}
|
|
772
|
+
function removeRequestCounter() {
|
|
773
|
+
context?.off("request", requests.started);
|
|
774
|
+
context?.off("requestfinished", requests.finished);
|
|
775
|
+
context?.off("requestfailed", requests.finished);
|
|
776
|
+
requests.clear();
|
|
777
|
+
}
|
|
665
778
|
function reply(id, result) {
|
|
666
779
|
process.stdout.write(`${JSON.stringify({ id, ok: true, result })}
|
|
667
780
|
`);
|
|
@@ -680,12 +793,14 @@ async function shutdown() {
|
|
|
680
793
|
clearTimeout(totalTimer);
|
|
681
794
|
idleTimer = null;
|
|
682
795
|
totalTimer = null;
|
|
796
|
+
removeRequestCounter();
|
|
683
797
|
await browser?.close().catch(() => {});
|
|
684
798
|
browser = null;
|
|
685
799
|
context = null;
|
|
686
800
|
page = null;
|
|
687
801
|
view = null;
|
|
688
802
|
recorder = null;
|
|
803
|
+
profileMemory = null;
|
|
689
804
|
}
|
|
690
805
|
function touch() {
|
|
691
806
|
if (idleTimer)
|
|
@@ -708,6 +823,33 @@ function secretValue(key, profile) {
|
|
|
708
823
|
throw Object.assign(new Error(`secret ${String(key)} is unavailable`), { code: "secret_unavailable" });
|
|
709
824
|
return value;
|
|
710
825
|
}
|
|
826
|
+
async function settle(currentPage) {
|
|
827
|
+
const deadline = Date.now() + 8000;
|
|
828
|
+
await currentPage.waitForLoadState("domcontentloaded").catch(() => {});
|
|
829
|
+
if (!context)
|
|
830
|
+
return true;
|
|
831
|
+
let quietSince = 0;
|
|
832
|
+
let previousSnapshot = null;
|
|
833
|
+
while (Date.now() < deadline) {
|
|
834
|
+
if (requests.size === 0) {
|
|
835
|
+
if (!quietSince)
|
|
836
|
+
quietSince = Date.now();
|
|
837
|
+
if (Date.now() - quietSince >= 500) {
|
|
838
|
+
const snapshot = await currentPage.ariaSnapshot({ mode: "ai" }).catch(() => "");
|
|
839
|
+
if (previousSnapshot !== null && snapshot === previousSnapshot)
|
|
840
|
+
return true;
|
|
841
|
+
previousSnapshot = snapshot;
|
|
842
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
} else {
|
|
846
|
+
quietSince = 0;
|
|
847
|
+
previousSnapshot = null;
|
|
848
|
+
}
|
|
849
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
850
|
+
}
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
711
853
|
async function doStart(request) {
|
|
712
854
|
if (page)
|
|
713
855
|
throw Object.assign(new Error("only one explore session is allowed"), { code: "session_exists" });
|
|
@@ -715,24 +857,32 @@ async function doStart(request) {
|
|
|
715
857
|
if (!environmentFile || !path2.isAbsolute(environmentFile)) {
|
|
716
858
|
throw Object.assign(new Error("environment profile path is missing"), { code: "environment_profile_invalid" });
|
|
717
859
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
860
|
+
try {
|
|
861
|
+
const profile = JSON.parse(fs2.readFileSync(environmentFile, "utf8"));
|
|
862
|
+
profileMemory = profile;
|
|
863
|
+
const policy = buildPolicy(profile);
|
|
864
|
+
redactor = new Redactor;
|
|
865
|
+
for (const envName of Object.values(profile.secret_env ?? {})) {
|
|
866
|
+
const value = process.env[envName];
|
|
867
|
+
if (value)
|
|
868
|
+
redactor.add(value);
|
|
869
|
+
}
|
|
870
|
+
browser = await chromium.launch();
|
|
871
|
+
context = await browser.newContext();
|
|
872
|
+
installRequestCounter(context);
|
|
873
|
+
await new NetworkRecorder(redactor).install(context, policy);
|
|
874
|
+
page = await context.newPage();
|
|
875
|
+
const startUrl = absoluteUrl(String(request.params?.url ?? "/"), profile);
|
|
876
|
+
await page.goto(startUrl, { waitUntil: "domcontentloaded" });
|
|
877
|
+
const settled = await settle(page);
|
|
878
|
+
view = new ExplorerView(page, redactor);
|
|
879
|
+
recorder = new Recorder(page, view, redactor, String(request.params?.feature ?? "explore"), profile.profile ?? path2.basename(environmentFile, path2.extname(environmentFile)), startUrl);
|
|
880
|
+
totalTimer = setTimeout(() => void shutdown(), 60 * 60 * 1000);
|
|
881
|
+
reply(request.id, await view.observe({ full: true }, settled));
|
|
882
|
+
} catch (error) {
|
|
883
|
+
await shutdown();
|
|
884
|
+
throw error;
|
|
885
|
+
}
|
|
736
886
|
}
|
|
737
887
|
async function doAct(request) {
|
|
738
888
|
const current = session();
|
|
@@ -745,10 +895,10 @@ async function doAct(request) {
|
|
|
745
895
|
let value = request.params?.value;
|
|
746
896
|
let secretKey;
|
|
747
897
|
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
898
|
secretKey = request.params.value_from_secret;
|
|
751
|
-
|
|
899
|
+
if (!profileMemory)
|
|
900
|
+
throw Object.assign(new Error("explore profile is unavailable"), { code: "environment_profile_invalid" });
|
|
901
|
+
value = secretValue(secretKey, profileMemory);
|
|
752
902
|
current.redactor.add(value);
|
|
753
903
|
current.recorder.noteSecretVariable(String(secretKey));
|
|
754
904
|
}
|
|
@@ -775,14 +925,14 @@ async function doAct(request) {
|
|
|
775
925
|
default:
|
|
776
926
|
throw Object.assign(new Error(`unknown action ${action}`), { code: "invalid_params" });
|
|
777
927
|
}
|
|
778
|
-
await current.page
|
|
928
|
+
const settled = await settle(current.page);
|
|
779
929
|
const id = `s${String(current.recorder.steps.length + 1).padStart(2, "0")}`;
|
|
780
930
|
const extra = {};
|
|
781
931
|
if (action === "fill" || action === "select" || action === "press") {
|
|
782
932
|
extra.value = secretKey === undefined ? current.redactor.text(String(value ?? "")) : `\${${String(secretKey)}}`;
|
|
783
933
|
}
|
|
784
934
|
current.recorder.recordPrepared(id, action, prepared, extra);
|
|
785
|
-
reply(request.id, await current.view.observe());
|
|
935
|
+
reply(request.id, { ...await current.view.observe({}, settled), settled });
|
|
786
936
|
}
|
|
787
937
|
async function doAssert(request) {
|
|
788
938
|
const current = session();
|
|
@@ -798,24 +948,25 @@ async function doAssert(request) {
|
|
|
798
948
|
const match = request.params?.match === "contains" ? "contains" : "exact";
|
|
799
949
|
const normalizeWhitespace = request.params?.normalize_whitespace !== false;
|
|
800
950
|
const caseSensitive = request.params?.case_sensitive !== false;
|
|
951
|
+
const matchesText = (actual, expected2) => textMatches(normalizeText(actual, normalizeWhitespace, caseSensitive), normalizeText(expected2, normalizeWhitespace, caseSensitive), match);
|
|
801
952
|
if (kind === "visible") {
|
|
802
953
|
if (!await locator.isVisible())
|
|
803
954
|
throw Object.assign(new Error("element is not visible"), { code: "assertion_failed" });
|
|
804
955
|
} else if (kind === "value") {
|
|
805
956
|
const actual = await locator.inputValue();
|
|
806
957
|
expected = String(requested ?? actual);
|
|
807
|
-
if (actual
|
|
958
|
+
if (!matchesText(actual, expected))
|
|
808
959
|
throw Object.assign(new Error(`value assertion failed: expected ${current.redactor.text(expected)}`), { code: "assertion_failed" });
|
|
809
960
|
} else if (kind === "text") {
|
|
810
961
|
const actual = await locator.innerText();
|
|
811
962
|
expected = String(requested ?? actual.trim());
|
|
812
|
-
const matches =
|
|
963
|
+
const matches = matchesText(actual, expected);
|
|
813
964
|
if (!matches)
|
|
814
965
|
throw Object.assign(new Error(`text assertion failed: expected ${current.redactor.text(expected)}`), { code: "assertion_failed" });
|
|
815
966
|
} else if (kind === "each_text") {
|
|
816
967
|
const actual = await locator.allInnerTexts();
|
|
817
968
|
expected = typeof requested === "string" ? requested : actual[0]?.trim() ?? "";
|
|
818
|
-
const matches = actual.every((item) =>
|
|
969
|
+
const matches = actual.length > 0 && actual.every((item) => matchesText(item, expected));
|
|
819
970
|
if (!matches)
|
|
820
971
|
throw Object.assign(new Error("each_text assertion failed"), { code: "assertion_failed" });
|
|
821
972
|
} else {
|
|
@@ -832,7 +983,16 @@ async function doAssert(request) {
|
|
|
832
983
|
extra.case_sensitive = caseSensitive;
|
|
833
984
|
}
|
|
834
985
|
current.recorder.recordPrepared(`s${String(current.recorder.steps.length + 1).padStart(2, "0")}`, action, prepared, extra);
|
|
835
|
-
|
|
986
|
+
const settled = await settle(current.page);
|
|
987
|
+
const targetName = current.view.node(ref)?.name ?? "";
|
|
988
|
+
const normalize = (value) => value.replace(/\s+/g, " ").trim();
|
|
989
|
+
const normalizedExpected = expected === undefined ? "" : normalize(expected);
|
|
990
|
+
const normalizedName = normalize(targetName);
|
|
991
|
+
const withinName = typeof prepared.locator === "object" && prepared.locator !== null ? String(prepared.locator.within?.name ?? "") : "";
|
|
992
|
+
const normalizedWithinName = normalize(withinName);
|
|
993
|
+
const circular = kind !== "visible" && Boolean(normalizedExpected && (normalizedName && (normalizedExpected.includes(normalizedName) || normalizedName.includes(normalizedExpected)) || normalizedWithinName && (normalizedExpected.includes(normalizedWithinName) || normalizedWithinName.includes(normalizedExpected))));
|
|
994
|
+
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 ?? ""));
|
|
995
|
+
reply(request.id, { actual: current.redactor.value(kind === "each_text" ? await locator.allInnerTexts() : expected), view: await current.view.observe({}, settled), settled });
|
|
836
996
|
}
|
|
837
997
|
async function handle(request) {
|
|
838
998
|
touch();
|
|
@@ -863,9 +1023,12 @@ async function handle(request) {
|
|
|
863
1023
|
}
|
|
864
1024
|
}
|
|
865
1025
|
var input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
1026
|
+
var requestQueue = Promise.resolve();
|
|
866
1027
|
input.on("line", (line) => {
|
|
867
1028
|
try {
|
|
868
|
-
|
|
1029
|
+
const request = JSON.parse(line);
|
|
1030
|
+
requestQueue = requestQueue.then(() => handle(request)).catch((error) => process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
1031
|
+
`));
|
|
869
1032
|
} catch (error) {
|
|
870
1033
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
871
1034
|
`);
|