workflow-loop 0.1.0__py3-none-any.whl
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.
- workflow_loop/__init__.py +6 -0
- workflow_loop/acceptance_records.py +338 -0
- workflow_loop/artifact_paths.py +278 -0
- workflow_loop/artifact_validation.py +1738 -0
- workflow_loop/bug_record.py +203 -0
- workflow_loop/cli.py +3257 -0
- workflow_loop/data/Standardized_Repository/acceptance/acceptance.md +119 -0
- workflow_loop/data/Standardized_Repository/acceptance/acceptance_plan.md +105 -0
- workflow_loop/data/Standardized_Repository/code_design/code_design.md +204 -0
- workflow_loop/data/Standardized_Repository/code_design/project_design_init.md +152 -0
- workflow_loop/data/Standardized_Repository/code_design/revise_code_design.md +32 -0
- workflow_loop/data/Standardized_Repository/code_design/update_code_design.md +94 -0
- workflow_loop/data/Standardized_Repository/global/document_writing.md +77 -0
- workflow_loop/data/Standardized_Repository/global/workflow_lifecycle.md +91 -0
- workflow_loop/data/Standardized_Repository/impl/code_implementation.md +85 -0
- workflow_loop/data/Standardized_Repository/impl/impl.md +164 -0
- workflow_loop/data/Standardized_Repository/qa/test.md +167 -0
- workflow_loop/data/Standardized_Repository/qa/test_code.md +121 -0
- workflow_loop/data/Standardized_Repository/qa/test_code_implementation.md +67 -0
- workflow_loop/data/Standardized_Repository/qa/test_plan.md +160 -0
- workflow_loop/data/Standardized_Repository/reproduce/reproduce.md +60 -0
- workflow_loop/data/Standardized_Repository/spec/spec.md +138 -0
- workflow_loop/data/Standardized_Repository/spike/spike.md +236 -0
- workflow_loop/data/Template_Repository/acceptance/acceptance_plan.md +142 -0
- workflow_loop/data/Template_Repository/acceptance/acceptance_result.md +108 -0
- workflow_loop/data/Template_Repository/code_design/code_design.md +260 -0
- workflow_loop/data/Template_Repository/code_design/project_design_init_evidence.md +39 -0
- workflow_loop/data/Template_Repository/impl/impl.md +112 -0
- workflow_loop/data/Template_Repository/qa/test.md +102 -0
- workflow_loop/data/Template_Repository/qa/test_plan.md +100 -0
- workflow_loop/data/Template_Repository/reproduce/reproduce.md +82 -0
- workflow_loop/data/Template_Repository/spec/spec.md +222 -0
- workflow_loop/data/Template_Repository/spike/spike.md +135 -0
- workflow_loop/installer.py +632 -0
- workflow_loop/journal.py +78 -0
- workflow_loop/path_composer.py +152 -0
- workflow_loop/process_runner.py +176 -0
- workflow_loop/project.py +397 -0
- workflow_loop/role_doc.py +133 -0
- workflow_loop/rollback.py +1738 -0
- workflow_loop/spike_validation.py +379 -0
- workflow_loop/stage_materials.py +169 -0
- workflow_loop/stages/__init__.py +45 -0
- workflow_loop/stages/base.py +164 -0
- workflow_loop/stages/stages.py +1191 -0
- workflow_loop/state.py +582 -0
- workflow_loop/test_entry.py +123 -0
- workflow_loop/test_execution.py +619 -0
- workflow_loop/test_mapping.py +568 -0
- workflow_loop/test_runner.py +134 -0
- workflow_loop/topic.py +114 -0
- workflow_loop/topic_relations.py +202 -0
- workflow_loop/traceability.py +533 -0
- workflow_loop/verification.py +971 -0
- workflow_loop-0.1.0.dist-info/METADATA +187 -0
- workflow_loop-0.1.0.dist-info/RECORD +60 -0
- workflow_loop-0.1.0.dist-info/WHEEL +5 -0
- workflow_loop-0.1.0.dist-info/entry_points.txt +2 -0
- workflow_loop-0.1.0.dist-info/licenses/LICENSE +21 -0
- workflow_loop-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""测试计划、测试代码标识和自动化范围的对应关系。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from .topic import topic_paths
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
TEST_METHODS = {
|
|
14
|
+
"自动化测试",
|
|
15
|
+
"人工验收",
|
|
16
|
+
"自动化测试 + 人工验收",
|
|
17
|
+
}
|
|
18
|
+
AUTOMATED_TEST_METHODS = {
|
|
19
|
+
"自动化测试",
|
|
20
|
+
"自动化测试 + 人工验收",
|
|
21
|
+
}
|
|
22
|
+
TEST_LEVELS = {
|
|
23
|
+
"单元测试",
|
|
24
|
+
"模块测试",
|
|
25
|
+
"集成测试",
|
|
26
|
+
"命令测试",
|
|
27
|
+
"接口测试",
|
|
28
|
+
"端到端测试",
|
|
29
|
+
}
|
|
30
|
+
TEST_SOURCE_SUFFIXES = {
|
|
31
|
+
".c", ".cc", ".cpp", ".cxx", ".java", ".kt", ".kts",
|
|
32
|
+
".py", ".go", ".rs", ".swift", ".m", ".mm", ".js", ".jsx",
|
|
33
|
+
".ts", ".tsx", ".vue", ".svelte", ".rb", ".php", ".cs", ".fs",
|
|
34
|
+
".ets", ".qml", ".sh", ".bash", ".zsh", ".bats",
|
|
35
|
+
}
|
|
36
|
+
EXCLUDED_DIRS = {
|
|
37
|
+
".git", ".workflow_loop", ".venv", "node_modules", "__pycache__",
|
|
38
|
+
".pytest_cache", "dist", "build",
|
|
39
|
+
}
|
|
40
|
+
TEST_DIRECTORY_NAMES = {
|
|
41
|
+
"tests", "test", "__tests__", "testdata", "test_data",
|
|
42
|
+
"integration_tests", "e2e",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
AC_LINK_RE = re.compile(r"\[(AC-\d{2,})[::]\s*([^\]]+)\]\([^)]+\)")
|
|
46
|
+
TC_LINK_RE = re.compile(
|
|
47
|
+
r"\[(TC-\d{2,})\s+([^\]]+)\]\(#(tc-\d{2,})\)"
|
|
48
|
+
)
|
|
49
|
+
ID_AND_NAME_RE = re.compile(r"^((?:AC|TC)-\d{2,})\s+(.+?)\s*$")
|
|
50
|
+
MARKER_FIELDS = (
|
|
51
|
+
"主题",
|
|
52
|
+
"测试项",
|
|
53
|
+
"验收条件",
|
|
54
|
+
"测试方式",
|
|
55
|
+
"测试层级",
|
|
56
|
+
"测试目标",
|
|
57
|
+
"测试入口",
|
|
58
|
+
"代码入口",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class TestPlanItem:
|
|
64
|
+
topic: str
|
|
65
|
+
criterion_id: str
|
|
66
|
+
criterion_name: str
|
|
67
|
+
test_id: str
|
|
68
|
+
test_name: str
|
|
69
|
+
test_method: str
|
|
70
|
+
dependencies: tuple[str, ...] = ()
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def requires_test_code(self) -> bool:
|
|
74
|
+
return self.test_method in AUTOMATED_TEST_METHODS
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class WorkflowTestMarker:
|
|
79
|
+
path: str
|
|
80
|
+
line: int
|
|
81
|
+
topic: str
|
|
82
|
+
test_id: str
|
|
83
|
+
test_name: str
|
|
84
|
+
criterion_id: str
|
|
85
|
+
criterion_name: str
|
|
86
|
+
test_method: str
|
|
87
|
+
test_level: str
|
|
88
|
+
test_target: str
|
|
89
|
+
test_entry: str
|
|
90
|
+
code_entry: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _read_text(project_root: str, relative_path: str) -> str:
|
|
94
|
+
with open(os.path.join(project_root, relative_path), "r", encoding="utf-8") as stream:
|
|
95
|
+
return stream.read()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _section(content: str, heading: str) -> str | None:
|
|
99
|
+
match = re.search(
|
|
100
|
+
rf"^##\s+{re.escape(heading)}\s*$\n(.*?)(?=^##\s+|\Z)",
|
|
101
|
+
content,
|
|
102
|
+
re.MULTILINE | re.DOTALL,
|
|
103
|
+
)
|
|
104
|
+
return match.group(1).strip() if match else None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _table_rows(content: str) -> tuple[list[str], list[list[str]]]:
|
|
108
|
+
lines = content.splitlines()
|
|
109
|
+
for header_index, line in enumerate(lines):
|
|
110
|
+
stripped = line.strip()
|
|
111
|
+
if not stripped.startswith("|") or not stripped.endswith("|"):
|
|
112
|
+
continue
|
|
113
|
+
headers = [cell.strip() for cell in stripped.strip("|").split("|")]
|
|
114
|
+
if "验收条件链接" not in headers or "测试项" not in headers:
|
|
115
|
+
continue
|
|
116
|
+
rows: list[list[str]] = []
|
|
117
|
+
for row_line in lines[header_index + 1 :]:
|
|
118
|
+
row_stripped = row_line.strip()
|
|
119
|
+
if not row_stripped.startswith("|") or not row_stripped.endswith("|"):
|
|
120
|
+
if rows:
|
|
121
|
+
break
|
|
122
|
+
continue
|
|
123
|
+
cells = [cell.strip() for cell in row_stripped.strip("|").split("|")]
|
|
124
|
+
if all(re.fullmatch(r"[-:]+", cell) for cell in cells):
|
|
125
|
+
continue
|
|
126
|
+
if len(cells) != len(headers):
|
|
127
|
+
raise ValueError("验收条件覆盖表的数据列数与表头不一致")
|
|
128
|
+
rows.append(cells)
|
|
129
|
+
return headers, rows
|
|
130
|
+
raise ValueError("缺少验收条件覆盖表")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_test_plan_items(project_root: str, topic: str) -> list[TestPlanItem]:
|
|
134
|
+
"""读取一个主题测试计划中的 AC、TC 和测试方式。"""
|
|
135
|
+
|
|
136
|
+
relative_path = topic_paths(project_root, topic)["test_plan"]
|
|
137
|
+
full_path = os.path.join(project_root, relative_path)
|
|
138
|
+
if not os.path.isfile(full_path):
|
|
139
|
+
raise ValueError(f"缺少测试计划文档: {relative_path}")
|
|
140
|
+
|
|
141
|
+
coverage = _section(_read_text(project_root, relative_path), "1. 验收条件覆盖")
|
|
142
|
+
if coverage is None:
|
|
143
|
+
raise ValueError(f"{relative_path} 缺少“1. 验收条件覆盖”")
|
|
144
|
+
headers, rows = _table_rows(coverage)
|
|
145
|
+
required_headers = [
|
|
146
|
+
"验收条件链接",
|
|
147
|
+
"测试项",
|
|
148
|
+
"前置测试项",
|
|
149
|
+
"测试方式",
|
|
150
|
+
"验证方向",
|
|
151
|
+
"预期观察结果",
|
|
152
|
+
"证据要求",
|
|
153
|
+
]
|
|
154
|
+
if headers != required_headers:
|
|
155
|
+
raise ValueError(f"{relative_path} 的验收条件覆盖表头必须是 {required_headers}")
|
|
156
|
+
|
|
157
|
+
items: list[TestPlanItem] = []
|
|
158
|
+
seen_test_ids: set[str] = set()
|
|
159
|
+
for cells in rows:
|
|
160
|
+
criterion_match = AC_LINK_RE.search(cells[0])
|
|
161
|
+
if criterion_match is None:
|
|
162
|
+
raise ValueError(f"{relative_path} 的验收条件必须同时写编号、名称和链接")
|
|
163
|
+
test_match = TC_LINK_RE.search(cells[1])
|
|
164
|
+
if test_match is None:
|
|
165
|
+
raise ValueError(f"{relative_path} 的测试项必须同时写编号、名称和锚点")
|
|
166
|
+
test_id, test_name, anchor_id = test_match.groups()
|
|
167
|
+
if test_id.lower() != anchor_id.lower():
|
|
168
|
+
raise ValueError(f"{relative_path} 的测试项 {test_id} 锚点必须是 #{test_id.lower()}")
|
|
169
|
+
if test_id in seen_test_ids:
|
|
170
|
+
raise ValueError(f"{relative_path} 的测试项 {test_id} 只能对应一条主要验收条件")
|
|
171
|
+
dependency_cell = cells[2].strip()
|
|
172
|
+
dependencies = _parse_dependencies(dependency_cell, relative_path, test_id)
|
|
173
|
+
test_method = cells[3].strip()
|
|
174
|
+
if test_method not in TEST_METHODS:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"{relative_path} 的测试项 {test_id} 测试方式必须是 {sorted(TEST_METHODS)}"
|
|
177
|
+
)
|
|
178
|
+
seen_test_ids.add(test_id)
|
|
179
|
+
items.append(
|
|
180
|
+
TestPlanItem(
|
|
181
|
+
topic=topic,
|
|
182
|
+
criterion_id=criterion_match.group(1),
|
|
183
|
+
criterion_name=criterion_match.group(2).strip(),
|
|
184
|
+
test_id=test_id,
|
|
185
|
+
test_name=test_name.strip(),
|
|
186
|
+
test_method=test_method,
|
|
187
|
+
dependencies=dependencies,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
if not items:
|
|
191
|
+
raise ValueError(f"{relative_path} 没有测试项")
|
|
192
|
+
_validate_dependencies(items, relative_path)
|
|
193
|
+
return items
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse_dependencies(value: str, relative_path: str, test_id: str) -> tuple[str, ...]:
|
|
197
|
+
"""解析同一主题内的直接前置测试项。"""
|
|
198
|
+
if value in {"", "无", "暂无"}:
|
|
199
|
+
return ()
|
|
200
|
+
dependencies = tuple(
|
|
201
|
+
part.strip()
|
|
202
|
+
for part in re.split(r"[,,、\s]+", value)
|
|
203
|
+
if part.strip()
|
|
204
|
+
)
|
|
205
|
+
if any(not re.fullmatch(r"TC-\d{2,}", dependency) for dependency in dependencies):
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"{relative_path} 的测试项 {test_id} 的前置测试项必须写 TC 编号,多个编号用逗号或顿号分隔"
|
|
208
|
+
)
|
|
209
|
+
if test_id in dependencies:
|
|
210
|
+
raise ValueError(f"{relative_path} 的测试项 {test_id} 不能依赖自己")
|
|
211
|
+
return dependencies
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _validate_dependencies(items: list[TestPlanItem], relative_path: str) -> None:
|
|
215
|
+
"""检查依赖引用存在且没有循环,避免执行顺序不确定。"""
|
|
216
|
+
known = {item.test_id for item in items}
|
|
217
|
+
item_by_id = {item.test_id: item for item in items}
|
|
218
|
+
dependencies = {item.test_id: item.dependencies for item in items}
|
|
219
|
+
for test_id, refs in dependencies.items():
|
|
220
|
+
unknown = sorted(set(refs) - known)
|
|
221
|
+
if unknown:
|
|
222
|
+
raise ValueError(
|
|
223
|
+
f"{relative_path} 的测试项 {test_id} 引用了不存在的前置测试项: {unknown}"
|
|
224
|
+
)
|
|
225
|
+
if item_by_id[test_id].requires_test_code:
|
|
226
|
+
manual_dependencies = [
|
|
227
|
+
dependency
|
|
228
|
+
for dependency in refs
|
|
229
|
+
if item_by_id[dependency].test_method == "人工验收"
|
|
230
|
+
]
|
|
231
|
+
if manual_dependencies:
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"{relative_path} 的自动化测试项 {test_id} 不能依赖人工验收项: "
|
|
234
|
+
f"{manual_dependencies};人工验收发生在主题验收阶段"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
visiting: set[str] = set()
|
|
238
|
+
visited: set[str] = set()
|
|
239
|
+
|
|
240
|
+
def visit(test_id: str) -> None:
|
|
241
|
+
if test_id in visiting:
|
|
242
|
+
raise ValueError(f"{relative_path} 的测试项依赖存在循环")
|
|
243
|
+
if test_id in visited:
|
|
244
|
+
return
|
|
245
|
+
visiting.add(test_id)
|
|
246
|
+
for dependency in dependencies[test_id]:
|
|
247
|
+
visit(dependency)
|
|
248
|
+
visiting.remove(test_id)
|
|
249
|
+
visited.add(test_id)
|
|
250
|
+
|
|
251
|
+
for test_id in dependencies:
|
|
252
|
+
visit(test_id)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def collect_test_plan_items(project_root: str, topics: list[str]) -> list[TestPlanItem]:
|
|
256
|
+
items: list[TestPlanItem] = []
|
|
257
|
+
for topic in topics:
|
|
258
|
+
items.extend(parse_test_plan_items(project_root, topic))
|
|
259
|
+
return items
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def automated_test_items(project_root: str, topics: list[str]) -> list[TestPlanItem]:
|
|
263
|
+
return [item for item in collect_test_plan_items(project_root, topics) if item.requires_test_code]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def automated_topics(project_root: str, topics: list[str]) -> list[str]:
|
|
267
|
+
return [
|
|
268
|
+
topic
|
|
269
|
+
for topic in topics
|
|
270
|
+
if any(item.requires_test_code for item in parse_test_plan_items(project_root, topic))
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def topic_requires_test_code(project_root: str, topic: str) -> bool:
|
|
275
|
+
return any(item.requires_test_code for item in parse_test_plan_items(project_root, topic))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def criterion_requires_test_code(
|
|
279
|
+
project_root: str,
|
|
280
|
+
topic: str,
|
|
281
|
+
criterion_id: str,
|
|
282
|
+
) -> bool:
|
|
283
|
+
"""判断一条 AC 是否至少有一个自动化或混合测试项。"""
|
|
284
|
+
return any(
|
|
285
|
+
item.criterion_id == criterion_id and item.requires_test_code
|
|
286
|
+
for item in parse_test_plan_items(project_root, topic)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _is_test_source(relative_path: str) -> bool:
|
|
291
|
+
normalized = relative_path.replace(os.sep, "/")
|
|
292
|
+
parts = normalized.split("/")
|
|
293
|
+
filename = parts[-1].lower()
|
|
294
|
+
suffix = os.path.splitext(filename)[1]
|
|
295
|
+
if suffix not in TEST_SOURCE_SUFFIXES:
|
|
296
|
+
return False
|
|
297
|
+
stem = os.path.splitext(filename)[0]
|
|
298
|
+
return (
|
|
299
|
+
any(part.lower() in TEST_DIRECTORY_NAMES for part in parts[:-1])
|
|
300
|
+
or filename.startswith(("test_", "tst_"))
|
|
301
|
+
or stem.endswith(("_test", "_spec", ".test", ".spec"))
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _test_source_paths(project_root: str) -> list[str]:
|
|
306
|
+
paths: list[str] = []
|
|
307
|
+
for root, dirs, files in os.walk(project_root):
|
|
308
|
+
dirs[:] = [directory for directory in dirs if directory not in EXCLUDED_DIRS]
|
|
309
|
+
for filename in files:
|
|
310
|
+
relative_path = os.path.relpath(os.path.join(root, filename), project_root)
|
|
311
|
+
if _is_test_source(relative_path):
|
|
312
|
+
paths.append(relative_path)
|
|
313
|
+
return sorted(paths)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _clean_marker_line(line: str) -> str:
|
|
317
|
+
value = line.strip()
|
|
318
|
+
value = re.sub(r"^(?:#|/{2,}|/\*+|\*+)\s*", "", value)
|
|
319
|
+
value = re.sub(r"(?:\*/|\"\"\"|''')\s*$", "", value).strip()
|
|
320
|
+
return value
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _id_and_name(value: str, expected_prefix: str) -> tuple[str, str] | None:
|
|
324
|
+
match = ID_AND_NAME_RE.match(value.strip())
|
|
325
|
+
if match is None or not match.group(1).startswith(expected_prefix):
|
|
326
|
+
return None
|
|
327
|
+
return match.group(1), match.group(2).strip()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# 各语言真实测试定义的识别模式:非 Python 语言的 Workflow-Test 注释必须紧邻
|
|
331
|
+
# 其中之一,才算写在真实测试函数、测试类或测试场景上;游离注释一律拒绝。
|
|
332
|
+
TEST_DEFINITION_PATTERNS = [
|
|
333
|
+
# C / C++ / Qt(GoogleTest、QtTest)
|
|
334
|
+
re.compile(r"\bTEST(?:_[A-Z]+)?\s*\("),
|
|
335
|
+
re.compile(r"\bvoid\s+(?:test|tst_)\w*\s*\("),
|
|
336
|
+
re.compile(r"\bQTEST_(?:MAIN|APPLESS_MAIN|GUILESS_MAIN)\b"),
|
|
337
|
+
# JavaScript / TypeScript(jest、vitest、mocha、playwright)
|
|
338
|
+
re.compile(r"\b(?:it|test|describe)(?:\.\w+)?\s*\("),
|
|
339
|
+
# Go
|
|
340
|
+
re.compile(r"\bfunc\s+Test\w+\s*\("),
|
|
341
|
+
# Rust
|
|
342
|
+
re.compile(r"#\[(?:tokio::)?test\]"),
|
|
343
|
+
# Java / Kotlin(JUnit)
|
|
344
|
+
re.compile(r"@Test\b"),
|
|
345
|
+
# Ruby(rspec / minitest)
|
|
346
|
+
re.compile(r"\b(?:it|describe|def\s+test_)\b"),
|
|
347
|
+
# Shell bats
|
|
348
|
+
re.compile(r"^@test\s"),
|
|
349
|
+
]
|
|
350
|
+
# 标识注释块之后向下查找真实测试定义的行数上限
|
|
351
|
+
DEFINITION_LOOKAHEAD_LINES = 14
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _near_test_definition(lines: list[str], marker_index: int) -> bool:
|
|
355
|
+
"""判断标识注释块之后是否紧跟真实测试定义。"""
|
|
356
|
+
for line in lines[marker_index : marker_index + DEFINITION_LOOKAHEAD_LINES]:
|
|
357
|
+
for pattern in TEST_DEFINITION_PATTERNS:
|
|
358
|
+
if pattern.search(line):
|
|
359
|
+
return True
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _parse_marker_lines(
|
|
364
|
+
lines: list[str],
|
|
365
|
+
relative_path: str,
|
|
366
|
+
*,
|
|
367
|
+
line_offset: int = 0,
|
|
368
|
+
require_comment: bool = False,
|
|
369
|
+
require_definition: bool = False,
|
|
370
|
+
errors: list[str] | None = None,
|
|
371
|
+
) -> list[WorkflowTestMarker]:
|
|
372
|
+
markers: list[WorkflowTestMarker] = []
|
|
373
|
+
for index, line in enumerate(lines):
|
|
374
|
+
if "Workflow-Test" not in line:
|
|
375
|
+
continue
|
|
376
|
+
if require_comment and re.match(r"^\s*(?:#|/{2,}|/\*+|\*+|--|<!--)", line) is None:
|
|
377
|
+
# 字符串或普通代码中的 Workflow-Test 字样不是追踪标识
|
|
378
|
+
continue
|
|
379
|
+
if require_definition and not _near_test_definition(lines, index + 1):
|
|
380
|
+
if errors is not None:
|
|
381
|
+
errors.append(
|
|
382
|
+
f"{relative_path}:{line_offset + index + 1} Workflow-Test 标识没有紧邻"
|
|
383
|
+
"真实测试函数、测试类或测试场景(游离注释不能作为正式登记入口)"
|
|
384
|
+
)
|
|
385
|
+
continue
|
|
386
|
+
fields: dict[str, str] = {}
|
|
387
|
+
for raw_line in lines[index + 1 : index + 12]:
|
|
388
|
+
cleaned = _clean_marker_line(raw_line)
|
|
389
|
+
if "Workflow-Test" in cleaned:
|
|
390
|
+
break
|
|
391
|
+
for label in MARKER_FIELDS:
|
|
392
|
+
prefix = f"{label}:"
|
|
393
|
+
if cleaned.startswith(prefix):
|
|
394
|
+
fields[label] = cleaned[len(prefix) :].strip()
|
|
395
|
+
break
|
|
396
|
+
test_value = _id_and_name(fields.get("测试项", ""), "TC-")
|
|
397
|
+
criterion_value = _id_and_name(fields.get("验收条件", ""), "AC-")
|
|
398
|
+
if test_value is None or criterion_value is None:
|
|
399
|
+
if errors is not None:
|
|
400
|
+
errors.append(
|
|
401
|
+
f"{relative_path}:{line_offset + index + 1} Workflow-Test 标识缺少"
|
|
402
|
+
"完整的测试项(TC-xx 名称)或验收条件(AC-xx 名称)"
|
|
403
|
+
)
|
|
404
|
+
continue
|
|
405
|
+
markers.append(
|
|
406
|
+
WorkflowTestMarker(
|
|
407
|
+
path=relative_path,
|
|
408
|
+
line=line_offset + index + 1,
|
|
409
|
+
topic=fields.get("主题", ""),
|
|
410
|
+
test_id=test_value[0],
|
|
411
|
+
test_name=test_value[1],
|
|
412
|
+
criterion_id=criterion_value[0],
|
|
413
|
+
criterion_name=criterion_value[1],
|
|
414
|
+
test_method=fields.get("测试方式", ""),
|
|
415
|
+
test_level=fields.get("测试层级", ""),
|
|
416
|
+
test_target=fields.get("测试目标", ""),
|
|
417
|
+
test_entry=fields.get("测试入口", ""),
|
|
418
|
+
code_entry=fields.get("代码入口", ""),
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
return markers
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _parse_python_markers(
|
|
425
|
+
content: str,
|
|
426
|
+
relative_path: str,
|
|
427
|
+
errors: list[str] | None = None,
|
|
428
|
+
) -> list[WorkflowTestMarker]:
|
|
429
|
+
"""Python 只接受真实测试函数或测试类的文档字符串。"""
|
|
430
|
+
try:
|
|
431
|
+
tree = ast.parse(content)
|
|
432
|
+
except SyntaxError:
|
|
433
|
+
return []
|
|
434
|
+
markers: list[WorkflowTestMarker] = []
|
|
435
|
+
for node in ast.walk(tree):
|
|
436
|
+
is_test_node = (
|
|
437
|
+
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
438
|
+
and node.name.startswith("test")
|
|
439
|
+
) or (
|
|
440
|
+
isinstance(node, ast.ClassDef)
|
|
441
|
+
and node.name.startswith("Test")
|
|
442
|
+
)
|
|
443
|
+
if not is_test_node or not node.body:
|
|
444
|
+
continue
|
|
445
|
+
first_statement = node.body[0]
|
|
446
|
+
if not (
|
|
447
|
+
isinstance(first_statement, ast.Expr)
|
|
448
|
+
and isinstance(first_statement.value, ast.Constant)
|
|
449
|
+
and isinstance(first_statement.value.value, str)
|
|
450
|
+
):
|
|
451
|
+
continue
|
|
452
|
+
docstring = first_statement.value.value
|
|
453
|
+
if "Workflow-Test" not in docstring:
|
|
454
|
+
continue
|
|
455
|
+
parsed = _parse_marker_lines(
|
|
456
|
+
docstring.splitlines(),
|
|
457
|
+
relative_path,
|
|
458
|
+
line_offset=first_statement.lineno - 1,
|
|
459
|
+
errors=errors,
|
|
460
|
+
)
|
|
461
|
+
if len(parsed) > 1:
|
|
462
|
+
raise ValueError(
|
|
463
|
+
f"{relative_path}:{first_statement.lineno} 一个 Python 测试函数或测试类只能写一个 Workflow-Test 标识"
|
|
464
|
+
)
|
|
465
|
+
markers.extend(parsed)
|
|
466
|
+
return markers
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _parse_markers_from_file(
|
|
470
|
+
project_root: str,
|
|
471
|
+
relative_path: str,
|
|
472
|
+
errors: list[str] | None = None,
|
|
473
|
+
) -> list[WorkflowTestMarker]:
|
|
474
|
+
content = _read_text(project_root, relative_path)
|
|
475
|
+
if relative_path.lower().endswith(".py"):
|
|
476
|
+
return _parse_python_markers(content, relative_path, errors)
|
|
477
|
+
return _parse_marker_lines(
|
|
478
|
+
content.splitlines(),
|
|
479
|
+
relative_path,
|
|
480
|
+
require_comment=True,
|
|
481
|
+
require_definition=True,
|
|
482
|
+
errors=errors,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _has_real_text(value: str) -> bool:
|
|
487
|
+
normalized = value.strip()
|
|
488
|
+
return bool(normalized) and "<" not in normalized and "todo" not in normalized.lower()
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def validate_workflow_test_markers(
|
|
492
|
+
project_root: str,
|
|
493
|
+
topics: list[str],
|
|
494
|
+
) -> tuple[bool, str]:
|
|
495
|
+
expected_items = automated_test_items(project_root, topics)
|
|
496
|
+
if not expected_items:
|
|
497
|
+
return True, "当前工作流没有自动化测试项,无需 Workflow-Test 标识"
|
|
498
|
+
|
|
499
|
+
markers: list[WorkflowTestMarker] = []
|
|
500
|
+
marker_errors: list[str] = []
|
|
501
|
+
try:
|
|
502
|
+
for relative_path in _test_source_paths(project_root):
|
|
503
|
+
markers.extend(
|
|
504
|
+
_parse_markers_from_file(project_root, relative_path, marker_errors)
|
|
505
|
+
)
|
|
506
|
+
except ValueError as exc:
|
|
507
|
+
return False, str(exc)
|
|
508
|
+
if marker_errors:
|
|
509
|
+
return False, ";".join(marker_errors)
|
|
510
|
+
|
|
511
|
+
# 当前主题的每个标识都必须完整对应现有测试项;未知测试项和错误名称一律拒绝
|
|
512
|
+
expected_by_key = {(item.topic, item.test_id): item for item in expected_items}
|
|
513
|
+
for marker in markers:
|
|
514
|
+
if marker.topic in topics and (marker.topic, marker.test_id) not in expected_by_key:
|
|
515
|
+
return (
|
|
516
|
+
False,
|
|
517
|
+
f"{marker.path}:{marker.line} 标识引用了当前测试计划中不存在的测试项: "
|
|
518
|
+
f"{marker.topic} / {marker.test_id}",
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
for item in expected_items:
|
|
522
|
+
candidates = [
|
|
523
|
+
marker
|
|
524
|
+
for marker in markers
|
|
525
|
+
if marker.topic == item.topic and marker.test_id == item.test_id
|
|
526
|
+
]
|
|
527
|
+
if not candidates:
|
|
528
|
+
return (
|
|
529
|
+
False,
|
|
530
|
+
f"测试代码缺少 Workflow-Test 标识: {item.topic} / {item.test_id} {item.test_name}",
|
|
531
|
+
)
|
|
532
|
+
errors = []
|
|
533
|
+
for marker in candidates:
|
|
534
|
+
marker_errors = []
|
|
535
|
+
if marker.test_name != item.test_name:
|
|
536
|
+
marker_errors.append(f"测试项名称应为“{item.test_name}”")
|
|
537
|
+
if marker.criterion_id != item.criterion_id:
|
|
538
|
+
marker_errors.append(f"验收条件应为 {item.criterion_id}")
|
|
539
|
+
if marker.criterion_name != item.criterion_name:
|
|
540
|
+
marker_errors.append(f"验收条件名称应为“{item.criterion_name}”")
|
|
541
|
+
if marker.test_method != item.test_method:
|
|
542
|
+
marker_errors.append(f"测试方式应为“{item.test_method}”")
|
|
543
|
+
if marker.test_level not in TEST_LEVELS:
|
|
544
|
+
marker_errors.append(f"测试层级必须是 {sorted(TEST_LEVELS)}")
|
|
545
|
+
if not _has_real_text(marker.test_target):
|
|
546
|
+
marker_errors.append("测试目标缺少具体内容")
|
|
547
|
+
if not _has_real_text(marker.test_entry):
|
|
548
|
+
marker_errors.append("测试入口缺少具体内容")
|
|
549
|
+
if not _has_real_text(marker.code_entry):
|
|
550
|
+
marker_errors.append("代码入口缺少具体内容")
|
|
551
|
+
if marker_errors:
|
|
552
|
+
errors.append(f"{marker.path}:{marker.line} " + ";".join(marker_errors))
|
|
553
|
+
# 同一测试项的多个标识必须全部完整正确;部分有效的重复标识不能被忽略
|
|
554
|
+
if errors:
|
|
555
|
+
return False, f"{item.topic} / {item.test_id} 的标识不完整: {';'.join(errors)}"
|
|
556
|
+
|
|
557
|
+
return True, f"{len(expected_items)} 个自动化测试项都有完整 Workflow-Test 标识"
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def collect_workflow_test_markers(
|
|
561
|
+
project_root: str,
|
|
562
|
+
topics: list[str],
|
|
563
|
+
) -> list[WorkflowTestMarker]:
|
|
564
|
+
"""返回当前主题的全部 Workflow-Test 标识,供执行登记读取测试入口。"""
|
|
565
|
+
markers: list[WorkflowTestMarker] = []
|
|
566
|
+
for relative_path in _test_source_paths(project_root):
|
|
567
|
+
markers.extend(_parse_markers_from_file(project_root, relative_path))
|
|
568
|
+
return [marker for marker in markers if marker.topic in topics]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""最终全量回归的执行。
|
|
2
|
+
|
|
3
|
+
本模块只保留最终全量回归职责:使用项目按平台登记的参数数组入口,
|
|
4
|
+
通过共同的受控进程执行器真实执行一次,并把完整机器事实写入状态。
|
|
5
|
+
修改前全量测试基线已经删除;实施前没有任何全量测试执行路径。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
|
|
12
|
+
from . import process_runner as process_runner_mod
|
|
13
|
+
from . import test_entry as test_entry_mod
|
|
14
|
+
from .project import load_project
|
|
15
|
+
from .state import RegressionTestState, WorkflowState
|
|
16
|
+
from .verification import compute_code_snapshot_hash
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
TEST_TIMEOUT_SECONDS = 30 * 60
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def regression_record_id(
|
|
23
|
+
started_at: str | None,
|
|
24
|
+
finished_at: str | None,
|
|
25
|
+
exit_code: int | None,
|
|
26
|
+
output_sha256: str | None,
|
|
27
|
+
command: list[str],
|
|
28
|
+
) -> str:
|
|
29
|
+
"""根据最终回归机器事实计算稳定记录编号。"""
|
|
30
|
+
payload = (
|
|
31
|
+
f"{started_at}|{finished_at}|{exit_code}|"
|
|
32
|
+
f"{output_sha256}|{' '.join(command)}"
|
|
33
|
+
)
|
|
34
|
+
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:8]
|
|
35
|
+
compact_time = (started_at or "").replace(":", "").replace("-", "")
|
|
36
|
+
return f"REG-{compact_time}-{digest}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_regression_entry(project_root: str) -> tuple[list[str] | None, str]:
|
|
40
|
+
"""按当前操作系统选择项目全量测试入口;返回 (参数数组, 说明)。"""
|
|
41
|
+
project = load_project(project_root)
|
|
42
|
+
raw_config = project.test_entry if project is not None else None
|
|
43
|
+
if not isinstance(raw_config, dict):
|
|
44
|
+
return None, "项目测试入口配置无效"
|
|
45
|
+
errors = test_entry_mod.validate_entry_config(raw_config)
|
|
46
|
+
if errors:
|
|
47
|
+
return None, "项目测试入口配置不合法:" + ";".join(errors)
|
|
48
|
+
argv = test_entry_mod.select_entry(raw_config)
|
|
49
|
+
if not argv:
|
|
50
|
+
return None, (
|
|
51
|
+
f"当前操作系统({test_entry_mod.current_platform_key()})没有可用的"
|
|
52
|
+
"项目全量测试入口;请先在测试计划阶段登记入口配置"
|
|
53
|
+
)
|
|
54
|
+
return argv, f"使用入口 {argv}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run_final_regression(project_root: str, workflow_state: WorkflowState) -> tuple[bool, str]:
|
|
58
|
+
"""在最新完整代码上执行一次项目全量入口,写入完整机器事实。"""
|
|
59
|
+
|
|
60
|
+
current_snapshot = compute_code_snapshot_hash(project_root)
|
|
61
|
+
argv, entry_detail = resolve_regression_entry(project_root)
|
|
62
|
+
if argv is None:
|
|
63
|
+
workflow_state.regression_test = RegressionTestState(
|
|
64
|
+
status="unavailable",
|
|
65
|
+
timeout_seconds=TEST_TIMEOUT_SECONDS,
|
|
66
|
+
output_tail=entry_detail,
|
|
67
|
+
code_snapshot_hash=current_snapshot,
|
|
68
|
+
)
|
|
69
|
+
return False, f"最终全量回归无法执行: {entry_detail}"
|
|
70
|
+
|
|
71
|
+
result = process_runner_mod.run_process(
|
|
72
|
+
process_runner_mod.ProcessRequest(
|
|
73
|
+
argv=argv,
|
|
74
|
+
cwd=project_root,
|
|
75
|
+
timeout_seconds=TEST_TIMEOUT_SECONDS,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
state = RegressionTestState(
|
|
79
|
+
entry=argv,
|
|
80
|
+
command=result.argv,
|
|
81
|
+
cwd="",
|
|
82
|
+
timeout_seconds=TEST_TIMEOUT_SECONDS,
|
|
83
|
+
started_at=result.started_at,
|
|
84
|
+
finished_at=result.finished_at,
|
|
85
|
+
duration_seconds=result.duration_seconds,
|
|
86
|
+
status=result.status,
|
|
87
|
+
exit_code=result.exit_code,
|
|
88
|
+
code_snapshot_hash=current_snapshot,
|
|
89
|
+
output_tail=result.output_tail,
|
|
90
|
+
output_sha256=result.output_sha256,
|
|
91
|
+
output_bytes=result.output_bytes,
|
|
92
|
+
platform=result.platform,
|
|
93
|
+
executable=result.executable,
|
|
94
|
+
)
|
|
95
|
+
state.record_id = regression_record_id(
|
|
96
|
+
result.started_at,
|
|
97
|
+
result.finished_at,
|
|
98
|
+
result.exit_code,
|
|
99
|
+
result.output_sha256,
|
|
100
|
+
result.argv,
|
|
101
|
+
)
|
|
102
|
+
workflow_state.regression_test = state
|
|
103
|
+
|
|
104
|
+
if result.status == "passed":
|
|
105
|
+
return True, f"最终全量测试通过: {argv};退出码 0;机器记录 {state.record_id}"
|
|
106
|
+
return False, (
|
|
107
|
+
f"最终全量测试未通过: {argv};状态 {result.status};"
|
|
108
|
+
f"退出码 {result.exit_code if result.exit_code is not None else '无'};"
|
|
109
|
+
f"输出摘要: {result.output_tail[-800:]}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def regression_journal_fields(workflow_state: WorkflowState) -> dict:
|
|
114
|
+
"""返回最终回归状态,供 journal 记录。"""
|
|
115
|
+
|
|
116
|
+
result = workflow_state.regression_test
|
|
117
|
+
return {
|
|
118
|
+
"entry": result.entry,
|
|
119
|
+
"command": result.command,
|
|
120
|
+
"cwd": result.cwd,
|
|
121
|
+
"timeout_seconds": result.timeout_seconds,
|
|
122
|
+
"started_at": result.started_at,
|
|
123
|
+
"finished_at": result.finished_at,
|
|
124
|
+
"duration_seconds": result.duration_seconds,
|
|
125
|
+
"status": result.status,
|
|
126
|
+
"exit_code": result.exit_code,
|
|
127
|
+
"code_snapshot_hash": result.code_snapshot_hash,
|
|
128
|
+
"record_id": result.record_id,
|
|
129
|
+
"output_sha256": result.output_sha256,
|
|
130
|
+
"output_bytes": result.output_bytes,
|
|
131
|
+
"platform": result.platform,
|
|
132
|
+
"executable": result.executable,
|
|
133
|
+
"output_tail": result.output_tail,
|
|
134
|
+
}
|