@suiflex/suitest-mcp 0.1.0 → 0.1.1
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/package.json +1 -1
- package/python/suitest_lifecycle/blackbox/mcp.py +90 -8
- package/python/suitest_lifecycle/cli.py +10 -1
- package/python/suitest_lifecycle/config.py +12 -0
- package/python/suitest_lifecycle/mcp_server.py +36 -8
- package/python/suitest_lifecycle/orchestrator.py +270 -3
- package/python/suitest_lifecycle/publish.py +56 -8
- package/python/suitest_lifecycle/retest.py +547 -0
- package/python/suitest_lifecycle/tools.py +43 -23
package/package.json
CHANGED
|
@@ -388,15 +388,22 @@ def blackbox_publish_results(**kwargs: Any) -> dict[str, Any]:
|
|
|
388
388
|
import os as _os
|
|
389
389
|
import re as _re
|
|
390
390
|
|
|
391
|
+
from suitest_lifecycle.retest import rewrite_project_id
|
|
392
|
+
|
|
391
393
|
project_id = str(kwargs.pop("project_id", "") or "")
|
|
392
394
|
suite_name = str(kwargs.pop("suite_name", "") or "")
|
|
395
|
+
# EXPLICIT recreate opt-in — mirrors the lifecycle run tools. Without it a
|
|
396
|
+
# stale binding fails this publish instead of minting a fresh project.
|
|
397
|
+
recreate = bool(kwargs.pop("recreate_project", False))
|
|
393
398
|
config_path = str(kwargs.get("config_path", "") or "")
|
|
394
399
|
ui, paths = _resolve(**kwargs)
|
|
395
|
-
if config_path
|
|
400
|
+
if config_path:
|
|
396
401
|
from suitest_lifecycle.config import load_config
|
|
397
402
|
|
|
398
403
|
cfg = load_config(config_path)
|
|
399
|
-
|
|
404
|
+
if not project_id:
|
|
405
|
+
project_id = cfg.publish.project_id
|
|
406
|
+
recreate = recreate or cfg.publish.recreate
|
|
400
407
|
# No project configured → the server finds-or-creates one by a slug derived
|
|
401
408
|
# from the target host. Publishing is MANDATORY in the blackbox pipeline;
|
|
402
409
|
# "no project yet" is not an excuse to keep results local.
|
|
@@ -424,6 +431,66 @@ def blackbox_publish_results(**kwargs: Any) -> dict[str, Any]:
|
|
|
424
431
|
|
|
425
432
|
try:
|
|
426
433
|
with SuitestClient(api_url, token=token, timeout=180.0) as client:
|
|
434
|
+
# --- project binding gate (before any upload/insert) ----------- #
|
|
435
|
+
binding: dict[str, Any] = {"status": "first_setup", "action": "will_create_by_slug"}
|
|
436
|
+
if project_id:
|
|
437
|
+
binding = {
|
|
438
|
+
"status": "unverified",
|
|
439
|
+
"action": "server_unreachable",
|
|
440
|
+
"projectId": project_id,
|
|
441
|
+
}
|
|
442
|
+
try:
|
|
443
|
+
resolved = client.resolve_project(
|
|
444
|
+
project_id=project_id,
|
|
445
|
+
project_slug=project_slug,
|
|
446
|
+
project_name=project_name,
|
|
447
|
+
)
|
|
448
|
+
except Exception:
|
|
449
|
+
# Resolve endpoint unreachable/older server: proceed — the
|
|
450
|
+
# publish itself still 404s a stale id without inserting.
|
|
451
|
+
resolved = None
|
|
452
|
+
if resolved is not None:
|
|
453
|
+
status = str(resolved.get("status", "missing"))
|
|
454
|
+
if status == "valid":
|
|
455
|
+
binding = {
|
|
456
|
+
"status": "valid",
|
|
457
|
+
"action": "reused_existing_project",
|
|
458
|
+
"projectId": project_id,
|
|
459
|
+
}
|
|
460
|
+
elif status == "repaired":
|
|
461
|
+
project_id = str(resolved.get("projectId", ""))
|
|
462
|
+
binding = {
|
|
463
|
+
"status": "repaired",
|
|
464
|
+
"action": "rebound_by_" + str(resolved.get("matchedBy", "match")),
|
|
465
|
+
"projectId": project_id,
|
|
466
|
+
}
|
|
467
|
+
if config_path:
|
|
468
|
+
rewrite_project_id(Path(config_path), project_id)
|
|
469
|
+
elif recreate:
|
|
470
|
+
binding = {
|
|
471
|
+
"status": "recreate_requested",
|
|
472
|
+
"action": "will_recreate_by_slug",
|
|
473
|
+
}
|
|
474
|
+
project_id = "" # server find-or-creates by slug below
|
|
475
|
+
else:
|
|
476
|
+
return _envelope(
|
|
477
|
+
False,
|
|
478
|
+
f"stale project binding: projectId '{project_id}' not found in "
|
|
479
|
+
"the workspace and no unambiguous project matched — nothing "
|
|
480
|
+
"was published",
|
|
481
|
+
data={
|
|
482
|
+
"projectBinding": {
|
|
483
|
+
"status": "missing",
|
|
484
|
+
"action": "fail",
|
|
485
|
+
"projectId": project_id,
|
|
486
|
+
"candidates": resolved.get("candidates", []),
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
errors=[
|
|
490
|
+
"fix publish.projectId (or the project_id argument), or "
|
|
491
|
+
"re-run with recreate_project=true to create a new project"
|
|
492
|
+
],
|
|
493
|
+
)
|
|
427
494
|
|
|
428
495
|
def _up(path: str, mime: str) -> str:
|
|
429
496
|
try:
|
|
@@ -505,26 +572,41 @@ def blackbox_publish_results(**kwargs: Any) -> dict[str, Any]:
|
|
|
505
572
|
)
|
|
506
573
|
imported = client.bulk_import_cases(
|
|
507
574
|
project_id=project_id,
|
|
508
|
-
|
|
509
|
-
|
|
575
|
+
# Slug fallback ONLY when no validated id — an explicit id must
|
|
576
|
+
# never silently degrade into a find-or-create.
|
|
577
|
+
project_slug="" if project_id else project_slug,
|
|
578
|
+
project_name="" if project_id else project_name,
|
|
510
579
|
suite_name=suite,
|
|
511
580
|
mode="frontend",
|
|
512
581
|
cases=cases_payload,
|
|
582
|
+
# Current generation = the suite's alive set; an empty payload
|
|
583
|
+
# (nothing generated) must not stale the whole suite.
|
|
584
|
+
mark_stale=bool(cases_payload),
|
|
513
585
|
)
|
|
586
|
+
resolved_id = str(imported.get("projectId", "") or "") or project_id
|
|
514
587
|
run = client.ingest_run(
|
|
515
|
-
project_id=
|
|
516
|
-
project_slug=project_slug,
|
|
517
|
-
project_name=project_name,
|
|
588
|
+
project_id=resolved_id,
|
|
589
|
+
project_slug="" if resolved_id else project_slug,
|
|
590
|
+
project_name="" if resolved_id else project_name,
|
|
518
591
|
suite_name=suite,
|
|
519
592
|
name=f"{suite} run",
|
|
520
593
|
results=results_payload,
|
|
521
594
|
)
|
|
522
595
|
except SuitestAPIError as exc:
|
|
523
596
|
return _envelope(False, f"publish failed: {exc}", errors=[str(exc)])
|
|
597
|
+
# Slug-based publish minted (or found) the project — pin its id so the next
|
|
598
|
+
# blackbox publish is an explicit-id retest, never a re-create.
|
|
599
|
+
if config_path and resolved_id and binding["status"] in ("first_setup", "recreate_requested"):
|
|
600
|
+
rewrite_project_id(Path(config_path), resolved_id)
|
|
524
601
|
return _envelope(
|
|
525
602
|
True,
|
|
526
603
|
f"published: {len(imported.get('imported', []))} case(s), run {run.get('runId')}",
|
|
527
|
-
data={
|
|
604
|
+
data={
|
|
605
|
+
"imported": imported,
|
|
606
|
+
"run": run,
|
|
607
|
+
"projectBinding": {**binding, "projectId": resolved_id},
|
|
608
|
+
"staleCases": imported.get("stale", []),
|
|
609
|
+
},
|
|
528
610
|
)
|
|
529
611
|
|
|
530
612
|
|
|
@@ -92,6 +92,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
92
92
|
|
|
93
93
|
test = sub.add_parser("test", help="Run the full config-driven lifecycle")
|
|
94
94
|
test.add_argument("--config", default="suitest.config.json")
|
|
95
|
+
test.add_argument(
|
|
96
|
+
"--recreate-project",
|
|
97
|
+
action="store_true",
|
|
98
|
+
help="EXPLICITLY recreate the Suitest project when publish.projectId is "
|
|
99
|
+
"stale and repair finds no match (otherwise a stale binding fails the run)",
|
|
100
|
+
)
|
|
95
101
|
|
|
96
102
|
sub.add_parser("mcp", help="Serve the stdio MCP server")
|
|
97
103
|
|
|
@@ -109,7 +115,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
109
115
|
from suitest_lifecycle.config import load_config
|
|
110
116
|
from suitest_lifecycle.orchestrator import run_lifecycle
|
|
111
117
|
|
|
112
|
-
|
|
118
|
+
cfg = load_config(args.config)
|
|
119
|
+
if args.recreate_project:
|
|
120
|
+
cfg.publish.recreate = True
|
|
121
|
+
res = run_lifecycle(cfg)
|
|
113
122
|
print(res.summary)
|
|
114
123
|
for step in res.steps:
|
|
115
124
|
print(f" - {step}")
|
|
@@ -76,6 +76,11 @@ class PublishConfig:
|
|
|
76
76
|
workspace_id: str = ""
|
|
77
77
|
project_id: str = ""
|
|
78
78
|
suite_name: str = ""
|
|
79
|
+
# EXPLICIT recreate opt-in: when the configured projectId no longer exists
|
|
80
|
+
# and repair finds no match, a fresh project is created ONLY if this is set
|
|
81
|
+
# (config keys ``recreateProject``/``resetProjectBinding``, env
|
|
82
|
+
# SUITEST_RECREATE_PROJECT=1, or the MCP tool arg ``recreate_project``).
|
|
83
|
+
recreate: bool = False
|
|
79
84
|
|
|
80
85
|
|
|
81
86
|
@dataclass
|
|
@@ -264,6 +269,8 @@ def load_config(path: str | Path) -> Config:
|
|
|
264
269
|
publish = PublishConfig()
|
|
265
270
|
pub_raw = raw.get("publish", {})
|
|
266
271
|
if isinstance(pub_raw, dict):
|
|
272
|
+
import os
|
|
273
|
+
|
|
267
274
|
publish = PublishConfig(
|
|
268
275
|
enabled=bool(pub_raw.get("enabled", False)),
|
|
269
276
|
api_url=str(pub_raw.get("apiUrl", "http://localhost:4000")).rstrip("/"),
|
|
@@ -271,6 +278,11 @@ def load_config(path: str | Path) -> Config:
|
|
|
271
278
|
workspace_id=str(pub_raw.get("workspaceId", "")),
|
|
272
279
|
project_id=str(pub_raw.get("projectId", "")),
|
|
273
280
|
suite_name=str(pub_raw.get("suiteName", "")),
|
|
281
|
+
recreate=bool(
|
|
282
|
+
pub_raw.get("recreateProject", False)
|
|
283
|
+
or pub_raw.get("resetProjectBinding", False)
|
|
284
|
+
or os.environ.get("SUITEST_RECREATE_PROJECT", "") in ("1", "true")
|
|
285
|
+
),
|
|
274
286
|
)
|
|
275
287
|
|
|
276
288
|
ids_raw = raw.get("testIds", [])
|
|
@@ -22,6 +22,10 @@ if TYPE_CHECKING:
|
|
|
22
22
|
|
|
23
23
|
PROTOCOL_VERSION = "2024-11-05"
|
|
24
24
|
|
|
25
|
+
# Run tools accept the explicit recreate opt-in (goal: recreate NEVER happens
|
|
26
|
+
# implicitly — only via this flag or the publish.recreateProject config key).
|
|
27
|
+
RECREATE_TOOLS = frozenset({"run_tests", "run_backend_tests", "run_frontend_tests"})
|
|
28
|
+
|
|
25
29
|
_TOOL_DESCRIPTIONS = {
|
|
26
30
|
"analyze_project": "Static-analyze the target project; list endpoints (backend) or pages (frontend).",
|
|
27
31
|
"generate_test_cases": "Analyze, build a PRD + test plan, and export runnable test files.",
|
|
@@ -74,6 +78,14 @@ _BLACKBOX_INPUT_SCHEMA: dict[str, object] = {
|
|
|
74
78
|
"type": "string",
|
|
75
79
|
"description": "Suitest project id to publish into (blackbox_publish_results)",
|
|
76
80
|
},
|
|
81
|
+
"recreate_project": {
|
|
82
|
+
"type": "boolean",
|
|
83
|
+
"description": (
|
|
84
|
+
"EXPLICIT opt-in: recreate the project when the configured/passed "
|
|
85
|
+
"project id no longer exists and repair finds no match "
|
|
86
|
+
"(blackbox_publish_results). Without it a stale binding fails the publish."
|
|
87
|
+
),
|
|
88
|
+
},
|
|
77
89
|
"prd_file": {
|
|
78
90
|
"type": "string",
|
|
79
91
|
"description": "Markdown PRD path — PRD-driven semantic plan via the workspace LLM (blackbox_generate_playwright_tests)",
|
|
@@ -90,18 +102,29 @@ def _tool_schema(name: str) -> dict[str, object]:
|
|
|
90
102
|
"description": _TOOL_DESCRIPTIONS.get(name, name),
|
|
91
103
|
"inputSchema": _BLACKBOX_INPUT_SCHEMA,
|
|
92
104
|
}
|
|
105
|
+
properties: dict[str, object] = {
|
|
106
|
+
"config_path": {
|
|
107
|
+
"type": "string",
|
|
108
|
+
"description": "Path to suitest.config.json",
|
|
109
|
+
"default": "suitest.config.json",
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if name in RECREATE_TOOLS:
|
|
113
|
+
properties["recreate_project"] = {
|
|
114
|
+
"type": "boolean",
|
|
115
|
+
"description": (
|
|
116
|
+
"EXPLICIT opt-in: recreate the project when the configured "
|
|
117
|
+
"publish.projectId no longer exists and repair finds no match. "
|
|
118
|
+
"Without this flag a stale binding FAILS the run (nothing is inserted)."
|
|
119
|
+
),
|
|
120
|
+
"default": False,
|
|
121
|
+
}
|
|
93
122
|
return {
|
|
94
123
|
"name": name,
|
|
95
124
|
"description": _TOOL_DESCRIPTIONS.get(name, name),
|
|
96
125
|
"inputSchema": {
|
|
97
126
|
"type": "object",
|
|
98
|
-
"properties":
|
|
99
|
-
"config_path": {
|
|
100
|
-
"type": "string",
|
|
101
|
-
"description": "Path to suitest.config.json",
|
|
102
|
-
"default": "suitest.config.json",
|
|
103
|
-
}
|
|
104
|
-
},
|
|
127
|
+
"properties": properties,
|
|
105
128
|
"required": ["config_path"],
|
|
106
129
|
},
|
|
107
130
|
}
|
|
@@ -124,7 +147,7 @@ def handle(message: dict[str, object]) -> dict[str, object] | None:
|
|
|
124
147
|
{
|
|
125
148
|
"protocolVersion": PROTOCOL_VERSION,
|
|
126
149
|
"capabilities": {"tools": {}},
|
|
127
|
-
"serverInfo": {"name": "suitest-lifecycle", "version": "0.1.
|
|
150
|
+
"serverInfo": {"name": "suitest-lifecycle", "version": "0.1.1"},
|
|
128
151
|
},
|
|
129
152
|
)
|
|
130
153
|
if method in ("notifications/initialized", "initialized"):
|
|
@@ -144,6 +167,11 @@ def handle(message: dict[str, object]) -> dict[str, object] | None:
|
|
|
144
167
|
try:
|
|
145
168
|
if str(name) in KWARG_TOOLS:
|
|
146
169
|
envelope = tool(**arguments)
|
|
170
|
+
elif str(name) in RECREATE_TOOLS:
|
|
171
|
+
envelope = tool(
|
|
172
|
+
str(arguments.get("config_path", "suitest.config.json")),
|
|
173
|
+
bool(arguments.get("recreate_project", False)),
|
|
174
|
+
)
|
|
147
175
|
else:
|
|
148
176
|
envelope = tool(str(arguments.get("config_path", "suitest.config.json")))
|
|
149
177
|
except Exception as exc: # defensive: never crash the server on a tool bug
|
|
@@ -27,6 +27,18 @@ from suitest_lifecycle.process import ProcessManager
|
|
|
27
27
|
from suitest_lifecycle.publish import publish_results
|
|
28
28
|
from suitest_lifecycle.readiness import wait_until_ready
|
|
29
29
|
from suitest_lifecycle.report import write_all_reports
|
|
30
|
+
from suitest_lifecycle.retest import (
|
|
31
|
+
BindingResult,
|
|
32
|
+
build_fingerprint,
|
|
33
|
+
can_reuse_generated,
|
|
34
|
+
classify_results,
|
|
35
|
+
diff_fingerprint,
|
|
36
|
+
load_codegen_meta,
|
|
37
|
+
load_snapshot,
|
|
38
|
+
reconcile_codegen,
|
|
39
|
+
resolve_binding,
|
|
40
|
+
save_snapshot,
|
|
41
|
+
)
|
|
30
42
|
from suitest_lifecycle.runner import run_tests
|
|
31
43
|
from suitest_lifecycle.serialize import (
|
|
32
44
|
code_summary_to_json,
|
|
@@ -48,6 +60,10 @@ class LifecycleResult:
|
|
|
48
60
|
artifacts: list[str] = field(default_factory=list)
|
|
49
61
|
errors: list[str] = field(default_factory=list)
|
|
50
62
|
steps: list[str] = field(default_factory=list)
|
|
63
|
+
# Retest telemetry: mode, project binding, change detection, generated-code
|
|
64
|
+
# status, failure classification, next actions. Surfaced verbatim in the
|
|
65
|
+
# MCP envelope so agents can reason about WHY a retest behaved as it did.
|
|
66
|
+
retest: dict[str, object] = field(default_factory=dict)
|
|
51
67
|
|
|
52
68
|
|
|
53
69
|
def _publish_step(pub: dict[str, object]) -> str:
|
|
@@ -58,6 +74,15 @@ def _publish_step(pub: dict[str, object]) -> str:
|
|
|
58
74
|
return f"publish skipped — {pub.get('reason')}"
|
|
59
75
|
|
|
60
76
|
|
|
77
|
+
def _record_publish(pub: dict[str, object], steps: list[str], errors: list[str]) -> None:
|
|
78
|
+
"""A failed publish never fails the run, but it must be LOUD: agents only
|
|
79
|
+
read the envelope's ``errors``, so a steps-only note is effectively silent."""
|
|
80
|
+
msg = _publish_step(pub)
|
|
81
|
+
steps.append(msg)
|
|
82
|
+
if not pub.get("published"):
|
|
83
|
+
errors.append(msg)
|
|
84
|
+
|
|
85
|
+
|
|
61
86
|
def _today() -> str:
|
|
62
87
|
return datetime.date.today().isoformat()
|
|
63
88
|
|
|
@@ -114,6 +139,67 @@ def _is_blackbox(config: Config) -> bool:
|
|
|
114
139
|
return config.mode is Mode.FRONTEND and config.analysis_source == "blackbox"
|
|
115
140
|
|
|
116
141
|
|
|
142
|
+
# Stable identity fields for a discovered element — deliberately EXCLUDES
|
|
143
|
+
# volatile data (screenshot paths, dynamic visible text) so a retest against an
|
|
144
|
+
# unchanged app never false-positives as a UI change.
|
|
145
|
+
_ELEMENT_ID_FIELDS = (
|
|
146
|
+
"kind",
|
|
147
|
+
"testid",
|
|
148
|
+
"role",
|
|
149
|
+
"name",
|
|
150
|
+
"label",
|
|
151
|
+
"placeholder",
|
|
152
|
+
"dom_id",
|
|
153
|
+
"css",
|
|
154
|
+
"input_type",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _crawl_elements(crawl: object | None) -> dict[str, object] | None:
|
|
159
|
+
"""Per-route interactive-element identity from a live DOM crawl.
|
|
160
|
+
|
|
161
|
+
Feeds selector-level change detection (``selector_changed``). Returns None
|
|
162
|
+
when no crawl ran (repo-analysis runs have no element capture)."""
|
|
163
|
+
if crawl is None:
|
|
164
|
+
return None
|
|
165
|
+
page_elements = getattr(crawl, "page_elements", None)
|
|
166
|
+
page_testids = getattr(crawl, "page_testids", None)
|
|
167
|
+
pe = page_elements if isinstance(page_elements, dict) else {}
|
|
168
|
+
pt = page_testids if isinstance(page_testids, dict) else {}
|
|
169
|
+
if not pe and not pt:
|
|
170
|
+
return None
|
|
171
|
+
return {
|
|
172
|
+
str(route): {"elements": pe.get(route), "testids": pt.get(route)}
|
|
173
|
+
for route in sorted({*pe, *pt})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _discovery_elements(pages: list[object]) -> dict[str, object]:
|
|
178
|
+
"""Per-route element identity from a blackbox discovery (same purpose as
|
|
179
|
+
:func:`_crawl_elements`, different source shape)."""
|
|
180
|
+
|
|
181
|
+
def _ident(e: object, *, with_text: bool = False) -> dict[str, object]:
|
|
182
|
+
d: dict[str, object] = {f: getattr(e, f, "") for f in _ELEMENT_ID_FIELDS}
|
|
183
|
+
if with_text:
|
|
184
|
+
# Button labels are part of the UI contract (button_label_changed);
|
|
185
|
+
# link/input text is too dynamic to fingerprint.
|
|
186
|
+
d["text"] = getattr(e, "text", "")
|
|
187
|
+
return d
|
|
188
|
+
|
|
189
|
+
out: dict[str, object] = {}
|
|
190
|
+
for p in pages:
|
|
191
|
+
out[str(getattr(p, "route", ""))] = {
|
|
192
|
+
"inputs": [_ident(e) for e in getattr(p, "inputs", [])],
|
|
193
|
+
"buttons": [_ident(e, with_text=True) for e in getattr(p, "buttons", [])],
|
|
194
|
+
"links": [_ident(e) for e in getattr(p, "links", [])],
|
|
195
|
+
"hasTable": getattr(p, "has_table", False),
|
|
196
|
+
"hasForm": getattr(p, "has_form", False),
|
|
197
|
+
"hasModal": getattr(p, "has_modal", False),
|
|
198
|
+
"rowLocator": getattr(p, "row_locator", ""),
|
|
199
|
+
}
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
|
|
117
203
|
def generate_only(
|
|
118
204
|
config: Config,
|
|
119
205
|
summary: CodeSummary | None = None,
|
|
@@ -145,7 +231,54 @@ def generate_only(
|
|
|
145
231
|
from suitest_lifecycle.llm_bridge import build_dom_context
|
|
146
232
|
|
|
147
233
|
dom_context = build_dom_context(crawl, summary) # type: ignore[arg-type]
|
|
148
|
-
|
|
234
|
+
|
|
235
|
+
# --- retest: change detection + generated-code reuse -------------------- #
|
|
236
|
+
import hashlib
|
|
237
|
+
|
|
238
|
+
fingerprint = build_fingerprint(summary, cases, _crawl_elements(crawl))
|
|
239
|
+
change_report = diff_fingerprint(load_snapshot(paths), fingerprint)
|
|
240
|
+
dom_digest = hashlib.sha256(dom_context.encode("utf-8")).hexdigest()[:16]
|
|
241
|
+
meta_prev = load_codegen_meta(paths)
|
|
242
|
+
reuse = (
|
|
243
|
+
not change_report["first"]
|
|
244
|
+
and not change_report["changed"]
|
|
245
|
+
and can_reuse_generated(cases, paths, meta_prev, dom_digest, config.codegen)
|
|
246
|
+
)
|
|
247
|
+
export_error = ""
|
|
248
|
+
stash: dict[str, str] = {}
|
|
249
|
+
if not reuse:
|
|
250
|
+
# Stash current files so a regen never silently destroys reviewed code:
|
|
251
|
+
# changed files are archived to history/, a failed regen is rolled back.
|
|
252
|
+
for entry in meta_prev.values():
|
|
253
|
+
fname = str(entry.get("file", ""))
|
|
254
|
+
fp = paths.test_file(fname) if fname else None
|
|
255
|
+
if fp is not None and fp.is_file():
|
|
256
|
+
stash[fname] = fp.read_text(encoding="utf-8")
|
|
257
|
+
try:
|
|
258
|
+
cases = _export(config, cases, summary, paths, llm=codegen_llm, dom_context=dom_context)
|
|
259
|
+
except Exception as exc: # regen failure → keep prior code, flag needs_review
|
|
260
|
+
export_error = f"{type(exc).__name__}: {exc}"
|
|
261
|
+
for fname, content in stash.items():
|
|
262
|
+
paths.test_file(fname).write_text(content, encoding="utf-8")
|
|
263
|
+
for c in cases:
|
|
264
|
+
prev_entry = meta_prev.get(c.title)
|
|
265
|
+
if prev_entry:
|
|
266
|
+
c.automation_file = str(prev_entry.get("file", ""))
|
|
267
|
+
_meta, gen_counts = reconcile_codegen(
|
|
268
|
+
cases,
|
|
269
|
+
paths,
|
|
270
|
+
meta_prev,
|
|
271
|
+
stash,
|
|
272
|
+
dom_digest,
|
|
273
|
+
config.codegen,
|
|
274
|
+
reused=reuse,
|
|
275
|
+
export_error=export_error,
|
|
276
|
+
)
|
|
277
|
+
save_snapshot(paths, fingerprint)
|
|
278
|
+
(paths.tmp_dir / "change_report.json").write_text(
|
|
279
|
+
json.dumps({"changeDetection": change_report, "generatedCode": gen_counts}, indent=2),
|
|
280
|
+
encoding="utf-8",
|
|
281
|
+
)
|
|
149
282
|
|
|
150
283
|
paths.code_summary_json.write_text(json.dumps(code_summary_to_json(summary), indent=2), "utf-8")
|
|
151
284
|
paths.prd_json.write_text(json.dumps(prd_to_json(prd), indent=2), encoding="utf-8")
|
|
@@ -249,6 +382,19 @@ def _blackbox_generate(config: Config) -> tuple[CodeSummary, list[PlanCase], Pat
|
|
|
249
382
|
else "No login form found."
|
|
250
383
|
),
|
|
251
384
|
)
|
|
385
|
+
# Retest change detection for the blackbox path (deterministic codegen, so
|
|
386
|
+
# code reuse is just hash bookkeeping — no LLM cost to skip).
|
|
387
|
+
fingerprint = build_fingerprint(summary, cases, _discovery_elements(list(discovery.pages)))
|
|
388
|
+
change_report = diff_fingerprint(load_snapshot(paths), fingerprint)
|
|
389
|
+
_meta, gen_counts = reconcile_codegen(
|
|
390
|
+
cases, paths, load_codegen_meta(paths), {}, "", "blackbox"
|
|
391
|
+
)
|
|
392
|
+
save_snapshot(paths, fingerprint)
|
|
393
|
+
(paths.tmp_dir / "change_report.json").write_text(
|
|
394
|
+
_json.dumps({"changeDetection": change_report, "generatedCode": gen_counts}, indent=2),
|
|
395
|
+
encoding="utf-8",
|
|
396
|
+
)
|
|
397
|
+
|
|
252
398
|
paths.code_summary_json.write_text(
|
|
253
399
|
_json.dumps(code_summary_to_json(summary), indent=2), encoding="utf-8"
|
|
254
400
|
)
|
|
@@ -257,10 +403,109 @@ def _blackbox_generate(config: Config) -> tuple[CodeSummary, list[PlanCase], Pat
|
|
|
257
403
|
return summary, cases, paths, steps
|
|
258
404
|
|
|
259
405
|
|
|
406
|
+
def _load_change_report(paths: Paths) -> dict[str, object]:
|
|
407
|
+
p = paths.tmp_dir / "change_report.json"
|
|
408
|
+
if not p.is_file():
|
|
409
|
+
return {}
|
|
410
|
+
try:
|
|
411
|
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
|
412
|
+
return raw if isinstance(raw, dict) else {}
|
|
413
|
+
except ValueError:
|
|
414
|
+
return {}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
_RUN_MODE = {
|
|
418
|
+
"local_only": "local_only",
|
|
419
|
+
"first_setup": "first_test",
|
|
420
|
+
"recreate_requested": "recreate",
|
|
421
|
+
"valid": "retest",
|
|
422
|
+
"repaired": "retest",
|
|
423
|
+
"unverified": "retest",
|
|
424
|
+
"missing": "blocked",
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _build_retest(
|
|
429
|
+
binding: BindingResult,
|
|
430
|
+
report: dict[str, object],
|
|
431
|
+
classifications: dict[str, str],
|
|
432
|
+
pub: dict[str, object],
|
|
433
|
+
) -> dict[str, object]:
|
|
434
|
+
cd = report.get("changeDetection")
|
|
435
|
+
gc = report.get("generatedCode")
|
|
436
|
+
cd = cd if isinstance(cd, dict) else {}
|
|
437
|
+
gc = gc if isinstance(gc, dict) else {}
|
|
438
|
+
mode = _RUN_MODE.get(binding.status, "retest")
|
|
439
|
+
if mode == "retest" and cd.get("first"):
|
|
440
|
+
mode = "first_test" # binding valid but this host never generated before
|
|
441
|
+
kinds = sorted(set(classifications.values()))
|
|
442
|
+
stale_raw = pub.get("stale")
|
|
443
|
+
stale = stale_raw if isinstance(stale_raw, list) else []
|
|
444
|
+
|
|
445
|
+
next_actions: list[str] = []
|
|
446
|
+
if binding.status == "missing":
|
|
447
|
+
next_actions.append(
|
|
448
|
+
"Fix publish.projectId in suitest.config.json (see candidates), or set "
|
|
449
|
+
"publish.recreateProject=true / re-run with recreate_project to create a new project."
|
|
450
|
+
)
|
|
451
|
+
if stale:
|
|
452
|
+
next_actions.append(f"Review {len(stale)} STALE test case(s) in the TCM: {stale}")
|
|
453
|
+
if kinds:
|
|
454
|
+
next_actions.append("Inspect classified failures: " + ", ".join(kinds))
|
|
455
|
+
if gc.get("needs_review"):
|
|
456
|
+
next_actions.append(
|
|
457
|
+
"Code regeneration failed — prior automation kept; see codegen_meta.json (needs_review)."
|
|
458
|
+
)
|
|
459
|
+
if not next_actions:
|
|
460
|
+
next_actions.append("No action needed — binding resolved and results recorded.")
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
"mode": mode,
|
|
464
|
+
"projectBinding": binding.to_json(),
|
|
465
|
+
"changeDetection": cd,
|
|
466
|
+
"generatedCode": gc,
|
|
467
|
+
"failureClassification": kinds,
|
|
468
|
+
"testCases": {
|
|
469
|
+
"created": pub.get("created", 0),
|
|
470
|
+
"reused": pub.get("reused", 0),
|
|
471
|
+
"stale": len(stale),
|
|
472
|
+
},
|
|
473
|
+
"testRun": {
|
|
474
|
+
"created": bool(pub.get("published")),
|
|
475
|
+
"runId": pub.get("runId"),
|
|
476
|
+
"status": pub.get("runStatus"),
|
|
477
|
+
},
|
|
478
|
+
"published": bool(pub.get("published")),
|
|
479
|
+
"publishReason": str(pub.get("reason", "")),
|
|
480
|
+
"nextActions": next_actions,
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
|
|
260
484
|
def run_lifecycle(config: Config) -> LifecycleResult:
|
|
261
485
|
steps: list[str] = []
|
|
262
486
|
errors: list[str] = []
|
|
263
487
|
|
|
488
|
+
# Project binding FIRST: a stale, unrepairable binding must fail loudly
|
|
489
|
+
# before anything is generated, executed, or inserted server-side.
|
|
490
|
+
binding = resolve_binding(config, recreate=config.publish.recreate)
|
|
491
|
+
steps.append(f"project binding: {binding.status} ({binding.action})")
|
|
492
|
+
if binding.detail:
|
|
493
|
+
steps.append(f"binding detail: {binding.detail}")
|
|
494
|
+
if binding.blocks_publish:
|
|
495
|
+
errors.append(binding.detail)
|
|
496
|
+
pub: dict[str, object] = {"published": False, "reason": binding.detail, "blocked": True}
|
|
497
|
+
return LifecycleResult(
|
|
498
|
+
success=False,
|
|
499
|
+
summary=(
|
|
500
|
+
"FAILED — project binding is stale (projectId not found) and could not be "
|
|
501
|
+
"repaired. Nothing was generated, executed, or published."
|
|
502
|
+
),
|
|
503
|
+
run=None,
|
|
504
|
+
errors=errors,
|
|
505
|
+
steps=steps,
|
|
506
|
+
retest=_build_retest(binding, {}, {}, pub),
|
|
507
|
+
)
|
|
508
|
+
|
|
264
509
|
crawl_mode = _is_crawl(config) or _is_blackbox(config)
|
|
265
510
|
summary_code: CodeSummary | None
|
|
266
511
|
if crawl_mode:
|
|
@@ -287,10 +532,13 @@ def run_lifecycle(config: Config) -> LifecycleResult:
|
|
|
287
532
|
|
|
288
533
|
def _finish_fail(detail: str) -> LifecycleResult:
|
|
289
534
|
errors.append(f"not ready: {detail}")
|
|
535
|
+
kind = "dependency_not_ready" if detail.startswith("dependency") else "target_not_ready"
|
|
290
536
|
run_failed = _empty_run(config, summary_code, server_started, False, detail, startup_tail)
|
|
291
537
|
_finalize(config, cases, run_failed, paths)
|
|
538
|
+
fail_pub: dict[str, object] = {"published": False, "reason": "run aborted before tests"}
|
|
292
539
|
if config.publish.enabled:
|
|
293
|
-
|
|
540
|
+
fail_pub = publish_results(config, run_failed, cases, paths, binding=binding)
|
|
541
|
+
_record_publish(fail_pub, steps, errors)
|
|
294
542
|
return LifecycleResult(
|
|
295
543
|
success=False,
|
|
296
544
|
summary=f"FAILED — {detail}",
|
|
@@ -298,6 +546,9 @@ def run_lifecycle(config: Config) -> LifecycleResult:
|
|
|
298
546
|
artifacts=_artifact_list(paths),
|
|
299
547
|
errors=errors,
|
|
300
548
|
steps=steps,
|
|
549
|
+
retest=_build_retest(
|
|
550
|
+
binding, _load_change_report(paths), {"readiness": kind}, fail_pub
|
|
551
|
+
),
|
|
301
552
|
)
|
|
302
553
|
|
|
303
554
|
try:
|
|
@@ -392,8 +643,23 @@ def run_lifecycle(config: Config) -> LifecycleResult:
|
|
|
392
643
|
|
|
393
644
|
run = _build_run(config, summary_code, results, server_started, ready_detail, startup_tail)
|
|
394
645
|
_finalize(config, cases, run, paths)
|
|
646
|
+
|
|
647
|
+
report = _load_change_report(paths)
|
|
648
|
+
cd = report.get("changeDetection")
|
|
649
|
+
api_changed = bool(cd.get("apiChanged")) if isinstance(cd, dict) else False
|
|
650
|
+
classifications = classify_results(run.results, config.mode, api_changed=api_changed)
|
|
651
|
+
if classifications:
|
|
652
|
+
steps.append(
|
|
653
|
+
"failure classification: "
|
|
654
|
+
+ ", ".join(f"{tid}={kind}" for tid, kind in sorted(classifications.items()))
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
pub2: dict[str, object] = {"published": False, "reason": "publish disabled"}
|
|
395
658
|
if config.publish.enabled:
|
|
396
|
-
|
|
659
|
+
pub2 = publish_results(
|
|
660
|
+
config, run, cases, paths, binding=binding, classifications=classifications
|
|
661
|
+
)
|
|
662
|
+
_record_publish(pub2, steps, errors)
|
|
397
663
|
|
|
398
664
|
ok = run.failed == 0 and run.errored == 0
|
|
399
665
|
verb = "PASSED" if ok else "FAILED"
|
|
@@ -407,6 +673,7 @@ def run_lifecycle(config: Config) -> LifecycleResult:
|
|
|
407
673
|
artifacts=_artifact_list(paths),
|
|
408
674
|
errors=errors,
|
|
409
675
|
steps=steps,
|
|
676
|
+
retest=_build_retest(binding, report, classifications, pub2),
|
|
410
677
|
)
|
|
411
678
|
|
|
412
679
|
|