@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.
Files changed (25) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +591 -274
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +6 -1
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +3 -1
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +3 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_traceability.py +31 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +6 -3
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/environment_profile.py +11 -2
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +14 -5
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +12 -5
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +8 -0
  13. package/plugins/devflow-intel/extensions/devflow-intel.ts +25 -3
  14. package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +264 -101
  15. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +41 -14
  16. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +2 -2
  17. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +2 -0
  18. package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +3 -0
  19. package/src/constants.ts +1 -1
  20. package/src/mcp/core.ts +23 -24
  21. package/src/mcp/server.ts +11 -12
  22. package/src/project-context.ts +9 -1
  23. package/src/project-initializer.ts +4 -4
  24. package/src/session-rollover.ts +10 -0
  25. 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
- 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="))
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 = this.page.locator(rawScope);
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
- this.steps.push({ action: "goto", url: new URL(startUrl).pathname });
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.unique(inner))
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 this.unique(this.page.getByTestId(testid)))
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
- if (await this.unique(roleLocator))
600
- return { locator: `role:${role}:${name}` };
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 this.unique(this.page.getByLabel(ariaLabel, { exact: true })))
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 this.unique(this.page.getByPlaceholder(placeholder, { exact: true })))
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
- return { needs_locator: true, candidates: candidates.map((candidate) => this.redactor.value(candidate)) };
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 manual = metadata.filter((item) => item.needs_locator).map((item) => item.id);
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 serialized = this.redactor.text(JSON.stringify(draft, null, 2));
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
- 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 }));
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
- value = secretValue(secretKey, profile);
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.waitForLoadState("domcontentloaded").catch(() => {});
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 !== expected)
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 = match === "contains" ? actual.includes(expected) : actual.trim() === expected;
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) => match === "contains" ? item.includes(expected) : item.trim() === expected);
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
- reply(request.id, { actual: current.redactor.value(kind === "each_text" ? await locator.allInnerTexts() : expected), view: await current.view.observe() });
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
- handle(JSON.parse(line));
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
  `);