@guanlin4924/devflow-pi 0.1.96 → 0.1.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +678 -91
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +18 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +6 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +64 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +52 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +2 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/explore_preflight.py +58 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/locator_map.py +156 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +50 -15
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +60 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +167 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +5 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +262 -56
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +126 -0
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +2 -0
- package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +103 -88
- package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +879 -0
- package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +297 -15
- package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +3 -2
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +4 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +10 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +6 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +3 -1
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +4 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +98 -15
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py +73 -0
- package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +69 -0
- package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +1 -1
- package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +1 -1
- package/src/constants.ts +1 -1
- package/src/startup-briefing.ts +109 -0
package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py
CHANGED
|
@@ -51,6 +51,8 @@ from dsl_coverage import check as coverage_check # noqa: E402
|
|
|
51
51
|
from dsl_v1 import SCHEMA_VERSION, validate as validate_dsl # noqa: E402
|
|
52
52
|
from execution_surface import derive as derive_surface # noqa: E402
|
|
53
53
|
from tc_hash import tc_sha256 # noqa: E402
|
|
54
|
+
from locator_map import LocatorMapError, expand_anchor_reference # noqa: E402
|
|
55
|
+
from locator_report import write_report # noqa: E402
|
|
54
56
|
|
|
55
57
|
|
|
56
58
|
FEATURE_RE = re.compile(r"^[^/\\\x00-\x1f\x7f]+$")
|
|
@@ -58,7 +60,7 @@ TC_ID_RE = re.compile(r"^TC-[0-9]{4,}$")
|
|
|
58
60
|
GENERATABLE_SURFACES = frozenset({"ui", "api_flow"})
|
|
59
61
|
|
|
60
62
|
UI_ACTIONS = frozenset({"goto", "click", "fill", "select", "check", "uncheck", "wait_for",
|
|
61
|
-
"assert_visible", "assert_text", "assert_url", "screenshot"})
|
|
63
|
+
"assert_visible", "assert_text", "assert_each_text", "assert_value", "assert_url", "screenshot"})
|
|
62
64
|
TARGET_ACTIONS = UI_ACTIONS - {"goto", "assert_url", "screenshot"}
|
|
63
65
|
HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
|
|
64
66
|
API_SHORTHAND = {method.lower(): method for method in HTTP_METHODS}
|
|
@@ -74,9 +76,13 @@ UI_KEYS = {
|
|
|
74
76
|
"wait_for": {"locator", "exact", "state"},
|
|
75
77
|
"assert_visible": {"locator", "exact"},
|
|
76
78
|
"assert_text": {"locator", "exact", "expected", "match", "normalize_whitespace", "case_sensitive"},
|
|
79
|
+
"assert_each_text": {"locator", "exact", "expected", "match", "min_count", "normalize_whitespace", "case_sensitive"},
|
|
80
|
+
"assert_value": {"locator", "exact", "expected", "match", "normalize_whitespace", "case_sensitive"},
|
|
77
81
|
"assert_url": {"expected", "match"},
|
|
78
82
|
"screenshot": {"full_page"},
|
|
79
83
|
}
|
|
84
|
+
for _ui_action in UI_KEYS:
|
|
85
|
+
UI_KEYS[_ui_action] = UI_KEYS[_ui_action] | {"source_anchor"}
|
|
80
86
|
API_KEYS = {"method", "url", "headers", "query", "json", "expect_status", "assertions", "extract", "retry", "idempotent"}
|
|
81
87
|
CASE_KEYS = {"tc_id", "name", "description", "variables", "data", "steps", "source_evidence", "tags"}
|
|
82
88
|
DATA_KEYS = {"namespace", "owner", "accounts", "repeatable", "setup", "cleanup"}
|
|
@@ -199,12 +205,73 @@ def _css_errors(css: str) -> list[str]:
|
|
|
199
205
|
return errors
|
|
200
206
|
|
|
201
207
|
|
|
202
|
-
def
|
|
208
|
+
def _validate_object_target(target: Any, label: str) -> dict[str, Any]:
|
|
209
|
+
"""Validate new structured targets without duplicating Core semantics."""
|
|
210
|
+
if not isinstance(target, dict) or not target:
|
|
211
|
+
raise CompileError(f"{label}.locator target must be a non-empty object")
|
|
212
|
+
allowed = {"testid", "role", "name", "label", "placeholder", "text", "css", "exact",
|
|
213
|
+
"within", "table", "column", "table_kind", "row"}
|
|
214
|
+
unknown = set(target) - allowed
|
|
215
|
+
if unknown:
|
|
216
|
+
raise CompileError(f"{label}.locator has unsupported target keys {sorted(unknown)}")
|
|
217
|
+
forms = [key for key in ("testid", "role", "label", "placeholder", "text", "css", "table", "row") if key in target]
|
|
218
|
+
if "within" in target:
|
|
219
|
+
if len(forms) != 1:
|
|
220
|
+
raise CompileError(f"{label}.locator.within must wrap exactly one locator form")
|
|
221
|
+
_validate_object_target(target["within"], f"{label}.locator.within")
|
|
222
|
+
elif len(forms) != 1:
|
|
223
|
+
raise CompileError(f"{label}.locator must contain exactly one locator form")
|
|
224
|
+
if "role" in target and (not isinstance(target["role"], str) or not isinstance(target.get("name"), str)):
|
|
225
|
+
raise CompileError(f"{label}.locator role target requires role and name")
|
|
226
|
+
if "role" not in target and "name" in target:
|
|
227
|
+
raise CompileError(f"{label}.locator.name requires role")
|
|
228
|
+
if "exact" in target and not isinstance(target["exact"], bool):
|
|
229
|
+
raise CompileError(f"{label}.locator.exact must be a boolean")
|
|
230
|
+
if "table" in target:
|
|
231
|
+
table = target["table"]
|
|
232
|
+
if not isinstance(table, dict) or set(table) not in ({"near_heading"}, {"testid"}, {"only"}, {"ref"}):
|
|
233
|
+
raise CompileError(f"{label}.locator.table must use near_heading, testid, only, or ref")
|
|
234
|
+
if "column" not in target or not isinstance(target["column"], str) or not target["column"].strip():
|
|
235
|
+
raise CompileError(f"{label}.locator.table requires a non-empty column")
|
|
236
|
+
if target.get("table_kind", "html") not in {"html", "element-plus"}:
|
|
237
|
+
raise CompileError(f"{label}.locator.table_kind must be html or element-plus")
|
|
238
|
+
if "row" in target:
|
|
239
|
+
row = target["row"]
|
|
240
|
+
if not isinstance(row, dict) or set(row) not in ({"column", "equals"}, {"column", "contains"}):
|
|
241
|
+
raise CompileError(f"{label}.locator.row must be {{column, equals}} or {{column, contains}}")
|
|
242
|
+
match_key = "equals" if "equals" in row else "contains"
|
|
243
|
+
if not isinstance(row.get("column"), str) or not row["column"].strip() or not isinstance(row.get(match_key), str):
|
|
244
|
+
raise CompileError(f"{label}.locator.row column and match value must be strings")
|
|
245
|
+
return json.loads(json.dumps(target, ensure_ascii=False))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _compile_target(raw: Any, exact: Any, label: str, warnings: list[str], locator_dir: Path | None = None) -> tuple[dict[str, Any], str | None]:
|
|
203
249
|
"""'type:value' → one v1 target. testid first; role/label/placeholder/text default to exact."""
|
|
250
|
+
if isinstance(raw, dict):
|
|
251
|
+
if exact is not None:
|
|
252
|
+
raise CompileError(f"{label}.exact must be inside a structured locator object")
|
|
253
|
+
target = _validate_object_target(raw, label)
|
|
254
|
+
if "table" in target:
|
|
255
|
+
warnings.append(f"{label} uses table+column locator; verify the table adapter and column uniqueness")
|
|
256
|
+
elif "row" in target:
|
|
257
|
+
warnings.append(f"{label} uses a data-dependent row locator")
|
|
258
|
+
elif "within" in target:
|
|
259
|
+
warnings.append(f"{label} uses a within-scoped locator")
|
|
260
|
+
return target, None
|
|
204
261
|
if not isinstance(raw, str) or ":" not in raw:
|
|
205
262
|
raise CompileError(f"{label}.locator must be 'testid:<id>' | 'role:<role>:<name>' | 'label:<text>' | "
|
|
206
263
|
"'placeholder:<text>' | 'text:<text>' | 'css:<selector>'")
|
|
207
264
|
kind, _, value = raw.strip().partition(":")
|
|
265
|
+
if kind.strip().lower() == "anchor":
|
|
266
|
+
if locator_dir is None:
|
|
267
|
+
raise CompileError(f"{label}.locator anchor requires the test-locators directory")
|
|
268
|
+
try:
|
|
269
|
+
target, source_anchor = expand_anchor_reference(raw, locator_dir)
|
|
270
|
+
except LocatorMapError as error:
|
|
271
|
+
raise CompileError(f"{label}.locator: {error}") from error
|
|
272
|
+
if exact is not None:
|
|
273
|
+
raise CompileError(f"{label}.exact cannot override an anchor target")
|
|
274
|
+
return _compile_target(target, None, label, warnings, locator_dir)[0], source_anchor
|
|
208
275
|
kind = kind.strip().lower()
|
|
209
276
|
if kind in REJECTED_LOCATOR_TYPES:
|
|
210
277
|
raise CompileError(f"{label}.locator: {REJECTED_LOCATOR_TYPES[kind]}")
|
|
@@ -217,7 +284,7 @@ def compile_target(raw: Any, exact: Any, label: str, warnings: list[str]) -> dic
|
|
|
217
284
|
raise CompileError(f"{label}.exact does not apply to testid locators")
|
|
218
285
|
if not value.strip():
|
|
219
286
|
raise CompileError(f"{label}.locator has an empty value")
|
|
220
|
-
return {"testid": value.strip()}
|
|
287
|
+
return {"testid": value.strip()}, None
|
|
221
288
|
if kind == "css":
|
|
222
289
|
if exact is not None:
|
|
223
290
|
raise CompileError(f"{label}.exact does not apply to css locators")
|
|
@@ -225,7 +292,7 @@ def compile_target(raw: Any, exact: Any, label: str, warnings: list[str]) -> dic
|
|
|
225
292
|
if errors:
|
|
226
293
|
raise CompileError(f"{label}: " + "; ".join(errors))
|
|
227
294
|
warnings.append(f"{label} uses a css locator; prefer a data-testid and cite the selector source in source-map.md")
|
|
228
|
-
return {"css": value.strip()}
|
|
295
|
+
return {"css": value.strip()}, None
|
|
229
296
|
if kind == "role":
|
|
230
297
|
role, _, name = value.partition(":")
|
|
231
298
|
if not re.fullmatch(r"[a-z]+", role.strip()) or not name.strip():
|
|
@@ -242,7 +309,12 @@ def compile_target(raw: Any, exact: Any, label: str, warnings: list[str]) -> dic
|
|
|
242
309
|
warnings.append(f"{label} locates '{text}' by {kind}; this text usually repeats per row/dialog and fails strict mode — use a data-testid")
|
|
243
310
|
else:
|
|
244
311
|
warnings.append(f"{label} uses {kind} instead of a data-testid")
|
|
245
|
-
return target
|
|
312
|
+
return target, None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def compile_target(raw: Any, exact: Any, label: str, warnings: list[str], locator_dir: Path | None = None) -> dict[str, Any]:
|
|
316
|
+
"""Backward-compatible public target compiler; anchor provenance is step-level."""
|
|
317
|
+
return _compile_target(raw, exact, label, warnings, locator_dir)[0]
|
|
246
318
|
|
|
247
319
|
|
|
248
320
|
# ---------------------------------------------------------------------------
|
|
@@ -360,7 +432,7 @@ def compile_api_step(step: dict[str, Any], method: str | None, default_id: str,
|
|
|
360
432
|
return compiled
|
|
361
433
|
|
|
362
434
|
|
|
363
|
-
def compile_step(step: Any, default_id: str, label: str, warnings: list[str], api_only: bool = False) -> dict[str, Any]:
|
|
435
|
+
def compile_step(step: Any, default_id: str, label: str, warnings: list[str], api_only: bool = False, locator_dir: Path | None = None) -> dict[str, Any]:
|
|
364
436
|
if not isinstance(step, dict):
|
|
365
437
|
raise CompileError(f"{label} must be an object")
|
|
366
438
|
_check_forbidden(step, label)
|
|
@@ -384,16 +456,20 @@ def compile_step(step: Any, default_id: str, label: str, warnings: list[str], ap
|
|
|
384
456
|
if lowered in TARGET_ACTIONS:
|
|
385
457
|
if "locator" not in step:
|
|
386
458
|
raise CompileError(f"{label}.locator is required for {lowered}")
|
|
387
|
-
compiled["target"] =
|
|
459
|
+
compiled["target"], source_anchor = _compile_target(step["locator"], step.get("exact"), label, warnings, locator_dir)
|
|
460
|
+
if source_anchor:
|
|
461
|
+
compiled["source_anchor"] = source_anchor
|
|
388
462
|
if lowered == "goto":
|
|
389
463
|
compiled["url"] = step.get("url")
|
|
390
464
|
elif lowered in {"fill", "select"}:
|
|
391
465
|
compiled["value"] = step.get("value")
|
|
392
466
|
elif lowered == "wait_for":
|
|
393
467
|
compiled["state"] = step.get("state", "visible")
|
|
394
|
-
elif lowered
|
|
468
|
+
elif lowered in {"assert_text", "assert_each_text", "assert_value"}:
|
|
395
469
|
compiled["match"] = step.get("match", "exact")
|
|
396
470
|
compiled["expected"] = step.get("expected")
|
|
471
|
+
if lowered == "assert_each_text" and "min_count" in step:
|
|
472
|
+
compiled["min_count"] = step["min_count"]
|
|
397
473
|
for key in ("normalize_whitespace", "case_sensitive"):
|
|
398
474
|
if key in step:
|
|
399
475
|
compiled[key] = step[key]
|
|
@@ -428,7 +504,7 @@ def compile_variables(raw: Any, label: str) -> dict[str, Any]:
|
|
|
428
504
|
return compiled
|
|
429
505
|
|
|
430
506
|
|
|
431
|
-
def compile_case(desc: Any, index: int, eligible: dict[str, dict[str, Any]], rejected: dict[str, str]) -> tuple[str, dict[str, Any], list[str]]:
|
|
507
|
+
def compile_case(desc: Any, index: int, eligible: dict[str, dict[str, Any]], rejected: dict[str, str], locator_dir: Path | None = None) -> tuple[str, dict[str, Any], list[str]]:
|
|
432
508
|
label = f"cases[{index}]"
|
|
433
509
|
if not isinstance(desc, dict):
|
|
434
510
|
raise CompileError(f"{label} must be an object")
|
|
@@ -457,12 +533,12 @@ def compile_case(desc: Any, index: int, eligible: dict[str, dict[str, Any]], rej
|
|
|
457
533
|
steps = data_raw.get(phase, [])
|
|
458
534
|
if not isinstance(steps, list):
|
|
459
535
|
raise CompileError(f"{label}.data.{phase} must be an array")
|
|
460
|
-
data[phase] = [compile_step(step, f"{phase}-{i:02d}", f"{label}.data.{phase}[{i - 1}]", warnings, api_only=True)
|
|
536
|
+
data[phase] = [compile_step(step, f"{phase}-{i:02d}", f"{label}.data.{phase}[{i - 1}]", warnings, api_only=True, locator_dir=locator_dir)
|
|
461
537
|
for i, step in enumerate(steps, start=1)]
|
|
462
538
|
raw_steps = desc.get("steps")
|
|
463
539
|
if not isinstance(raw_steps, list) or not raw_steps:
|
|
464
540
|
raise CompileError(f"{label}.steps must be a non-empty array")
|
|
465
|
-
steps = [compile_step(step, f"s{i:02d}", f"{label}.steps[{i - 1}]", warnings) for i, step in enumerate(raw_steps, start=1)]
|
|
541
|
+
steps = [compile_step(step, f"s{i:02d}", f"{label}.steps[{i - 1}]", warnings, locator_dir=locator_dir) for i, step in enumerate(raw_steps, start=1)]
|
|
466
542
|
source = eligible[tc_id]
|
|
467
543
|
name = desc.get("name")
|
|
468
544
|
dsl = {
|
|
@@ -482,7 +558,7 @@ def compile_case(desc: Any, index: int, eligible: dict[str, dict[str, Any]], rej
|
|
|
482
558
|
return tc_id, dsl, [f"{label}: {warning}" for warning in warnings]
|
|
483
559
|
|
|
484
560
|
|
|
485
|
-
def compile_all(payload: Any, suite: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], list[str], list[str]]:
|
|
561
|
+
def compile_all(payload: Any, suite: dict[str, Any], locator_dir: Path | None = None) -> tuple[dict[str, dict[str, Any]], list[str], list[str]]:
|
|
486
562
|
"""Return ({tc_id: dsl}, warnings, errors). Errors are collected per case."""
|
|
487
563
|
cases = payload.get("cases") if isinstance(payload, dict) else payload
|
|
488
564
|
if not isinstance(cases, list):
|
|
@@ -493,7 +569,7 @@ def compile_all(payload: Any, suite: dict[str, Any]) -> tuple[dict[str, dict[str
|
|
|
493
569
|
errors: list[str] = []
|
|
494
570
|
for index, desc in enumerate(cases):
|
|
495
571
|
try:
|
|
496
|
-
tc_id, dsl, case_warnings = compile_case(desc, index, eligible, rejected)
|
|
572
|
+
tc_id, dsl, case_warnings = compile_case(desc, index, eligible, rejected, locator_dir=locator_dir)
|
|
497
573
|
except CompileError as error:
|
|
498
574
|
errors.append(str(error))
|
|
499
575
|
continue
|
|
@@ -540,7 +616,8 @@ def main() -> int:
|
|
|
540
616
|
payload = json.loads(Path(args.input).read_text(encoding="utf-8"))
|
|
541
617
|
except (OSError, json.JSONDecodeError) as error:
|
|
542
618
|
raise CompileError(f"cannot read case descriptions: {error}") from error
|
|
543
|
-
|
|
619
|
+
locator_dir = root / ".devflow/planning/test-locators"
|
|
620
|
+
compiled, warnings, errors = compile_all(payload, suite, locator_dir=locator_dir)
|
|
544
621
|
except CompileError as error:
|
|
545
622
|
print(f"COMPILE ERROR: {error}", file=sys.stderr)
|
|
546
623
|
return 1
|
|
@@ -561,8 +638,14 @@ def main() -> int:
|
|
|
561
638
|
write_atomic(path, content)
|
|
562
639
|
files.append({"tc_id": tc_id, "path": path.relative_to(root).as_posix() if path.is_relative_to(root) else str(path),
|
|
563
640
|
"changed": not unchanged})
|
|
641
|
+
report_path = root / ".devflow/planning/features" / feature / "locator-stability.md"
|
|
642
|
+
try:
|
|
643
|
+
write_report(report_path, compiled.values(), feature)
|
|
644
|
+
except (OSError, ValueError) as error:
|
|
645
|
+
print(f"COMPILE ERROR: cannot write locator stability report: {error}", file=sys.stderr)
|
|
646
|
+
return 1
|
|
564
647
|
print(json.dumps({"status": "compiled", "feature": feature, "case_count": len(files), "files": files,
|
|
565
|
-
"warnings": len(warnings)}, ensure_ascii=False, indent=2))
|
|
648
|
+
"warnings": len(warnings), "locator_report": str(report_path.relative_to(root))}, ensure_ascii=False, indent=2))
|
|
566
649
|
return 0
|
|
567
650
|
|
|
568
651
|
|
package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render the internal locator stability report for generated DSL."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Iterable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _strategy(target: dict[str, Any]) -> tuple[str, str, str]:
|
|
11
|
+
if "within" in target:
|
|
12
|
+
return "within", "中", "区域限定目标;区域与内层目标都必须唯一匹配。"
|
|
13
|
+
if "table" in target:
|
|
14
|
+
return "table_column", "中", f"按表格选择器和表头‘{target.get('column', '')}’解析,运行时计算列位置。"
|
|
15
|
+
if "row" in target:
|
|
16
|
+
return "row", "高", "依赖测试数据;行值变化或重复会导致失败。"
|
|
17
|
+
for key, risk, reason in (
|
|
18
|
+
("testid", "低", "使用稳定的 data-testid。"),
|
|
19
|
+
("role", "中", "依赖可访问角色和名称。"),
|
|
20
|
+
("label", "中", "依赖表单 label 文本。"),
|
|
21
|
+
("placeholder", "中", "依赖 placeholder 文本。"),
|
|
22
|
+
("text", "高", "可见文本可能重复或随文案变化。"),
|
|
23
|
+
("css", "高", "CSS 选择器容易受结构或构建变化影响。"),
|
|
24
|
+
):
|
|
25
|
+
if key in target:
|
|
26
|
+
return key, risk, reason
|
|
27
|
+
return "unknown", "高", "无法识别的定位形式。"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _steps(dsl: dict[str, Any]) -> Iterable[tuple[str, dict[str, Any]]]:
|
|
31
|
+
for phase, values in (("setup", dsl.get("data", {}).get("setup", [])), ("main", dsl.get("steps", [])),
|
|
32
|
+
("cleanup", dsl.get("data", {}).get("cleanup", []))):
|
|
33
|
+
for step in values if isinstance(values, list) else []:
|
|
34
|
+
if isinstance(step, dict) and step.get("driver") == "ui" and "target" in step:
|
|
35
|
+
yield phase, step
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def report_entries(dsl: dict[str, Any]) -> list[dict[str, str]]:
|
|
39
|
+
entries: list[dict[str, str]] = []
|
|
40
|
+
for phase, step in _steps(dsl):
|
|
41
|
+
strategy, risk, reason = _strategy(step["target"])
|
|
42
|
+
entry = {"step": str(step.get("id", "")), "phase": phase, "strategy": strategy,
|
|
43
|
+
"risk": risk, "reason": reason}
|
|
44
|
+
if step.get("source_anchor"):
|
|
45
|
+
entry["source_anchor"] = str(step["source_anchor"])
|
|
46
|
+
entries.append(entry)
|
|
47
|
+
return entries
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render_report(dsls: Iterable[dict[str, Any]], feature: str | None = None) -> str:
|
|
51
|
+
cases = list(dsls)
|
|
52
|
+
title = f"# 定位稳定性报告{f':{feature}' if feature else ''}\n\n"
|
|
53
|
+
lines = [title, "本报告由编译后的 DSL 自动生成,仅用于内部留档;运行时只执行展开后的 DSL。", ""]
|
|
54
|
+
for dsl in sorted(cases, key=lambda item: str(item.get("tc_id", ""))):
|
|
55
|
+
lines.extend([f"## {dsl.get('tc_id', 'unknown')}:{dsl.get('name', '')}", "", "| 步骤 | 阶段 | 定位方式 | 风险 | 原因 | 来源锚点 |", "|---|---|---|---|---|---|"])
|
|
56
|
+
entries = report_entries(dsl)
|
|
57
|
+
if not entries:
|
|
58
|
+
lines.append("| — | — | — | — | UI 步骤无定位目标 | — |")
|
|
59
|
+
else:
|
|
60
|
+
for item in entries:
|
|
61
|
+
row = dict(item)
|
|
62
|
+
row["source_anchor"] = "`" + item["source_anchor"] + "`" if item.get("source_anchor") else "—"
|
|
63
|
+
lines.append("| {step} | {phase} | {strategy} | {risk} | {reason} | {source_anchor} |".format(**row))
|
|
64
|
+
lines.extend(["", "可选前端改进:优先补充已有业务语义的 `data-testid`;本报告不要求修改被测前端。", ""])
|
|
65
|
+
return "\n".join(lines)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_report(path: Path, dsls: Iterable[dict[str, Any]], feature: str | None = None) -> None:
|
|
69
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
path.write_text(render_report(dsls, feature), encoding="utf-8")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
__all__ = ["render_report", "report_entries", "write_report"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: workflow-regression-test-design
|
|
3
|
+
description: "Use from an idle project to derive a user-confirmed, frozen regression acceptance suite for an existing feature without requiring a requirement baseline."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 存量功能回归验收测试设计
|
|
7
|
+
|
|
8
|
+
本步骤为已经上线或已有实现的功能建立可复现的回归验收套件。它不补造需求,也不把当前实现自动宣布为正确;它把代码、代码索引、外部契约和用户确认的现有行为整理为候选 `TC-*`,然后交给 Core 在 CP-3 冻结。
|
|
9
|
+
|
|
10
|
+
## 执行合同
|
|
11
|
+
|
|
12
|
+
图谱写入受 ExecutionContract v2 约束,active contract 的 step 必须是 `regression-test-design`。开始时先调用 `devflow_contract(action="status")`;没有 active contract 时调用 `devflow_contract(action="activate", feature="<feature>", step="regression-test-design")`。不得用普通对话、Skill 自己写 `tests.json` 或逐条确认替代 Core 的冻结操作。
|
|
13
|
+
|
|
14
|
+
## 输入顺序与范围
|
|
15
|
+
|
|
16
|
+
需要补充当前页面行为时,可在已批准的测试环境中调用 `devflow_explore`(start/observe/act/assert/draft)。探索只用于发现和录制草稿,不是验收证据;密码、密钥和凭据值不得写入描述、日志或草稿。草稿必须由后续 `workflow-generate-mainflow-cases` 编译、校验并经用户批准后才能执行。
|
|
17
|
+
|
|
18
|
+
1. 读取 `devflow_requirement_graph(action="inspect")` 和 `.devflow/planning/code-index/`,确认当前功能、代码索引 revision、入口、接口、数据流和调用关系。
|
|
19
|
+
2. 阅读实际前端与后端源码、路由/控制器、服务、权限规则、数据访问和已有测试;代码只能用于发现真实功能点与可执行入口,不能把实现细节直接当作预期。
|
|
20
|
+
3. 阅读 `.devflow/planning/test-environments/*.json` 中的环境声明;需要登录或测试 token 时,只记录 `secret_env` 的变量名和所需前置,不读取或输出值。
|
|
21
|
+
4. 阅读项目已有文档、接口定义和公开约定,区分可引用的 `contract` 与仅能说明当前行为的 `characterization`。
|
|
22
|
+
|
|
23
|
+
如果代码索引缺失、源码与索引明显不一致、或无法写出外部可观察的判定标准,先记录阻塞,不用猜测补全。
|
|
24
|
+
|
|
25
|
+
## 生成流程
|
|
26
|
+
|
|
27
|
+
先列出功能点和证据来源,再为每个功能点提出候选 TC。优先顺序是 `invariant`、`contract`,其次是 `characterization`;只有明确的需求/验收文档才使用 `requirement`。每条回归 TC 必须显式填写 `oracle_source`。非 `requirement` 必须填写 `oracle_evidence`,包括文件:行、接口定义、不变量说明,或带环境和时间的观察记录。
|
|
28
|
+
|
|
29
|
+
候选结构至少包含:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"acceptance_refs": [],
|
|
34
|
+
"requirement_refs": [],
|
|
35
|
+
"test_type": "page|api|permission|boundary|resilience|migration|performance|analytics|freshness|reconciliation",
|
|
36
|
+
"execution_surface": "http|ui|api_flow|project_test",
|
|
37
|
+
"target": "外部可观察功能点",
|
|
38
|
+
"preconditions": ["身份、环境和数据前置"],
|
|
39
|
+
"steps": ["原子化操作"],
|
|
40
|
+
"oracle": "不依赖易变业务数据值的可观察结果",
|
|
41
|
+
"oracle_source": "invariant",
|
|
42
|
+
"oracle_evidence": "不变量说明及其来源",
|
|
43
|
+
"automation_mode": "automated|manual|blocked",
|
|
44
|
+
"evidence_strategy": "replay|causal|observation"
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- `requirement` 必须有非空 `requirement_refs` 与 `acceptance_refs`,并通过已有 `verified_by`/确认校验;`contract`、`invariant`、`characterization` 在回归工作流中可以两者均为空,因为存量功能可能没有 REQ/AC 图谱节点,但仍必须有 `oracle_evidence`。如果实际存在 REQ/AC 引用,则必须按普通边和确认规则校验。
|
|
49
|
+
- 禁止把依赖环境数据的值写死为预期,例如“公海 = 394”。优先断言结构、关系、权限边界、稳定状态和每行一致性;`characterization` 只证明行为未变化,不证明行为正确。
|
|
50
|
+
- `execution_surface` 必须显式填写,且遵守已有 DSL/adapter 兼容矩阵。具体 selector、endpoint、测试数据和命令属于后续绑定,不得改变 TC 意图。
|
|
51
|
+
- `automated` 的 `ui`/`api_flow` 用例必须能由 `workflow-generate-mainflow-cases` 生成确定性 DSL;没有稳定 locator、无法隔离数据或需要纯视觉判断时标为 `manual` 或 `blocked`。
|
|
52
|
+
- 回归用例需要绑定定位时,优先复用已有 `data-testid`,其次在 `.devflow/planning/test-locators/<page-key>.json` 建立可审计锚点;用例描述使用 `anchor:<page-key>/<anchor-name>`,不要把映射文件交给 Runtime。
|
|
53
|
+
- `observation` 只能配合 `automation_mode: blocked`,并填写 `observation_plan`;不能把人工看过当作通过证据。
|
|
54
|
+
- 需要当前行为观察时,可以在已批准测试环境中执行只读观察。记录环境、时间、身份类别、操作摘要、观察到的结构/关系和复现条件,不记录凭据、响应中的秘密或脱敏前的个人数据。
|
|
55
|
+
|
|
56
|
+
## 用户检查点与冻结
|
|
57
|
+
|
|
58
|
+
写入 `.devflow/planning/features/{feature}/回归验收测试设计.md`,包含功能点清单、每条 TC 的来源和证据、执行面、自动化可行性、环境前置、风险与未决问题;同时维护 `tests.meta.json` 作为该候选套件的元数据视图。检查点前必须有这两个回归设计产出,Core 才能生成并冻结 `tests.json`。回归没有实现任务,因此不生成 `test-bindings.json`;后续 DSL 和验收步骤的绑定/结果文件不属于本步骤输出。
|
|
59
|
+
|
|
60
|
+
在 CP-3 展示所有候选,明确提示:`characterization` 只保证行为不变;`contract`/`invariant`/`characterization` 的证据是否足够;哪些用例会因无环境或无稳定执行面而 blocked。只有用户确认后,Core 才能一次性冻结完整套件。冻结失败时严格拒绝,修正候选后重新走检查点。
|
|
61
|
+
|
|
62
|
+
冻结后移交 `workflow-generate-mainflow-cases` 的 `regression-dsl` 步骤。该步骤只绑定已冻结意图;它不能新增、删减或改写 oracle、步骤或 `oracle_source`。
|
|
63
|
+
|
|
64
|
+
## 输出与禁止事项
|
|
65
|
+
|
|
66
|
+
- 输出:回归设计文档及其 meta 视图;结构化 `TC-*` 只由 Core 图谱冻结产生。
|
|
67
|
+
- 不读取或使用 agent 交付自述、`SUMMARY.md` 或 git diff 来决定测试范围。
|
|
68
|
+
- 不写入 `acceptance-results.json`,不生成人工通过结果,不确认执行计划或环境批准。
|
|
69
|
+
- 不向模型上下文、文档或日志输出测试凭据值。
|
|
@@ -31,7 +31,7 @@ description: "在冻结需求验收测试全部通过后,执行 Devflow 自有
|
|
|
31
31
|
|
|
32
32
|
## 收口动作
|
|
33
33
|
|
|
34
|
-
1. 调用 `devflow_run_acceptance_tests(action="validate_results", feature=...)`,让 Core 重新校验 `acceptance-results.json`(suite pins、`execution_surface` 兼容、当前仍有效的 `execution_plan`/`environment_profile`/`human_result`/`mode_override` 批准、运行目录与工件哈希);运行后 DSL 或 profile 被修改、批准被替换,都会使已记录结果失效,需要回到 `run-acceptance-tests`
|
|
34
|
+
1. 调用 `devflow_run_acceptance_tests(action="validate_results", feature=...)`,让 Core 重新校验 `acceptance-results.json`(suite pins、`execution_surface` 兼容、当前仍有效的 `execution_plan`/`environment_profile`/`human_result`/`mode_override` 批准、运行目录与工件哈希);运行后 DSL 或 profile 被修改、批准被替换,都会使已记录结果失效,需要回到 `run-acceptance-tests` 重跑;不通过则停止收口。通过后读取该文件(读取即可,不得写入),计算通过/失败/阻塞数量,展示逐 TC 的 `oracle_source`、按来源计数、`legacy_unclassified`、有效降级记录和逐 TC `dsl_approval`。`characterization` 用例只证明行为未变化,不证明正确性。任何 `dsl_approval: none` 必须对应 blocked;若其被标为 passed、结果未全 passed 或存在未批准降级,立即拒绝收口。人工 TC 的 passed 只来自已确认的 `human_result` approval,`human-acceptance-results.json` 草稿不算证据。失败分析和证据读取只依据 `result.json` 与 `acceptance-results.json`;Agent 不得读取 trace、截图、网络工件或 `runtime.log`,这些由用户本地查看(hook 会拒绝 Agent 读取)。
|
|
35
35
|
2. 更新 `.devflow/planning/navigation/INDEX.md`,记录本次 code-index revision 和验收结果 fingerprint。
|
|
36
36
|
3. 在 `.devflow/planning/milestones/` 写入本版本的 ROADMAP、需求覆盖、测试结果、变更清单和已接受风险摘要。
|
|
37
37
|
4. 更新 `.devflow/planning/PROJECT.md` 的当前版本与下一步目标。
|
|
@@ -28,7 +28,7 @@ description: "Execute the frozen TC acceptance suite after code review. Use the
|
|
|
28
28
|
## 过程
|
|
29
29
|
|
|
30
30
|
1. 用 `devflow_acceptance_tests(feature)` 检查测试集已由 CP-3 冻结。若有 stale 或候选 TC,停止并回到需求/测试设计。
|
|
31
|
-
2. 读取冻结 TC 与其 oracle
|
|
31
|
+
2. 读取冻结 TC 与其 oracle、`oracle_source`/`oracle_evidence`、前置数据、身份、自动化模式和 `execution_surface`。结果与交接报告必须逐 TC 展示 `oracle_source`,并按来源汇总;`characterization` 用例只证明行为未变化。
|
|
32
32
|
对 `api_refs`、`workload_refs` 和 `risk_refs` 指向的专项用例,保留其契约、性能/迁移或韧性义务;没有适配器或环境时必须写为 blocked,不能用普通主流程替代。
|
|
33
33
|
3. 读取代码审查报告、代码索引和实际源码,仅为每个 TC 绑定运行适配器;不得改变 TC 的断言、范围或预期。对每个自动化 `ui|api_flow` TC,若 `generated-cases/{feature}/TC-xxxx.json` 不存在或已过期(TC 重冻结、代码变更),先调用 `workflow-generate-mainflow-cases`;它无法生成的正向 UI/API TC(无稳定 locator、无法准备或隔离数据、纯视觉判断)按下一步降级,异常/边界/权限/性能专项 TC 也不由它生成。
|
|
34
34
|
4. 写入 `.devflow/planning/features/{feature}/acceptance-test-bindings.json`:
|
package/src/constants.ts
CHANGED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export type StartupIssue = { code?: unknown; detail?: unknown; path?: unknown };
|
|
2
|
+
|
|
3
|
+
type StartupFacts = {
|
|
4
|
+
feature?: unknown;
|
|
5
|
+
phase?: unknown;
|
|
6
|
+
step?: { id?: unknown; label?: unknown; skill?: unknown; description?: unknown };
|
|
7
|
+
progress?: { index?: unknown; total?: unknown };
|
|
8
|
+
inputs?: Array<{ path?: unknown; required?: unknown; exists?: unknown }>;
|
|
9
|
+
outputs?: unknown[];
|
|
10
|
+
allowed_paths?: unknown[];
|
|
11
|
+
contract_id?: unknown;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type StartupHandoff = {
|
|
15
|
+
previous_step?: { id?: unknown; label?: unknown; outputs?: Array<{ path?: unknown }> };
|
|
16
|
+
carry?: { user_notes?: unknown[]; agent_report?: unknown };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type StartupMeta = { revision?: unknown; handoffProvided?: boolean };
|
|
20
|
+
|
|
21
|
+
const issueDescriptions: Record<string, (issue: StartupIssue) => string> = {
|
|
22
|
+
PREVIOUS_OUTPUT_CHANGED: issue => `上一步的产物 ${String(issue.path ?? "未知路径")} 在交接后被修改过`,
|
|
23
|
+
PREVIOUS_OUTPUT_MISSING: issue => `上一步的产物 ${String(issue.path ?? "未知路径")} 不存在`,
|
|
24
|
+
HANDOFF_UNAVAILABLE: issue => `无法读取或校验交接单${issue.path ? ` ${String(issue.path)}` : ""}`,
|
|
25
|
+
STEP_UNAVAILABLE: () => "当前步骤不在工作流中",
|
|
26
|
+
MISSING_INPUT_FACT: issue => `本步需要的输入 ${String(issue.path ?? "未知路径")} 不存在`,
|
|
27
|
+
STALE_EXECUTION_CONTRACT: () => "执行合同已过期,请重新激活",
|
|
28
|
+
STALE_WORKFLOW_SNAPSHOT: () => "工作流快照已变化,执行合同不再有效",
|
|
29
|
+
STALE_STATE_SNAPSHOT: () => "STATE 快照已变化,执行合同不再有效",
|
|
30
|
+
UNREACHABLE_OBLIGATION: issue => `执行合同不允许产出 ${String(issue.path ?? "该必需产物")}`,
|
|
31
|
+
CONTRACT_TARGET_MISMATCH: () => "执行合同与当前 Feature 或步骤不匹配",
|
|
32
|
+
STARTUP_PREFLIGHT_INPUT_UNAVAILABLE: issue => `开工预检输入不可用${issue.detail ? `:${String(issue.detail)}` : ""}`,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function describeStartupIssue(issue: StartupIssue): string {
|
|
36
|
+
const code = typeof issue.code === "string" && issue.code ? issue.code : "UNKNOWN_ISSUE";
|
|
37
|
+
return issueDescriptions[code]?.(issue) ?? `${code}:${String(issue.detail ?? "无详细信息")}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function strings(values: unknown[] | undefined): string[] {
|
|
41
|
+
return Array.isArray(values) ? values.filter((value): value is string => typeof value === "string" && value.length > 0) : [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function truncate(value: string, limit: number): string {
|
|
45
|
+
if (value.length <= limit) return value;
|
|
46
|
+
return limit <= 1 ? "…".slice(0, limit) : `${value.slice(0, limit - 1)}…`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function renderStartupBriefing(facts: StartupFacts, handoff: StartupHandoff = {}, meta: StartupMeta = {}): string {
|
|
50
|
+
const render = (report: string, notes: string[]): string => {
|
|
51
|
+
const sections: string[] = [];
|
|
52
|
+
const step = facts.step;
|
|
53
|
+
const progress = facts.progress;
|
|
54
|
+
if (facts.feature && step?.id && step.label && progress?.index !== undefined && progress.total !== undefined) {
|
|
55
|
+
sections.push(`【需求】${String(facts.feature)} · 第 ${String(progress.index)} / ${String(progress.total)} 步:${String(step.label)}(${String(step.id)})`);
|
|
56
|
+
}
|
|
57
|
+
if (step?.description) sections.push(`【本步目标】${String(step.description)}`);
|
|
58
|
+
|
|
59
|
+
const previous = handoff.previous_step;
|
|
60
|
+
const previousOutputs = previous?.outputs?.flatMap(item => typeof item?.path === "string" ? [item.path] : []) ?? [];
|
|
61
|
+
const handoffLines: string[] = [];
|
|
62
|
+
if (previous?.label) {
|
|
63
|
+
handoffLines.push(`- 上一步「${String(previous.label)}」已完成,产物:${previousOutputs.join("、") || "无已记录产物"}`);
|
|
64
|
+
}
|
|
65
|
+
if (notes.length > 0) {
|
|
66
|
+
handoffLines.push("- 用户在上一步审查期间的意见:", ...notes.map(note => `- ${note}`));
|
|
67
|
+
}
|
|
68
|
+
if (report) handoffLines.push(`- 上一步 AI 报告摘要:${report}`);
|
|
69
|
+
if (handoffLines.length > 0) sections.push(["【上一棒交接】", ...handoffLines].join("\n"));
|
|
70
|
+
|
|
71
|
+
const work: string[] = [];
|
|
72
|
+
if (step?.skill) work.push(`- 使用技能:skill://${String(step.skill)}`);
|
|
73
|
+
const inputText = facts.inputs?.flatMap(input => {
|
|
74
|
+
if (typeof input?.path !== "string") return [];
|
|
75
|
+
if (input.required === true && input.exists === true) return [`${input.path} ✓`];
|
|
76
|
+
if (input.required === false && input.exists !== true) return [`${input.path}(可选,缺失)`];
|
|
77
|
+
return [input.path];
|
|
78
|
+
}) ?? [];
|
|
79
|
+
if (inputText.length > 0) work.push(`- 已核对的输入:${inputText.join("/")}`);
|
|
80
|
+
const outputs = strings(facts.outputs);
|
|
81
|
+
if (outputs.length > 0) work.push(`- 必须产出:${outputs.join("、")}`);
|
|
82
|
+
const allowedPaths = strings(facts.allowed_paths);
|
|
83
|
+
if (allowedPaths.length > 0) work.push(`- 只允许写入:${allowedPaths.join("、")}`);
|
|
84
|
+
if (facts.contract_id) work.push(`- 执行合同已由 Runtime 激活:${String(facts.contract_id)}(不要再次 inspect 或 activate)`);
|
|
85
|
+
if (work.length > 0) sections.push(["【这一步你要做】", ...work].join("\n"));
|
|
86
|
+
|
|
87
|
+
sections.push("【完成标准】产出完成后,报告证据、未决项和验证结果并停止,等待用户回复\"批准执行\"。");
|
|
88
|
+
if (meta.revision !== undefined || facts.contract_id) {
|
|
89
|
+
sections.push(`【预检记录】STATE revision ${String(meta.revision ?? "未知")} · 环境体检通过 · 交接单校验${meta.handoffProvided ? "通过" : "不适用"} · 合同 ${String(facts.contract_id ?? "无")}`);
|
|
90
|
+
}
|
|
91
|
+
return sections.join("\n");
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
let report = typeof handoff.carry?.agent_report === "string" ? handoff.carry.agent_report : "";
|
|
95
|
+
let notes = handoff.carry?.user_notes?.flatMap(note => typeof note === "string" ? [note] : []) ?? [];
|
|
96
|
+
let result = render(report, notes);
|
|
97
|
+
if (result.length > 6000 && report) {
|
|
98
|
+
report = truncate(report, Math.max(0, report.length - (result.length - 6000)));
|
|
99
|
+
result = render(report, notes);
|
|
100
|
+
}
|
|
101
|
+
while (result.length > 6000 && notes.length > 0) {
|
|
102
|
+
const last = notes.at(-1)!;
|
|
103
|
+
const excess = result.length - 6000;
|
|
104
|
+
if (last.length > excess + 1) notes = [...notes.slice(0, -1), truncate(last, last.length - excess)];
|
|
105
|
+
else notes = notes.slice(0, -1);
|
|
106
|
+
result = render(report, notes);
|
|
107
|
+
}
|
|
108
|
+
return result.length <= 6000 ? result : truncate(result, 6000);
|
|
109
|
+
}
|