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,619 @@
|
|
|
1
|
+
"""测试执行阶段的任务登记、依赖调度和安全子进程执行。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from . import artifact_paths as artifact_paths_mod
|
|
12
|
+
from . import journal as journal_mod
|
|
13
|
+
from . import process_runner as process_runner_mod
|
|
14
|
+
from . import state as state_mod
|
|
15
|
+
from . import test_mapping
|
|
16
|
+
from . import traceability as traceability_mod
|
|
17
|
+
from . import verification
|
|
18
|
+
from .project import DEFAULT_TEST_PARALLELISM, load_project
|
|
19
|
+
from .state import TestExecutionRecord, TestTaskState, WorkflowState, now_iso
|
|
20
|
+
from .topic import topic_paths
|
|
21
|
+
from .topic_relations import read_topic_index
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
FORBIDDEN_SHELL_TOKENS = ("|", "&&", ";", ">", "<", "$(", "`")
|
|
25
|
+
INLINE_CODE_FLAGS = {"-c", "-e", "--eval", "--evaluate"}
|
|
26
|
+
DEFAULT_TIMEOUT_SECONDS = 600
|
|
27
|
+
SAFE_ENVIRONMENT_KEYS = {
|
|
28
|
+
"CI",
|
|
29
|
+
"GITHUB_ACTIONS",
|
|
30
|
+
"PYTHON_VERSION",
|
|
31
|
+
"NODE_ENV",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ExecutionAttempt:
|
|
37
|
+
topic: str
|
|
38
|
+
test_id: str
|
|
39
|
+
status: str
|
|
40
|
+
command: list[str]
|
|
41
|
+
started_at: str
|
|
42
|
+
finished_at: str
|
|
43
|
+
duration_seconds: float
|
|
44
|
+
exit_code: int | None
|
|
45
|
+
output_tail: str
|
|
46
|
+
error: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def normalize_task_cwd(project_root: str, cwd: str | None) -> str:
|
|
50
|
+
"""校验并规范化项目内工作目录;返回相对项目根的路径(空串表示项目根)。"""
|
|
51
|
+
if not cwd or cwd in (".", "./"):
|
|
52
|
+
return ""
|
|
53
|
+
candidate = cwd
|
|
54
|
+
if not os.path.isabs(candidate):
|
|
55
|
+
candidate = os.path.join(project_root, candidate)
|
|
56
|
+
project_real = os.path.realpath(project_root)
|
|
57
|
+
candidate_real = os.path.realpath(candidate)
|
|
58
|
+
if os.path.commonpath([project_real, candidate_real]) != project_real:
|
|
59
|
+
raise ValueError(f"测试工作目录必须在项目内: {cwd}")
|
|
60
|
+
if not os.path.isdir(candidate_real):
|
|
61
|
+
raise ValueError(f"测试工作目录不存在: {cwd}")
|
|
62
|
+
relative = os.path.relpath(candidate_real, project_real)
|
|
63
|
+
return "" if relative == "." else relative.replace(os.sep, "/")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_command(argv: list[str]) -> tuple[bool, str]:
|
|
67
|
+
"""检查命令是否能以 shell=False 安全执行。"""
|
|
68
|
+
if not argv:
|
|
69
|
+
return False, "测试命令不能为空"
|
|
70
|
+
for token in argv:
|
|
71
|
+
if not isinstance(token, str) or not token.strip():
|
|
72
|
+
return False, "测试命令参数必须是非空字符串"
|
|
73
|
+
if any(operator in token for operator in FORBIDDEN_SHELL_TOKENS):
|
|
74
|
+
return False, "测试命令不能包含管道、重定向、命令串联或命令替换"
|
|
75
|
+
return True, ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _command_value_tokens(command: list[str]) -> set[str]:
|
|
79
|
+
values = set(command)
|
|
80
|
+
for token in command:
|
|
81
|
+
if "=" in token:
|
|
82
|
+
values.add(token.split("=", 1)[1])
|
|
83
|
+
return values
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _entry_is_selected(entry: str, command_values: set[str]) -> bool:
|
|
87
|
+
normalized_entry = entry.replace("\\", "/")
|
|
88
|
+
parts = normalized_entry.split("::")
|
|
89
|
+
path = parts[0]
|
|
90
|
+
symbol = parts[-1] if len(parts) > 1 else ""
|
|
91
|
+
candidates = {normalized_entry, path}
|
|
92
|
+
if symbol:
|
|
93
|
+
candidates.add(symbol)
|
|
94
|
+
for value in command_values:
|
|
95
|
+
normalized_value = value.replace("\\", "/").rstrip("/")
|
|
96
|
+
if normalized_value in candidates:
|
|
97
|
+
return True
|
|
98
|
+
if normalized_value and path.startswith(normalized_value + "/"):
|
|
99
|
+
return True
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def validate_command_entries(command: list[str], entries: list[str]) -> tuple[bool, str]:
|
|
104
|
+
"""确认登记命令确实选择了当前 TC 的测试入口,而不是任意成功命令。"""
|
|
105
|
+
if any(flag in command for flag in INLINE_CODE_FLAGS):
|
|
106
|
+
return False, "测试命令不能使用 -c、-e 或 --eval 执行临时代码,必须运行真实测试入口"
|
|
107
|
+
command_values = _command_value_tokens(command)
|
|
108
|
+
missing = [entry for entry in entries if not _entry_is_selected(entry, command_values)]
|
|
109
|
+
if missing:
|
|
110
|
+
return False, f"测试命令没有明确选择当前测试入口: {missing}"
|
|
111
|
+
return True, ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def safe_environment() -> dict[str, str]:
|
|
115
|
+
"""只记录少量不含密钥的环境事实,避免把整个环境写进 state.json。"""
|
|
116
|
+
return {
|
|
117
|
+
"platform": sys.platform,
|
|
118
|
+
"python": sys.version.split()[0],
|
|
119
|
+
**{
|
|
120
|
+
key: os.environ[key]
|
|
121
|
+
for key in sorted(SAFE_ENVIRONMENT_KEYS)
|
|
122
|
+
if os.environ.get(key)
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _markers_by_test(project_root: str, topics: list[str]) -> dict[tuple[str, str], list[str]]:
|
|
128
|
+
markers = test_mapping.collect_workflow_test_markers(project_root, topics)
|
|
129
|
+
result: dict[tuple[str, str], list[str]] = {}
|
|
130
|
+
for marker in markers:
|
|
131
|
+
key = (marker.topic, marker.test_id)
|
|
132
|
+
result.setdefault(key, []).append(marker.test_entry)
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def prepare_task(
|
|
137
|
+
project_root: str,
|
|
138
|
+
workflow_state: WorkflowState,
|
|
139
|
+
topic: str,
|
|
140
|
+
test_id: str,
|
|
141
|
+
command: list[str],
|
|
142
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
143
|
+
cwd: str | None = None,
|
|
144
|
+
) -> TestTaskState:
|
|
145
|
+
"""登记一个已经由用户确认过的测试项命令;登记不启动测试。"""
|
|
146
|
+
valid, detail = validate_command(command)
|
|
147
|
+
if not valid:
|
|
148
|
+
raise ValueError(detail)
|
|
149
|
+
if timeout_seconds <= 0:
|
|
150
|
+
raise ValueError("测试超时时间必须大于 0 秒")
|
|
151
|
+
if topic not in workflow_state.topics:
|
|
152
|
+
raise ValueError(f"主题不属于当前工作流: {topic}")
|
|
153
|
+
normalized_cwd = normalize_task_cwd(project_root, cwd)
|
|
154
|
+
|
|
155
|
+
items = test_mapping.parse_test_plan_items(project_root, topic)
|
|
156
|
+
item = next((candidate for candidate in items if candidate.test_id == test_id), None)
|
|
157
|
+
if item is None:
|
|
158
|
+
raise ValueError(f"{topic} 的测试计划没有 {test_id}")
|
|
159
|
+
if not item.requires_test_code:
|
|
160
|
+
raise ValueError(f"{topic} / {test_id} 不是自动化或混合测试项,不需要登记自动化命令")
|
|
161
|
+
|
|
162
|
+
marker_ok, marker_detail = test_mapping.validate_workflow_test_markers(
|
|
163
|
+
project_root,
|
|
164
|
+
[topic],
|
|
165
|
+
)
|
|
166
|
+
if not marker_ok:
|
|
167
|
+
raise ValueError(marker_detail)
|
|
168
|
+
entries = _markers_by_test(project_root, [topic]).get((topic, test_id), [])
|
|
169
|
+
if not entries:
|
|
170
|
+
raise ValueError(f"{topic} / {test_id} 没有可追踪的测试入口")
|
|
171
|
+
entries_ok, entries_detail = validate_command_entries(command, entries)
|
|
172
|
+
if not entries_ok:
|
|
173
|
+
raise ValueError(f"{topic} / {test_id}: {entries_detail}")
|
|
174
|
+
|
|
175
|
+
stage_state = workflow_state.stages.get("test_execution")
|
|
176
|
+
if stage_state is None:
|
|
177
|
+
raise ValueError("当前工作流没有 test_execution 阶段")
|
|
178
|
+
stage_state.test_tasks.setdefault(topic, {})[test_id] = TestTaskState(
|
|
179
|
+
test_entries=sorted(set(entries)),
|
|
180
|
+
command=list(command),
|
|
181
|
+
cwd=normalized_cwd,
|
|
182
|
+
dependencies=list(item.dependencies),
|
|
183
|
+
timeout_seconds=timeout_seconds,
|
|
184
|
+
status="pending",
|
|
185
|
+
prepared_at=now_iso(),
|
|
186
|
+
last_error=None,
|
|
187
|
+
current_record=None,
|
|
188
|
+
)
|
|
189
|
+
return stage_state.test_tasks[topic][test_id]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def missing_prepared_tasks(
|
|
193
|
+
project_root: str,
|
|
194
|
+
workflow_state: WorkflowState,
|
|
195
|
+
) -> list[str]:
|
|
196
|
+
"""返回所有需要自动执行但尚未登记命令的主题/测试项。"""
|
|
197
|
+
missing: list[str] = []
|
|
198
|
+
for item in test_mapping.automated_test_items(project_root, workflow_state.topics):
|
|
199
|
+
task = workflow_state.stages.get("test_execution", state_mod.StageState()).test_tasks.get(
|
|
200
|
+
item.topic,
|
|
201
|
+
{},
|
|
202
|
+
).get(item.test_id)
|
|
203
|
+
if task is None:
|
|
204
|
+
missing.append(f"{item.topic} / {item.test_id}")
|
|
205
|
+
return missing
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def validate_prepared_tasks(
|
|
209
|
+
project_root: str,
|
|
210
|
+
workflow_state: WorkflowState,
|
|
211
|
+
) -> tuple[bool, str]:
|
|
212
|
+
"""核对登记任务与当前测试计划、依赖和 Workflow-Test 入口完全一致。"""
|
|
213
|
+
stage_state = workflow_state.stages.get("test_execution")
|
|
214
|
+
if stage_state is None:
|
|
215
|
+
return False, "当前工作流没有 test_execution 阶段"
|
|
216
|
+
try:
|
|
217
|
+
expected_items = test_mapping.automated_test_items(
|
|
218
|
+
project_root,
|
|
219
|
+
workflow_state.topics,
|
|
220
|
+
)
|
|
221
|
+
marker_ok, marker_detail = test_mapping.validate_workflow_test_markers(
|
|
222
|
+
project_root,
|
|
223
|
+
workflow_state.topics,
|
|
224
|
+
)
|
|
225
|
+
except ValueError as exc:
|
|
226
|
+
return False, str(exc)
|
|
227
|
+
if not marker_ok:
|
|
228
|
+
return False, marker_detail
|
|
229
|
+
|
|
230
|
+
expected = {(item.topic, item.test_id): item for item in expected_items}
|
|
231
|
+
actual = {
|
|
232
|
+
(topic, test_id): task
|
|
233
|
+
for topic, tasks in stage_state.test_tasks.items()
|
|
234
|
+
for test_id, task in tasks.items()
|
|
235
|
+
}
|
|
236
|
+
missing = sorted(set(expected) - set(actual))
|
|
237
|
+
extra = sorted(set(actual) - set(expected))
|
|
238
|
+
if missing:
|
|
239
|
+
return False, f"尚未登记测试命令: {[f'{topic} / {test_id}' for topic, test_id in missing]}"
|
|
240
|
+
if extra:
|
|
241
|
+
return False, f"登记了当前测试计划不存在的测试任务: {extra}"
|
|
242
|
+
|
|
243
|
+
current_entries = _markers_by_test(project_root, workflow_state.topics)
|
|
244
|
+
for key, item in expected.items():
|
|
245
|
+
task = actual[key]
|
|
246
|
+
command_ok, command_detail = validate_command(task.command)
|
|
247
|
+
if not command_ok:
|
|
248
|
+
return False, f"{item.topic} / {item.test_id}: {command_detail}"
|
|
249
|
+
if task.timeout_seconds <= 0:
|
|
250
|
+
return False, f"{item.topic} / {item.test_id} 的超时时间必须大于 0 秒"
|
|
251
|
+
try:
|
|
252
|
+
normalize_task_cwd(project_root, task.cwd or None)
|
|
253
|
+
except ValueError as exc:
|
|
254
|
+
return False, f"{item.topic} / {item.test_id}: {exc}"
|
|
255
|
+
if tuple(task.dependencies) != item.dependencies:
|
|
256
|
+
return False, f"{item.topic} / {item.test_id} 的登记依赖与当前测试计划不一致"
|
|
257
|
+
expected_entries = sorted(set(current_entries.get(key, [])))
|
|
258
|
+
if sorted(set(task.test_entries)) != expected_entries:
|
|
259
|
+
return False, f"{item.topic} / {item.test_id} 的登记测试入口与当前测试代码不一致"
|
|
260
|
+
entries_ok, entries_detail = validate_command_entries(task.command, expected_entries)
|
|
261
|
+
if not entries_ok:
|
|
262
|
+
return False, f"{item.topic} / {item.test_id}: {entries_detail}"
|
|
263
|
+
return True, f"{len(expected)} 个自动化测试项的登记任务与当前计划和测试代码一致"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _record_id_for(attempt_result: process_runner_mod.ProcessResult, topic: str, test_id: str) -> str:
|
|
267
|
+
"""测试机器记录编号:可从结果文档逐字段回查状态快照。"""
|
|
268
|
+
payload = (
|
|
269
|
+
f"{topic}|{test_id}|{attempt_result.started_at}|{attempt_result.finished_at}|"
|
|
270
|
+
f"{attempt_result.exit_code}|{attempt_result.output_sha256}"
|
|
271
|
+
)
|
|
272
|
+
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:8]
|
|
273
|
+
compact_time = (attempt_result.started_at or "").replace(":", "").replace("-", "")
|
|
274
|
+
return f"RUN-{compact_time}-{digest}"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _run_one(
|
|
278
|
+
project_root: str,
|
|
279
|
+
topic: str,
|
|
280
|
+
test_id: str,
|
|
281
|
+
task: TestTaskState,
|
|
282
|
+
code_hash: str,
|
|
283
|
+
test_hash: str,
|
|
284
|
+
) -> tuple[ExecutionAttempt, process_runner_mod.ProcessResult]:
|
|
285
|
+
"""通过共同受控执行器运行一个测试项,返回尝试摘要和完整机器事实。"""
|
|
286
|
+
cwd = os.path.join(project_root, task.cwd) if task.cwd else project_root
|
|
287
|
+
result = process_runner_mod.run_process(
|
|
288
|
+
process_runner_mod.ProcessRequest(
|
|
289
|
+
argv=list(task.command),
|
|
290
|
+
cwd=cwd,
|
|
291
|
+
timeout_seconds=task.timeout_seconds,
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
if result.status == "passed":
|
|
295
|
+
error = None
|
|
296
|
+
elif result.status == "timeout":
|
|
297
|
+
error = result.error_message
|
|
298
|
+
elif result.status == "error":
|
|
299
|
+
error = result.error_message or "启动测试命令失败"
|
|
300
|
+
else:
|
|
301
|
+
error = f"退出码为 {result.exit_code}"
|
|
302
|
+
attempt = ExecutionAttempt(
|
|
303
|
+
topic=topic,
|
|
304
|
+
test_id=test_id,
|
|
305
|
+
status=result.status if result.status != "error" else "unavailable",
|
|
306
|
+
command=list(task.command),
|
|
307
|
+
started_at=result.started_at,
|
|
308
|
+
finished_at=result.finished_at,
|
|
309
|
+
duration_seconds=result.duration_seconds,
|
|
310
|
+
exit_code=result.exit_code,
|
|
311
|
+
output_tail=result.output_tail,
|
|
312
|
+
error=error,
|
|
313
|
+
)
|
|
314
|
+
return attempt, result
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _topic_execution_order(
|
|
318
|
+
project_root: str,
|
|
319
|
+
topic: str,
|
|
320
|
+
tasks: dict[str, TestTaskState],
|
|
321
|
+
) -> list[str]:
|
|
322
|
+
items = {item.test_id: item for item in test_mapping.parse_test_plan_items(project_root, topic)}
|
|
323
|
+
ordered: list[str] = []
|
|
324
|
+
visited: set[str] = set()
|
|
325
|
+
|
|
326
|
+
def visit(test_id: str) -> None:
|
|
327
|
+
if test_id in visited:
|
|
328
|
+
return
|
|
329
|
+
for dependency in items[test_id].dependencies:
|
|
330
|
+
if dependency in tasks:
|
|
331
|
+
visit(dependency)
|
|
332
|
+
visited.add(test_id)
|
|
333
|
+
ordered.append(test_id)
|
|
334
|
+
|
|
335
|
+
for test_id in tasks:
|
|
336
|
+
visit(test_id)
|
|
337
|
+
return ordered
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _has_current_success(task: TestTaskState) -> bool:
|
|
341
|
+
"""判断任务是否已有可继续使用的当前成功记录。"""
|
|
342
|
+
record = task.current_record
|
|
343
|
+
return (
|
|
344
|
+
task.status == "passed"
|
|
345
|
+
and record is not None
|
|
346
|
+
and record.status == "passed"
|
|
347
|
+
and record.exit_code == 0
|
|
348
|
+
and record.command == task.command
|
|
349
|
+
and set(record.test_entries) == set(task.test_entries)
|
|
350
|
+
and bool(record.code_snapshot_hash)
|
|
351
|
+
and bool(record.test_code_hash)
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _topic_prerequisites(
|
|
356
|
+
project_root: str,
|
|
357
|
+
workflow_state: WorkflowState,
|
|
358
|
+
topics: set[str],
|
|
359
|
+
) -> tuple[list[str], dict[str, tuple[str, ...]]]:
|
|
360
|
+
"""读取 qa/索引.md 的主题顺序,只保留本阶段有自动化任务的前置主题。"""
|
|
361
|
+
relations = read_topic_index(
|
|
362
|
+
project_root,
|
|
363
|
+
artifact_paths_mod.QA_INDEX_DOC,
|
|
364
|
+
workflow_state.workflow_id,
|
|
365
|
+
["展示顺序", "验收主题", "前置主题", "验收计划", "测试计划", "测试结果"],
|
|
366
|
+
{"测试结果": {"无自动化测试项"}},
|
|
367
|
+
)
|
|
368
|
+
ordered_topics = [relation.topic for relation in relations if relation.topic in topics]
|
|
369
|
+
missing = sorted(topics - set(ordered_topics))
|
|
370
|
+
if missing:
|
|
371
|
+
raise ValueError(f"{artifact_paths_mod.QA_INDEX_DOC} 缺少需要执行自动化测试的主题: {missing}")
|
|
372
|
+
prerequisites = {
|
|
373
|
+
relation.topic: tuple(
|
|
374
|
+
prerequisite
|
|
375
|
+
for prerequisite in relation.prerequisites
|
|
376
|
+
if prerequisite in topics
|
|
377
|
+
)
|
|
378
|
+
for relation in relations
|
|
379
|
+
if relation.topic in topics
|
|
380
|
+
}
|
|
381
|
+
return ordered_topics, prerequisites
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def run_prepared_tasks(
|
|
385
|
+
project_root: str,
|
|
386
|
+
workflow_state: WorkflowState,
|
|
387
|
+
parallelism: int | None = None,
|
|
388
|
+
) -> list[ExecutionAttempt]:
|
|
389
|
+
"""按主题并行、主题内按 TC 依赖顺序执行已登记任务。"""
|
|
390
|
+
stage_state = workflow_state.stages.get("test_execution")
|
|
391
|
+
if stage_state is None:
|
|
392
|
+
raise ValueError("当前工作流没有 test_execution 阶段")
|
|
393
|
+
tasks_ok, tasks_detail = validate_prepared_tasks(project_root, workflow_state)
|
|
394
|
+
if not tasks_ok:
|
|
395
|
+
raise ValueError(tasks_detail)
|
|
396
|
+
|
|
397
|
+
project = load_project(project_root)
|
|
398
|
+
max_workers = parallelism or (
|
|
399
|
+
project.test_parallelism if project is not None else DEFAULT_TEST_PARALLELISM
|
|
400
|
+
)
|
|
401
|
+
max_workers = max(1, int(max_workers))
|
|
402
|
+
code_hash = verification.compute_non_test_code_snapshot_hash(project_root)
|
|
403
|
+
test_hash = verification.compute_test_code_snapshot_hash(project_root)
|
|
404
|
+
topic_tasks = {
|
|
405
|
+
topic: tasks
|
|
406
|
+
for topic, tasks in stage_state.test_tasks.items()
|
|
407
|
+
if tasks
|
|
408
|
+
}
|
|
409
|
+
ordered_topics, topic_prerequisites = _topic_prerequisites(
|
|
410
|
+
project_root,
|
|
411
|
+
workflow_state,
|
|
412
|
+
set(topic_tasks),
|
|
413
|
+
)
|
|
414
|
+
topics_to_run = [
|
|
415
|
+
topic
|
|
416
|
+
for topic in ordered_topics
|
|
417
|
+
if any(not _has_current_success(task) for task in topic_tasks[topic].values())
|
|
418
|
+
]
|
|
419
|
+
|
|
420
|
+
# 本主题要重新执行时,旧的正式结果已经不能代表本次代码状态。
|
|
421
|
+
if topics_to_run:
|
|
422
|
+
trace_detail = traceability_mod.reset_topic_test_results(
|
|
423
|
+
project_root,
|
|
424
|
+
workflow_state.workflow_id,
|
|
425
|
+
topics_to_run,
|
|
426
|
+
)
|
|
427
|
+
journal_mod.append_entry(
|
|
428
|
+
project_root,
|
|
429
|
+
"主题测试追踪状态重置",
|
|
430
|
+
"workflow.py",
|
|
431
|
+
workflow_id=workflow_state.workflow_id,
|
|
432
|
+
topics=topics_to_run,
|
|
433
|
+
detail=trace_detail,
|
|
434
|
+
)
|
|
435
|
+
for topic in topics_to_run:
|
|
436
|
+
result_path = os.path.join(project_root, topic_paths(project_root, topic)["test_result"])
|
|
437
|
+
if os.path.exists(result_path):
|
|
438
|
+
os.remove(result_path)
|
|
439
|
+
journal_mod.append_entry(
|
|
440
|
+
project_root,
|
|
441
|
+
"主题测试结果失效",
|
|
442
|
+
"workflow.py",
|
|
443
|
+
workflow_id=workflow_state.workflow_id,
|
|
444
|
+
topic=topic,
|
|
445
|
+
reason="主题测试重新执行,旧结果不再代表当前代码",
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
def run_topic(topic: str) -> list[ExecutionAttempt]:
|
|
449
|
+
attempts: list[ExecutionAttempt] = []
|
|
450
|
+
tasks = topic_tasks[topic]
|
|
451
|
+
for test_id in _topic_execution_order(project_root, topic, tasks):
|
|
452
|
+
task = tasks[test_id]
|
|
453
|
+
if _has_current_success(task):
|
|
454
|
+
continue
|
|
455
|
+
if any(
|
|
456
|
+
not _has_current_success(tasks[dependency])
|
|
457
|
+
for dependency in task.dependencies
|
|
458
|
+
if dependency in tasks
|
|
459
|
+
):
|
|
460
|
+
task.status = "blocked"
|
|
461
|
+
task.current_record = None
|
|
462
|
+
task.last_error = "前置测试项没有通过,本测试项未执行"
|
|
463
|
+
attempts.append(
|
|
464
|
+
ExecutionAttempt(
|
|
465
|
+
topic=topic,
|
|
466
|
+
test_id=test_id,
|
|
467
|
+
status="blocked",
|
|
468
|
+
command=list(task.command),
|
|
469
|
+
started_at=now_iso(),
|
|
470
|
+
finished_at=now_iso(),
|
|
471
|
+
duration_seconds=0.0,
|
|
472
|
+
exit_code=None,
|
|
473
|
+
output_tail="",
|
|
474
|
+
error=task.last_error,
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
continue
|
|
478
|
+
attempt, result = _run_one(project_root, topic, test_id, task, code_hash, test_hash)
|
|
479
|
+
attempts.append(attempt)
|
|
480
|
+
if attempt.status == "passed":
|
|
481
|
+
task.status = "passed"
|
|
482
|
+
task.last_error = None
|
|
483
|
+
record = TestExecutionRecord(
|
|
484
|
+
test_entries=list(task.test_entries),
|
|
485
|
+
command=list(task.command),
|
|
486
|
+
cwd=task.cwd,
|
|
487
|
+
timeout_seconds=task.timeout_seconds,
|
|
488
|
+
started_at=attempt.started_at,
|
|
489
|
+
finished_at=attempt.finished_at,
|
|
490
|
+
duration_seconds=attempt.duration_seconds,
|
|
491
|
+
exit_code=attempt.exit_code,
|
|
492
|
+
status="passed",
|
|
493
|
+
environment=safe_environment(),
|
|
494
|
+
code_snapshot_hash=code_hash,
|
|
495
|
+
test_code_hash=test_hash,
|
|
496
|
+
output_tail=result.output_tail,
|
|
497
|
+
output_sha256=result.output_sha256,
|
|
498
|
+
output_bytes=result.output_bytes,
|
|
499
|
+
platform=result.platform,
|
|
500
|
+
executable=result.executable,
|
|
501
|
+
)
|
|
502
|
+
record.record_id = _record_id_for(result, topic, test_id)
|
|
503
|
+
task.current_record = record
|
|
504
|
+
else:
|
|
505
|
+
# 失败、超时或无法启动立即清除当前测试项旧成功
|
|
506
|
+
task.status = "needs_action"
|
|
507
|
+
task.last_error = attempt.error or "测试没有通过"
|
|
508
|
+
task.current_record = None
|
|
509
|
+
return attempts
|
|
510
|
+
|
|
511
|
+
all_attempts: list[ExecutionAttempt] = []
|
|
512
|
+
completed_topics = {
|
|
513
|
+
topic
|
|
514
|
+
for topic, tasks in topic_tasks.items()
|
|
515
|
+
if all(_has_current_success(task) for task in tasks.values())
|
|
516
|
+
}
|
|
517
|
+
failed_topics: set[str] = set()
|
|
518
|
+
remaining = set(topics_to_run)
|
|
519
|
+
|
|
520
|
+
while remaining:
|
|
521
|
+
blocked_topics = [
|
|
522
|
+
topic
|
|
523
|
+
for topic in ordered_topics
|
|
524
|
+
if topic in remaining
|
|
525
|
+
and any(
|
|
526
|
+
prerequisite in failed_topics
|
|
527
|
+
for prerequisite in topic_prerequisites.get(topic, ())
|
|
528
|
+
)
|
|
529
|
+
]
|
|
530
|
+
for topic in blocked_topics:
|
|
531
|
+
for test_id in _topic_execution_order(project_root, topic, topic_tasks[topic]):
|
|
532
|
+
task = topic_tasks[topic][test_id]
|
|
533
|
+
if _has_current_success(task):
|
|
534
|
+
continue
|
|
535
|
+
task.status = "blocked"
|
|
536
|
+
task.current_record = None
|
|
537
|
+
task.last_error = "前置主题的自动化测试没有通过,本主题未执行"
|
|
538
|
+
all_attempts.append(
|
|
539
|
+
ExecutionAttempt(
|
|
540
|
+
topic=topic,
|
|
541
|
+
test_id=test_id,
|
|
542
|
+
status="blocked",
|
|
543
|
+
command=list(task.command),
|
|
544
|
+
started_at=now_iso(),
|
|
545
|
+
finished_at=now_iso(),
|
|
546
|
+
duration_seconds=0.0,
|
|
547
|
+
exit_code=None,
|
|
548
|
+
output_tail="",
|
|
549
|
+
error=task.last_error,
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
remaining.remove(topic)
|
|
553
|
+
failed_topics.add(topic)
|
|
554
|
+
|
|
555
|
+
ready_topics = [
|
|
556
|
+
topic
|
|
557
|
+
for topic in ordered_topics
|
|
558
|
+
if topic in remaining
|
|
559
|
+
and all(
|
|
560
|
+
prerequisite in completed_topics
|
|
561
|
+
for prerequisite in topic_prerequisites.get(topic, ())
|
|
562
|
+
)
|
|
563
|
+
]
|
|
564
|
+
if not ready_topics:
|
|
565
|
+
# 失败会逐层阻塞后置主题;先重新计算,不把正常传播误报为非法依赖。
|
|
566
|
+
if blocked_topics:
|
|
567
|
+
continue
|
|
568
|
+
if remaining:
|
|
569
|
+
raise ValueError(
|
|
570
|
+
f"主题依赖无法继续执行,请检查 {artifact_paths_mod.QA_INDEX_DOC}: "
|
|
571
|
+
f"{sorted(remaining)}"
|
|
572
|
+
)
|
|
573
|
+
break
|
|
574
|
+
|
|
575
|
+
with ThreadPoolExecutor(max_workers=min(max_workers, len(ready_topics))) as executor:
|
|
576
|
+
futures = {executor.submit(run_topic, topic): topic for topic in ready_topics}
|
|
577
|
+
for future in as_completed(futures):
|
|
578
|
+
topic = futures[future]
|
|
579
|
+
attempts = future.result()
|
|
580
|
+
all_attempts.extend(attempts)
|
|
581
|
+
if all(_has_current_success(task) for task in topic_tasks[topic].values()):
|
|
582
|
+
completed_topics.add(topic)
|
|
583
|
+
else:
|
|
584
|
+
failed_topics.add(topic)
|
|
585
|
+
remaining.remove(topic)
|
|
586
|
+
|
|
587
|
+
# 并发执行只运行子进程;state 统一在这里保存,避免多个线程同时写 state.json。
|
|
588
|
+
for attempt in sorted(all_attempts, key=lambda item: (item.topic, item.test_id)):
|
|
589
|
+
journal_mod.append_entry(
|
|
590
|
+
project_root,
|
|
591
|
+
"测试项执行",
|
|
592
|
+
"workflow.py",
|
|
593
|
+
workflow_id=workflow_state.workflow_id,
|
|
594
|
+
topic=attempt.topic,
|
|
595
|
+
test_id=attempt.test_id,
|
|
596
|
+
status=attempt.status,
|
|
597
|
+
command=attempt.command,
|
|
598
|
+
started_at=attempt.started_at,
|
|
599
|
+
finished_at=attempt.finished_at,
|
|
600
|
+
duration_seconds=attempt.duration_seconds,
|
|
601
|
+
exit_code=attempt.exit_code,
|
|
602
|
+
error=attempt.error,
|
|
603
|
+
)
|
|
604
|
+
state_mod.save_state(project_root, workflow_state)
|
|
605
|
+
return sorted(all_attempts, key=lambda item: (item.topic, item.test_id))
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def summarize_attempts(attempts: list[ExecutionAttempt]) -> str:
|
|
609
|
+
if not attempts:
|
|
610
|
+
return "本次没有执行测试命令:当前没有自动化测试项,或全部测试项已有当前成功记录"
|
|
611
|
+
passed = sum(attempt.status == "passed" for attempt in attempts)
|
|
612
|
+
failed = [attempt for attempt in attempts if attempt.status != "passed"]
|
|
613
|
+
detail = f"本次执行 {len(attempts)} 个测试项,{passed} 个通过"
|
|
614
|
+
if failed:
|
|
615
|
+
detail += ";未通过或未执行:" + ", ".join(
|
|
616
|
+
f"{attempt.topic}/{attempt.test_id}({attempt.status})"
|
|
617
|
+
for attempt in failed
|
|
618
|
+
)
|
|
619
|
+
return detail
|