@capagents/forgeqe 1.2.1 → 1.3.0
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/HOW_TO_USE.md +39 -2
- package/README.md +1 -1
- package/docs/COST_ARCHITECTURE.md +280 -0
- package/docs/feature-test-prompts.md +95 -0
- package/docs/user-guide.html +53 -6
- package/forgeqe.example.yaml +15 -0
- package/package.json +1 -1
- package/prompts/brain.md +35 -4
- package/prompts/browser-checks.md +2 -0
- package/py/forgeqe_engine/checks.py +8 -2
- package/py/forgeqe_engine/llm.py +33 -5
- package/py/forgeqe_engine/memory.py +59 -2
- package/py/forgeqe_engine/observe.py +123 -0
- package/py/forgeqe_engine/replay.py +55 -2
- package/py/forgeqe_engine/service.py +155 -3
- package/src/capabilities/registry.ts +19 -5
- package/src/cases/ado.ts +1 -1
- package/src/cases/journey.ts +61 -0
- package/src/cases/readiness.ts +35 -6
- package/src/cases/save.ts +241 -48
- package/src/cases/types.ts +11 -0
- package/src/cli/help.ts +8 -0
- package/src/cli/index.ts +64 -1
- package/src/code/coverage.ts +22 -3
- package/src/code/decisions.ts +73 -0
- package/src/code/find.ts +3 -1
- package/src/code/graph/graph.ts +33 -2
- package/src/code/loop.ts +17 -1
- package/src/code/notes.ts +48 -1
- package/src/code/prompt.ts +31 -9
- package/src/code/protect.ts +103 -0
- package/src/code/review.ts +2 -0
- package/src/code/runtime.ts +35 -3
- package/src/code/shortlist.ts +309 -0
- package/src/code/testrun.ts +3 -1
- package/src/code/traceability.ts +2 -0
- package/src/commands.ts +6 -1
- package/src/config.ts +46 -0
- package/src/engine/bridge.ts +47 -5
- package/src/mcp/casetools.ts +107 -11
- package/src/mcp/commands.ts +74 -20
- package/src/mcp/packtools.ts +187 -0
- package/src/mcp/tools.ts +238 -29
- package/src/pack/collect.ts +251 -0
- package/src/pack/index.ts +134 -0
- package/src/pack/init.ts +45 -0
- package/src/pack/pack.ts +327 -0
- package/src/pack/pipeline.ts +373 -0
- package/src/pack/run.ts +155 -0
- package/src/registry/db.ts +249 -0
- package/src/registry/index.ts +72 -0
- package/src/registry/resolve.ts +57 -0
- package/src/registry/tier0.ts +81 -0
- package/src/report/data.ts +7 -0
- package/src/report/html.ts +14 -0
- package/src/report/template.html +52 -48
- package/src/report/usage.ts +113 -0
- package/src/run.ts +22 -2
- package/src/suite/run.ts +12 -5
- package/src/summary.ts +1 -0
- package/src/tokenomics/budget.ts +172 -0
- package/src/tokenomics/ledger.ts +287 -0
- package/src/tui/App.tsx +89 -2
- package/src/tui/inspector.tsx +40 -35
- package/src/types.ts +9 -2
|
@@ -15,6 +15,7 @@ import os
|
|
|
15
15
|
import re
|
|
16
16
|
import time
|
|
17
17
|
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timezone
|
|
18
19
|
from pathlib import Path
|
|
19
20
|
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
|
20
21
|
|
|
@@ -25,6 +26,7 @@ from .memory import READ_ONLY_ACTIONS, MemoryStore, compact_step, flow_step, pag
|
|
|
25
26
|
from .prompts import CONVENTIONS, text as prompt_text, use_prompts
|
|
26
27
|
from .report import CASE_REPORT_INSTRUCTION, CaseReport, build_timeline, case_report_from_history
|
|
27
28
|
from .replay import ReplayOutcome, handoff_task, replay_flow
|
|
29
|
+
from .observe import check_key, checkable, checks_for_flow
|
|
28
30
|
from .settings import (
|
|
29
31
|
_truthy_env,
|
|
30
32
|
_browser_use_agent_tuning_kwargs,
|
|
@@ -107,6 +109,9 @@ class RunRequest:
|
|
|
107
109
|
logins: List[Dict[str, Any]] = field(default_factory=list)
|
|
108
110
|
# Number of steps in the test case this goal came from; the agent then reports a verdict for each.
|
|
109
111
|
case_steps: int = 0
|
|
112
|
+
# The test case and a key per step (changes when the step is reworded): an edited case replays its unchanged steps.
|
|
113
|
+
case_id: str = ""
|
|
114
|
+
case_step_keys: List[str] = field(default_factory=list)
|
|
110
115
|
# Step screenshots for the report: "all", "failures", or "off".
|
|
111
116
|
screenshots: str = "all"
|
|
112
117
|
# Accessibility checks on each page visited, for the report.
|
|
@@ -139,6 +144,8 @@ class RunRequest:
|
|
|
139
144
|
return cls(
|
|
140
145
|
logins=[x for x in (d.get("logins") or []) if isinstance(x, dict)],
|
|
141
146
|
case_steps=int(d["case_steps"]) if str(d.get("case_steps") or "").isdigit() else 0,
|
|
147
|
+
case_id=str(d.get("case_id") or ""),
|
|
148
|
+
case_step_keys=[str(k) for k in (d.get("case_step_keys") or []) if str(k)],
|
|
142
149
|
screenshots=str(d.get("screenshots") or "all").lower(),
|
|
143
150
|
accessibility=d.get("accessibility") is not False,
|
|
144
151
|
overlay=d.get("overlay") is not False,
|
|
@@ -395,6 +402,41 @@ def serialize_browser_step_payload(browser_state: Any, agent_output: Any, n_step
|
|
|
395
402
|
|
|
396
403
|
|
|
397
404
|
REPLAY_MODES = ("verify", "trust", "off")
|
|
405
|
+
# checks_first (docs/COST_ARCHITECTURE.md): every Nth replay of a flow is still checked by the agent, so a check that
|
|
406
|
+
# passes when it should not is caught; and a flow not passed for this long is checked by the agent again.
|
|
407
|
+
CANARY_EVERY = 10
|
|
408
|
+
MAX_FLOW_AGE_DAYS = 30
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _int(raw: Any, default: int) -> int:
|
|
412
|
+
try:
|
|
413
|
+
n = int(raw)
|
|
414
|
+
except (TypeError, ValueError):
|
|
415
|
+
return default
|
|
416
|
+
return n if n >= 0 else default
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def judged_by_checks(flow: Dict[str, Any], replay: Any, mem: "MemoryRun") -> Optional[Dict[str, Any]]:
|
|
420
|
+
"""
|
|
421
|
+
After a complete replay of an exact flow: whether its recorded checks decide the run with no model. ok: every
|
|
422
|
+
check was found on the page again. Otherwise `why` says what leaves it to the agent (today's verify path).
|
|
423
|
+
"""
|
|
424
|
+
why = checkable(flow)
|
|
425
|
+
if why:
|
|
426
|
+
return {"ok": False, "why": why}
|
|
427
|
+
if len(replay.checked) < len(flow.get("checks") or []) or not all(c.get("holds_now") for c in replay.checked):
|
|
428
|
+
return {"ok": False, "why": "not every recorded check was found on the page again"}
|
|
429
|
+
replays = int(flow.get("replays") or 0) + 1
|
|
430
|
+
if mem.canary_every and replays % mem.canary_every == 0:
|
|
431
|
+
return {"ok": False, "why": f"replay {replays} of this flow: every {mem.canary_every}th is checked by the agent too"}
|
|
432
|
+
last = str(flow.get("last_success") or "")
|
|
433
|
+
try:
|
|
434
|
+
age = (datetime.now(timezone.utc) - datetime.fromisoformat(last.replace("Z", "+00:00"))).days if last else None
|
|
435
|
+
except ValueError:
|
|
436
|
+
age = None
|
|
437
|
+
if age is None or age > mem.max_age_days:
|
|
438
|
+
return {"ok": False, "why": f"the flow last passed more than {mem.max_age_days} days ago" if age is not None else "the flow has no recorded pass"}
|
|
439
|
+
return {"ok": True, "why": ""}
|
|
398
440
|
|
|
399
441
|
|
|
400
442
|
@dataclass
|
|
@@ -418,6 +460,59 @@ class MemoryRun:
|
|
|
418
460
|
used_checkpoint: str = ""
|
|
419
461
|
# Actions that worked without changing the page (dead clicks, read-only scripts): not part of the flow.
|
|
420
462
|
no_effect: List[str] = field(default_factory=list)
|
|
463
|
+
# The test case this run is for, and a key per case step.
|
|
464
|
+
case_id: str = ""
|
|
465
|
+
case_keys: List[str] = field(default_factory=list)
|
|
466
|
+
# Case step number -> flow steps done when its expected result was recorded (the end of that step).
|
|
467
|
+
check_at: Dict[int, int] = field(default_factory=dict)
|
|
468
|
+
# Ends of the case steps a replay already did, carried over from the flow that was replayed.
|
|
469
|
+
carried_ends: List[Optional[int]] = field(default_factory=list)
|
|
470
|
+
# registry.mode on (docs/COST_ARCHITECTURE.md): the flow keeps its checks and a replay is judged by them.
|
|
471
|
+
checks_first: bool = False
|
|
472
|
+
# The checks the replayed flow had, and those the agent recorded this run with the flow step each came after.
|
|
473
|
+
flow_checks: List[Dict[str, Any]] = field(default_factory=list)
|
|
474
|
+
recorded: List[Dict[str, Any]] = field(default_factory=list)
|
|
475
|
+
check_pos: Dict[str, int] = field(default_factory=dict)
|
|
476
|
+
check_text: Dict[str, Any] = field(default_factory=dict)
|
|
477
|
+
canary_every: int = CANARY_EVERY
|
|
478
|
+
max_age_days: int = MAX_FLOW_AGE_DAYS
|
|
479
|
+
|
|
480
|
+
def checks_to_save(self, status: str) -> Optional[List[Dict[str, Any]]]:
|
|
481
|
+
"""The checks the flow keeps after this run; None leaves the flow's as they are."""
|
|
482
|
+
if not self.checks_first or status != "complete":
|
|
483
|
+
return None
|
|
484
|
+
new = checks_for_flow(self.recorded, self.check_pos)
|
|
485
|
+
if not self.llm_steps:
|
|
486
|
+
# A replay judged by its checks: the same steps, so the same checks.
|
|
487
|
+
return list(self.flow_checks) if self.flow_checks else None
|
|
488
|
+
# Checks from the replayed part still stand where the agent did not record them again.
|
|
489
|
+
done = len(self.replay.done) if self.replay else 0
|
|
490
|
+
keys = {check_key(c) for c in new}
|
|
491
|
+
carried = [c for c in self.flow_checks if 0 < int(c.get("at") or 0) <= done and check_key(c) not in keys]
|
|
492
|
+
return carried + new
|
|
493
|
+
|
|
494
|
+
def note_checks(self, checks: List[Dict[str, Any]]) -> None:
|
|
495
|
+
"""After an agent step: each case step whose check was just recorded ends at the flow as it is now."""
|
|
496
|
+
self.recorded = checks
|
|
497
|
+
for c in checks:
|
|
498
|
+
key = check_key(c)
|
|
499
|
+
# A check recorded again (the same expected result, new text) moves to where it was recorded last.
|
|
500
|
+
if key and self.check_text.get(key) != c.get("text"):
|
|
501
|
+
self.check_pos[key] = len(self.steps)
|
|
502
|
+
self.check_text[key] = c.get("text")
|
|
503
|
+
for c in checks:
|
|
504
|
+
n = c.get("step")
|
|
505
|
+
if isinstance(n, int) and 0 < n <= len(self.case_keys) and n not in self.check_at:
|
|
506
|
+
self.check_at[n] = len(self.steps)
|
|
507
|
+
|
|
508
|
+
def case_ends(self) -> List[Optional[int]]:
|
|
509
|
+
ends: List[Optional[int]] = [None] * len(self.case_keys)
|
|
510
|
+
for i, e in enumerate(self.carried_ends[: len(ends)]):
|
|
511
|
+
ends[i] = e
|
|
512
|
+
for n, at in self.check_at.items():
|
|
513
|
+
if ends[n - 1] is None:
|
|
514
|
+
ends[n - 1] = at
|
|
515
|
+
return ends
|
|
421
516
|
|
|
422
517
|
def checkpoint(self) -> Optional[Dict[str, Any]]:
|
|
423
518
|
"""Save the steps that worked so far and the page memory learned so far, so a run cut off here can be picked up."""
|
|
@@ -447,7 +542,14 @@ class MemoryRun:
|
|
|
447
542
|
if not folder:
|
|
448
543
|
return None
|
|
449
544
|
mode = str((req.memory or {}).get("replay") or "verify").lower()
|
|
450
|
-
|
|
545
|
+
mem = req.memory or {}
|
|
546
|
+
return cls(
|
|
547
|
+
MemoryStore(folder), mode if mode in REPLAY_MODES else "verify", req.goal, start_url,
|
|
548
|
+
case_id=req.case_id, case_keys=list(req.case_step_keys) if req.case_id else [],
|
|
549
|
+
checks_first=bool(mem.get("checks_first")),
|
|
550
|
+
canary_every=_int(mem.get("canary_every"), CANARY_EVERY),
|
|
551
|
+
max_age_days=_int(mem.get("max_age_days"), MAX_FLOW_AGE_DAYS),
|
|
552
|
+
)
|
|
451
553
|
|
|
452
554
|
def finish(self, out: Dict[str, Any]) -> Dict[str, Any]:
|
|
453
555
|
self.finished = True
|
|
@@ -470,6 +572,8 @@ class MemoryRun:
|
|
|
470
572
|
replayed=len(self.replay.done) if self.replay else 0,
|
|
471
573
|
llm_steps=self.llm_steps,
|
|
472
574
|
resume=self.resume if status == "fail" else None,
|
|
575
|
+
case={"id": self.case_id, "keys": self.case_keys, "ends": self.case_ends()} if self.case_id and self.case_keys else None,
|
|
576
|
+
checks=self.checks_to_save(status),
|
|
473
577
|
)
|
|
474
578
|
except Exception as ex:
|
|
475
579
|
logger.warning("memory: could not save the flow: %s", ex)
|
|
@@ -494,6 +598,11 @@ class MemoryRun:
|
|
|
494
598
|
info["repaired"] = {"step": self.replay.stopped_at, "reason": self.replay.reason, "agent_steps": self.llm_steps}
|
|
495
599
|
if self.replay and self.replay.heals:
|
|
496
600
|
info["heals"] = self.replay.heals
|
|
601
|
+
if self.replay and self.replay.checked:
|
|
602
|
+
info["checked"] = len(self.replay.checked)
|
|
603
|
+
info["checks_held"] = sum(1 for c in self.replay.checked if c.get("holds_now"))
|
|
604
|
+
if self.replay and self.replay.skipped_reads:
|
|
605
|
+
info["skipped_reads"] = self.replay.skipped_reads
|
|
497
606
|
if self.resume and status == "fail":
|
|
498
607
|
info["resume"] = {
|
|
499
608
|
"steps": len(self.resume["steps"]),
|
|
@@ -788,7 +897,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
788
897
|
except Exception as e:
|
|
789
898
|
return {"success": False, "error": str(e), "summary": ""}
|
|
790
899
|
llm_calls: List[Tuple[float, float]] = []
|
|
791
|
-
_time_llm_calls(llm, llm_calls)
|
|
900
|
+
_time_llm_calls(llm, llm_calls, lambda c: emit({"type": "call", **c}))
|
|
792
901
|
use_prompts(req.prompts)
|
|
793
902
|
diag = Diagnostics(accessibility=req.accessibility, network=req.network, axe=req.axe or None, vitals=req.vitals, visual=req.visual)
|
|
794
903
|
|
|
@@ -920,6 +1029,13 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
920
1029
|
mem.used_checkpoint = str(checkpoint.get("run_id") or "")
|
|
921
1030
|
elif saved and isinstance(saved.get("resume"), dict) and saved["resume"].get("steps"):
|
|
922
1031
|
flow, kind = saved["resume"], "resume"
|
|
1032
|
+
elif mem.case_id and mem.case_keys:
|
|
1033
|
+
# The test case was edited (its goal is new): replay its steps before the first one that changed.
|
|
1034
|
+
earlier = mem.store.load_case_flow(mem.case_id)
|
|
1035
|
+
if earlier and str(earlier.get("start_url") or "") == (normalized_initial or ""):
|
|
1036
|
+
done, end = MemoryStore.case_prefix(earlier, mem.case_keys)
|
|
1037
|
+
if done:
|
|
1038
|
+
flow, kind = {**earlier, "steps": earlier["steps"][:end], "case_steps_done": done}, "prefix"
|
|
923
1039
|
if flow and kind and flow.get("steps"):
|
|
924
1040
|
if kind == "interrupted":
|
|
925
1041
|
replay_text = (
|
|
@@ -932,6 +1048,11 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
932
1048
|
+ (f" ({flow['run_id']})" if flow.get("run_id") else "")
|
|
933
1049
|
+ (f", which went wrong at step {flow['failed_at']}" if flow.get("failed_at") else "")
|
|
934
1050
|
)
|
|
1051
|
+
elif kind == "prefix":
|
|
1052
|
+
replay_text = (
|
|
1053
|
+
f"The test case changed after step {flow['case_steps_done']}: replaying the {len(flow['steps'])} recorded "
|
|
1054
|
+
f"steps that did steps 1 to {flow['case_steps_done']}, which did not change"
|
|
1055
|
+
)
|
|
935
1056
|
else:
|
|
936
1057
|
replay_text = (
|
|
937
1058
|
f"Replaying {len(flow['steps'])} recorded steps from memory "
|
|
@@ -940,13 +1061,19 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
940
1061
|
emit({"type": "memory", "text": replay_text})
|
|
941
1062
|
await browser.start()
|
|
942
1063
|
await diag.start(browser)
|
|
1064
|
+
mem.flow_checks = [c for c in (flow.get("checks") or []) if isinstance(c, dict)] if mem.checks_first else []
|
|
943
1065
|
replay = await replay_flow(
|
|
944
|
-
flow, browser=browser, store=mem.store, emit=emit, cancel_event=cancel_event, on_step=lambda: diag.sample(browser), overlay=overlay
|
|
1066
|
+
flow, browser=browser, store=mem.store, emit=emit, cancel_event=cancel_event, on_step=lambda: diag.sample(browser), overlay=overlay,
|
|
1067
|
+
checks_first=mem.checks_first and kind == "exact",
|
|
945
1068
|
)
|
|
946
1069
|
mem.replay = replay
|
|
947
1070
|
for done_step in replay.done:
|
|
948
1071
|
mem.add(done_step)
|
|
949
1072
|
replay_offset = replay.emitted
|
|
1073
|
+
if replay.complete and kind in ("exact", "prefix"):
|
|
1074
|
+
# The case steps the replay did end where they ended in the recorded run.
|
|
1075
|
+
known = list(flow.get("case_step_ends") or [])
|
|
1076
|
+
mem.carried_ends = known[: int(flow.get("case_steps_done") or len(known))]
|
|
950
1077
|
if replay.complete and kind == "exact":
|
|
951
1078
|
emit({"type": "memory", "text": f"Replayed all {len(replay.done)} steps without the LLM"})
|
|
952
1079
|
elif replay.complete:
|
|
@@ -958,6 +1085,30 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
958
1085
|
out = {"success": False, "error": err, "summary": err, "step_events": collected_steps, "cancelled": True}
|
|
959
1086
|
_save(out, "cancelled")
|
|
960
1087
|
return out
|
|
1088
|
+
judged = judged_by_checks(flow, replay, mem) if replay.complete and kind == "exact" and mem.checks_first and mem.mode == "verify" else None
|
|
1089
|
+
if judged and judged["ok"]:
|
|
1090
|
+
held = replay.checked
|
|
1091
|
+
summary = f"Replayed all {len(replay.done)} recorded steps and found all {len(held)} recorded checks on the page again, without calling the LLM."
|
|
1092
|
+
if flow.get("summary"):
|
|
1093
|
+
summary += f"\n\nResult of the run that recorded them: {flow['summary']}"
|
|
1094
|
+
emit({"type": "memory", "text": f"All {len(held)} recorded checks hold: passed without the LLM"})
|
|
1095
|
+
out = {
|
|
1096
|
+
"success": True,
|
|
1097
|
+
"outcome": "passed",
|
|
1098
|
+
"summary": summary,
|
|
1099
|
+
"result_markdown": f"### Task\n\n{goal}\n\n---\n\n## Result\n\n{summary}",
|
|
1100
|
+
"session_id": req.session_id,
|
|
1101
|
+
"final_url": replay.url,
|
|
1102
|
+
"usage": {"input": 0, "output": 0},
|
|
1103
|
+
"steps": replay.emitted,
|
|
1104
|
+
"step_events": collected_steps,
|
|
1105
|
+
"checks": [{k: v for k, v in c.items() if k in ("expected", "text", "url", "step")} for c in held],
|
|
1106
|
+
"browser": await browser_profile(browser, browser_kw, provider=browser_provider, remote=browser_remote),
|
|
1107
|
+
}
|
|
1108
|
+
_save(out, "memory_checks")
|
|
1109
|
+
return out
|
|
1110
|
+
if judged and not judged["ok"]:
|
|
1111
|
+
emit({"type": "memory", "text": f"The agent checks the result: {judged['why']}"})
|
|
961
1112
|
if replay.complete and mem.mode == "trust" and kind == "exact":
|
|
962
1113
|
summary = f"Replayed all {len(replay.done)} recorded steps from memory without calling the LLM."
|
|
963
1114
|
if flow.get("summary"):
|
|
@@ -1141,6 +1292,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1141
1292
|
mem, pending_actions, results, mem.llm_steps, agent_note(getattr(last, "model_output", None)),
|
|
1142
1293
|
effects=list(action_effects), page_url=step_url["url"],
|
|
1143
1294
|
)
|
|
1295
|
+
mem.note_checks(recorded_checks)
|
|
1144
1296
|
try:
|
|
1145
1297
|
point = mem.checkpoint()
|
|
1146
1298
|
if point:
|
|
@@ -111,19 +111,20 @@ export const CAPABILITIES: Capability[] = [
|
|
|
111
111
|
area: "Test cases",
|
|
112
112
|
title: "Check and improve test cases",
|
|
113
113
|
summary:
|
|
114
|
-
"Checks test cases before they run (vague or missing expected results, missing test data, placeholders, duplicates) against the story, feature, and epic they test, then rewrites Azure DevOps cases once the user agrees.",
|
|
114
|
+
"Checks test cases before they run (vague or missing expected results, missing test data, placeholders, duplicates) against the story, feature, and epic they test, then rewrites Azure DevOps or Jira cases in place once the user agrees.",
|
|
115
115
|
tools: ["check_test_cases", "update_test_case"],
|
|
116
116
|
examples: ["Are the cases in test plan 12 good enough to run?", "Fix the expected results of case 4512"],
|
|
117
|
-
requires: [{ kind: "system", anyOf: ["azure_devops"], optional: true, why: "to rewrite cases in place" }],
|
|
117
|
+
requires: [{ kind: "system", anyOf: ["azure_devops", "jira"], optional: true, why: "to rewrite cases in place" }],
|
|
118
118
|
docs: [{ doc: "HOW_TO_USE.md", heading: "Checking test cases before they run" }],
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
121
|
id: "write-test-cases",
|
|
122
122
|
area: "Test cases",
|
|
123
123
|
title: "Write test cases from a story",
|
|
124
|
-
summary:
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
summary:
|
|
125
|
+
"Writes test cases from a story's acceptance criteria and saves them in Azure DevOps or Jira, linked to the story. Reads the cases already linked first, so asking again adds only what is missing and updates cases in place instead of duplicating them; plans end-to-end journeys from the existing cases.",
|
|
126
|
+
tools: ["story_test_cases", "save_test_cases", "e2e_journey"],
|
|
127
|
+
examples: ["Write test cases for story 1234 and save them", "Build an end-to-end journey from story 1234's cases"],
|
|
127
128
|
requires: [{ kind: "system", anyOf: ["azure_devops", "jira"] }],
|
|
128
129
|
docs: [{ doc: "HOW_TO_USE.md", heading: "Where cases come from" }],
|
|
129
130
|
},
|
|
@@ -185,6 +186,19 @@ export const CAPABILITIES: Capability[] = [
|
|
|
185
186
|
],
|
|
186
187
|
docs: [{ doc: "HOW_TO_USE.md", heading: "Finding and running existing tests" }],
|
|
187
188
|
},
|
|
189
|
+
{
|
|
190
|
+
id: "regression-packs",
|
|
191
|
+
area: "Automated tests",
|
|
192
|
+
title: "Regression packs for a release",
|
|
193
|
+
summary: "Builds a regression pack from what goes into a release (an Azure DevOps sprint or work items, a commit range, pull requests, or changes read from any connected tool): the tests those changes can break, each with why, plus risk-area and smoke tests, and the changes no test covers. Runs it locally, on TestMu AI, or in a pipeline, and compares each run with the last.",
|
|
194
|
+
tools: ["build_regression_pack", "run_regression_pack", "list_regression_packs"],
|
|
195
|
+
examples: ["Build a regression pack for sprint 12 and run it on TestMu", "Which tests should we run for the PRs merged this week?"],
|
|
196
|
+
requires: [
|
|
197
|
+
{ kind: "input", what: "a repository with tests" },
|
|
198
|
+
{ kind: "system", anyOf: ["azure_devops", "jira"], optional: true, why: "to read a sprint's or release's work items" },
|
|
199
|
+
],
|
|
200
|
+
docs: [{ doc: "HOW_TO_USE.md", heading: "Regression packs for a release" }],
|
|
201
|
+
},
|
|
188
202
|
{
|
|
189
203
|
id: "flaky-tests",
|
|
190
204
|
area: "Automated tests",
|
package/src/cases/ado.ts
CHANGED
|
@@ -192,7 +192,7 @@ const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "", pr
|
|
|
192
192
|
type Node = Record<string, unknown> & { ":@"?: Record<string, string> }
|
|
193
193
|
|
|
194
194
|
/** Microsoft.VSTS.TCM.Steps: `<step>` action/expected pairs in order, with shared steps (`<compref ref>`) inlined. */
|
|
195
|
-
async function parseSteps(source: string, base: string, auth: string, depth = 0): Promise<CaseStep[]> {
|
|
195
|
+
export async function parseSteps(source: string, base: string, auth: string, depth = 0): Promise<CaseStep[]> {
|
|
196
196
|
if (!source.trim()) return []
|
|
197
197
|
const steps: CaseStep[] = []
|
|
198
198
|
const walk = async (nodes: Node[]) => {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { stepText, type CaseStep } from "./types.ts"
|
|
2
|
+
|
|
3
|
+
/** A test case as a journey uses it. */
|
|
4
|
+
export type JourneyCase = { id: string; title: string; url?: string; startUrl?: string; steps: CaseStep[] }
|
|
5
|
+
|
|
6
|
+
/** One case inside a journey: its steps are `from`-`to` of the journey (1-based), after the shared steps. */
|
|
7
|
+
export type JourneySection = { caseId: string; title: string; from: number; to: number; negative: boolean }
|
|
8
|
+
|
|
9
|
+
export type Journey = { startUrl?: string; shared: CaseStep[]; steps: CaseStep[]; sections: JourneySection[]; saved: number }
|
|
10
|
+
|
|
11
|
+
/** Steps that look like a negative path: a refusal, an error, or one that ends the session or removes data. */
|
|
12
|
+
const NEGATIVE = /\b(invalid|incorrect|wrong|error|denied|refused|rejects?|rejected|fails?|failed|locked|not allowed|cannot|can't|unauthori[sz]ed|forbidden|sign(?:s|ed)? out|log(?:s|ged)? out|delete[sd]?|remove[sd]?)\b/i
|
|
13
|
+
|
|
14
|
+
const key = (s: CaseStep) => `${stepText(s.action)}|${stepText(s.expected)}`
|
|
15
|
+
|
|
16
|
+
function sharedPrefix(cases: JourneyCase[]): number {
|
|
17
|
+
let n = 0
|
|
18
|
+
for (;;) {
|
|
19
|
+
const first = cases[0].steps[n]
|
|
20
|
+
if (!first || cases.some((c) => !c.steps[n] || key(c.steps[n]) !== key(first))) return n
|
|
21
|
+
n++
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Journeys from cases that start the same way: same start page and first step. The steps they all share come once,
|
|
27
|
+
* then each case's own steps follow as a section; cases that look negative go last, since they may end the session.
|
|
28
|
+
* A case that starts like no other stays on its own. The draft keeps the cases' order otherwise: the agent reorders it.
|
|
29
|
+
*/
|
|
30
|
+
export function planJourneys(cases: JourneyCase[]): { journeys: Journey[]; alone: JourneyCase[]; stepsSaved: number } {
|
|
31
|
+
const groups = new Map<string, JourneyCase[]>()
|
|
32
|
+
for (const c of cases) {
|
|
33
|
+
if (!c.steps.length) continue
|
|
34
|
+
const g = `${c.startUrl || ""}\n${key(c.steps[0])}`
|
|
35
|
+
groups.set(g, [...(groups.get(g) || []), c])
|
|
36
|
+
}
|
|
37
|
+
const journeys: Journey[] = []
|
|
38
|
+
const alone: JourneyCase[] = cases.filter((c) => !c.steps.length)
|
|
39
|
+
for (const group of groups.values()) {
|
|
40
|
+
if (group.length < 2) {
|
|
41
|
+
alone.push(...group)
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
const n = sharedPrefix(group)
|
|
45
|
+
const shared = group[0].steps.slice(0, n)
|
|
46
|
+
const ordered = group
|
|
47
|
+
.map((c) => ({ c, tail: c.steps.slice(n) }))
|
|
48
|
+
.map((x) => ({ ...x, negative: NEGATIVE.test(x.tail.map((s) => `${s.action} ${s.expected || ""}`).join(" ")) }))
|
|
49
|
+
.sort((a, b) => Number(a.negative) - Number(b.negative))
|
|
50
|
+
const steps = [...shared]
|
|
51
|
+
const sections: JourneySection[] = []
|
|
52
|
+
for (const { c, tail, negative } of ordered) {
|
|
53
|
+
// A case that is only the shared steps is covered by them.
|
|
54
|
+
const from = tail.length ? steps.length + 1 : 1
|
|
55
|
+
steps.push(...tail)
|
|
56
|
+
sections.push({ caseId: c.id, title: c.title, from, to: tail.length ? steps.length : n, negative })
|
|
57
|
+
}
|
|
58
|
+
journeys.push({ startUrl: group[0].startUrl, shared, steps, sections, saved: (group.length - 1) * n })
|
|
59
|
+
}
|
|
60
|
+
return { journeys, alone, stepsSaved: journeys.reduce((sum, j) => sum + j.saved, 0) }
|
|
61
|
+
}
|
package/src/cases/readiness.ts
CHANGED
|
@@ -20,7 +20,8 @@ import { adoSprints, findSprint } from "./sprints.ts"
|
|
|
20
20
|
* runs: the newest suite report for the case, then the automated test's history.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
/** iteration: an iteration path and everything under it, such as a release ("Release 2026.09", or Project\Release 2026.09). */
|
|
24
|
+
export type Scope = { sprint?: string; iteration?: string; area?: string; parent?: number; ids?: number[]; project?: string }
|
|
24
25
|
|
|
25
26
|
export type Outcome = "passed" | "failed" | "blocked" | "not run"
|
|
26
27
|
export type CaseRow = { id: number; title: string; url: string; automated: boolean; test?: string; outcome: Outcome; when?: string; from?: "Azure DevOps" | "ForgeQE"; flaky?: boolean; quarantined?: boolean }
|
|
@@ -118,11 +119,26 @@ function forgeqeResults(suitesDir: string): Map<number, { outcome: Outcome; when
|
|
|
118
119
|
return out
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
export
|
|
122
|
+
export type WorkItem = Item
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* An Azure DevOps scope as work items: a sprint, an area, a parent epic or feature and everything under it, or given
|
|
126
|
+
* ids. Gives the stories in it, the WIQL filter its other work items share (empty for a parent or ids), and helpers to
|
|
127
|
+
* query and read more. Release readiness and regression packs both start here.
|
|
128
|
+
*/
|
|
129
|
+
export async function resolveScope(cfg: AppConfig, scope: Scope): Promise<{
|
|
130
|
+
label: string
|
|
131
|
+
filter: string
|
|
132
|
+
project?: string
|
|
133
|
+
org: string
|
|
134
|
+
storyIds: number[]
|
|
135
|
+
wiql: (query: string) => Promise<number[]>
|
|
136
|
+
items: (ids: number[]) => Promise<Item[]>
|
|
137
|
+
urlOf: (it: Item) => string
|
|
138
|
+
}> {
|
|
122
139
|
const { org, auth, project: configured } = adoSettings(cfg.sources)
|
|
123
140
|
const project = scope.project || configured
|
|
124
141
|
if (!project && !scope.ids?.length) throw new Error("Name the Azure DevOps project (project, or sources.azure_devops.project) for a sprint, area, or epic.")
|
|
125
|
-
const notes: string[] = []
|
|
126
142
|
const wiql = async (query: string) => {
|
|
127
143
|
const { data } = await http<{ workItems?: { id: number }[]; workItemRelations?: { target?: { id: number } }[] }>(`${org}/${encodeURIComponent(project!)}/_apis/wit/wiql?api-version=7.1`, { auth, body: { query } })
|
|
128
144
|
return [...new Set([...(data?.workItems || []).map((w) => w.id), ...(data?.workItemRelations || []).map((r) => r.target?.id).filter((n): n is number => Boolean(n))])]
|
|
@@ -130,8 +146,9 @@ export async function releaseReadiness(cfg: AppConfig, repo: string, scope: Scop
|
|
|
130
146
|
const items = async (ids: number[]) => {
|
|
131
147
|
const out: Item[] = []
|
|
132
148
|
for (let i = 0; i < ids.length; i += 200) {
|
|
133
|
-
|
|
134
|
-
|
|
149
|
+
// errorPolicy=omit: one id that does not exist, or that the token cannot read, does not lose the others.
|
|
150
|
+
const { data } = await http<{ value?: (Item | null)[] }>(`${org}/_apis/wit/workitems?ids=${ids.slice(i, i + 200).join(",")}&$expand=relations&errorPolicy=omit&api-version=7.1`, { auth })
|
|
151
|
+
out.push(...(data?.value || []).filter((w): w is Item => Boolean(w)))
|
|
135
152
|
}
|
|
136
153
|
return out
|
|
137
154
|
}
|
|
@@ -156,14 +173,26 @@ export async function releaseReadiness(cfg: AppConfig, repo: string, scope: Scop
|
|
|
156
173
|
parts.push(`[System.IterationPath] UNDER ${q(s.path)}`)
|
|
157
174
|
label = `sprint ${s.name}${s.start ? ` (${s.start} to ${s.end})` : ""}`
|
|
158
175
|
} else label = ""
|
|
176
|
+
if (scope.iteration) {
|
|
177
|
+
const iterationPath = scope.iteration.includes("\\") || !project ? scope.iteration : `${project}\\${scope.iteration}`
|
|
178
|
+
parts.push(`[System.IterationPath] UNDER ${q(iterationPath)}`)
|
|
179
|
+
label = [label, `iteration ${iterationPath}`].filter(Boolean).join(", ")
|
|
180
|
+
}
|
|
159
181
|
if (scope.area) {
|
|
160
182
|
parts.push(`[System.AreaPath] UNDER ${q(scope.area)}`)
|
|
161
183
|
label = [label, `area ${scope.area}`].filter(Boolean).join(", ")
|
|
162
184
|
}
|
|
163
|
-
if (!parts.length) throw new Error("Give a sprint, an area, a parent epic or feature, or work item ids.")
|
|
185
|
+
if (!parts.length) throw new Error("Give a sprint, an iteration path, an area, a parent epic or feature, or work item ids.")
|
|
164
186
|
filter = parts.join(" AND ")
|
|
165
187
|
storyIds = await wiql(`SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND ${REQUIREMENT} AND [System.State] <> 'Removed' AND ${filter}`)
|
|
166
188
|
}
|
|
189
|
+
return { label, filter, ...(project ? { project } : {}), org, storyIds, wiql, items, urlOf }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function releaseReadiness(cfg: AppConfig, repo: string, scope: Scope, thresholds: ReleaseThresholds = DEFAULT_THRESHOLDS): Promise<Readiness> {
|
|
193
|
+
const { org, auth } = adoSettings(cfg.sources)
|
|
194
|
+
const notes: string[] = []
|
|
195
|
+
const { label, filter, project, storyIds, wiql, items, urlOf } = await resolveScope(cfg, scope)
|
|
167
196
|
const storyItems = await items(storyIds)
|
|
168
197
|
|
|
169
198
|
// Test cases each story is tested by, and their newest outcome.
|