@hupan56/wlkj 2.7.12 → 3.1.2
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/bin/cli.js +1435 -1107
- package/package.json +29 -29
- package/templates/qoder/agents/insight-planning.md +1 -1
- package/templates/qoder/agents/insight-research.md +28 -15
- package/templates/qoder/commands/optional/wl-insight.md +161 -161
- package/templates/qoder/commands/optional/wl-report.md +20 -39
- package/templates/qoder/commands/optional/wl-status.md +4 -4
- package/templates/qoder/commands/wl-code.md +2 -2
- package/templates/qoder/commands/wl-design.md +6 -6
- package/templates/qoder/commands/wl-init.md +3 -3
- package/templates/qoder/commands/wl-prd.md +26 -13
- package/templates/qoder/commands/wl-req.md +43 -0
- package/templates/qoder/commands/wl-search.md +78 -47
- package/templates/qoder/commands/wl-task.md +343 -40
- package/templates/qoder/commands/wl-test.md +43 -17
- package/templates/qoder/config.yaml +52 -14
- package/templates/qoder/hooks/post-tool-use.py +181 -0
- package/templates/qoder/hooks/session-start.py +94 -23
- package/templates/qoder/hooks/stop-eval.py +207 -0
- package/templates/qoder/hooks/user-prompt-submit.py +139 -0
- package/templates/qoder/rules/wl-pipeline.md +85 -49
- package/templates/qoder/scripts/README.md +102 -0
- package/templates/qoder/scripts/capability/__init__.py +26 -0
- package/templates/qoder/scripts/capability/__main__.py +72 -0
- package/templates/qoder/scripts/capability/adapters/__init__.py +29 -0
- package/templates/qoder/scripts/capability/adapters/cli.py +316 -0
- package/templates/qoder/scripts/capability/adapters/mcp.py +334 -0
- package/templates/qoder/scripts/capability/adapters/qw.py +271 -0
- package/templates/qoder/scripts/capability/caps/__init__.py +27 -0
- package/templates/qoder/scripts/capability/caps/context.py +36 -0
- package/templates/qoder/scripts/capability/caps/cron.py +50 -0
- package/templates/qoder/scripts/capability/caps/identity.py +43 -0
- package/templates/qoder/scripts/capability/caps/memory.py +71 -0
- package/templates/qoder/scripts/capability/caps/notify.py +41 -0
- package/templates/qoder/scripts/capability/caps/present.py +48 -0
- package/templates/qoder/scripts/capability/caps/repo.py +31 -0
- package/templates/qoder/scripts/capability/caps/sandbox.py +43 -0
- package/templates/qoder/scripts/capability/chain.py +92 -0
- package/templates/qoder/scripts/capability/memory_chain.py +41 -0
- package/templates/qoder/scripts/capability/registry.py +246 -0
- package/templates/qoder/scripts/capability/registry_mcp.py +250 -0
- package/templates/qoder/scripts/capability/smoke_test.py +211 -0
- package/templates/qoder/scripts/capability/smoke_test_report.json +94 -0
- package/templates/qoder/scripts/deployment/setup/__init__.py +11 -0
- package/templates/qoder/scripts/deployment/setup/carriers.py +669 -0
- package/templates/qoder/scripts/deployment/setup/carriers_verify.py +148 -0
- package/templates/qoder/scripts/{init_doctor.py → deployment/setup/init_doctor.py} +100 -39
- package/templates/qoder/scripts/{install_qoderwork.py → deployment/setup/install_qoderwork.py} +48 -30
- package/templates/qoder/scripts/{platform_doctor.py → deployment/setup/platform_doctor.py} +271 -259
- package/templates/qoder/scripts/{repo_root.py → deployment/setup/repo_root.py} +9 -2
- package/templates/qoder/scripts/{setup.py → deployment/setup/setup.py} +143 -14
- package/templates/qoder/scripts/{setup_lanhu.py → deployment/setup/setup_lanhu.py} +979 -963
- package/templates/qoder/scripts/domain/design/__init__.py +0 -0
- package/templates/qoder/scripts/{fill_prototype.py → domain/design/fill_prototype.py} +17 -5
- package/templates/qoder/scripts/{gen_design_doc.py → domain/design/gen_design_doc.py} +406 -394
- package/templates/qoder/scripts/domain/kg/__init__.py +11 -0
- package/templates/qoder/scripts/domain/kg/build/__init__.py +0 -0
- package/templates/qoder/scripts/domain/kg/build/build_entity_registry.py +201 -0
- package/templates/qoder/scripts/domain/kg/build/build_relations.py +132 -0
- package/templates/qoder/scripts/{build_style_index.py → domain/kg/build/build_style_index.py} +50 -15
- package/templates/qoder/scripts/domain/kg/build/build_workflows.py +149 -0
- package/templates/qoder/scripts/{kg_build.py → domain/kg/build/kg_build.py} +685 -612
- package/templates/qoder/scripts/{kg_build_db.py → domain/kg/build/kg_build_db.py} +338 -327
- package/templates/qoder/scripts/{kg_incremental.py → domain/kg/build/kg_incremental.py} +425 -393
- package/templates/qoder/scripts/{learn_aggregate.py → domain/kg/build/learn_aggregate.py} +212 -201
- package/templates/qoder/scripts/domain/kg/extract/__init__.py +0 -0
- package/templates/qoder/scripts/{common → domain/kg/extract}/extract.py +430 -419
- package/templates/qoder/scripts/domain/kg/extract/test_extract.py +115 -0
- package/templates/qoder/scripts/{common → domain/kg/extract}/ts_extract.py +614 -536
- package/templates/qoder/scripts/domain/kg/graph/__init__.py +0 -0
- package/templates/qoder/scripts/{kg_link_db.py → domain/kg/graph/kg_link_db.py} +235 -224
- package/templates/qoder/scripts/domain/kg/graph/kg_semantic.py +683 -0
- package/templates/qoder/scripts/{kg.py → domain/kg/kg.py} +871 -708
- package/templates/qoder/scripts/domain/kg/kg_capabilities.py +182 -0
- package/templates/qoder/scripts/domain/kg/search/__init__.py +0 -0
- package/templates/qoder/scripts/{context_pack.py → domain/kg/search/context_pack.py} +45 -17
- package/templates/qoder/scripts/{enrich_prompt.py → domain/kg/search/enrich_prompt.py} +238 -226
- package/templates/qoder/scripts/domain/kg/search/prefetch.py +384 -0
- package/templates/qoder/scripts/{common → domain/kg/search}/search_engine.py +207 -205
- package/templates/qoder/scripts/{search_index.py → domain/kg/search/search_index.py} +210 -88
- package/templates/qoder/scripts/domain/kg/server/__init__.py +0 -0
- package/templates/qoder/scripts/domain/kg/server/kgd.py +549 -0
- package/templates/qoder/scripts/domain/kg/server/perf_bench.py +197 -0
- package/templates/qoder/scripts/domain/kg/storage/__init__.py +0 -0
- package/templates/qoder/scripts/{kg_duckdb.py → domain/kg/storage/kg_duckdb.py} +70 -45
- package/templates/qoder/scripts/domain/learning/__init__.py +0 -0
- package/templates/qoder/scripts/{learn.py → domain/learning/learn.py} +157 -146
- package/templates/qoder/scripts/domain/report/__init__.py +11 -0
- package/templates/qoder/scripts/{add_session.py → domain/report/add_session.py} +256 -244
- package/templates/qoder/scripts/{export.py → domain/report/export.py} +72 -4
- package/templates/qoder/scripts/{report.py → domain/report/report.py} +292 -281
- package/templates/qoder/scripts/domain/report/report_snapshot.py +261 -0
- package/templates/qoder/scripts/domain/report/role.py +39 -0
- package/templates/qoder/scripts/{status.py → domain/report/status.py} +737 -628
- package/templates/qoder/scripts/domain/report/status_snapshot.py +191 -0
- package/templates/qoder/scripts/domain/requirement/__init__.py +0 -0
- package/templates/qoder/scripts/{archive_prd.py → domain/requirement/archive_prd.py} +389 -377
- package/templates/qoder/scripts/domain/requirement/req.py +228 -0
- package/templates/qoder/scripts/domain/task/__init__.py +11 -0
- package/templates/qoder/scripts/{git_sync.py → domain/task/git_sync.py} +90 -50
- package/templates/qoder/scripts/{syncgate.py → domain/task/syncgate.py} +18 -7
- package/templates/qoder/scripts/domain/task/task.py +229 -0
- package/templates/qoder/scripts/domain/task/task_lifecycle.py +606 -0
- package/templates/qoder/scripts/domain/task/task_query.py +162 -0
- package/templates/qoder/scripts/domain/task/task_relations.py +425 -0
- package/templates/qoder/scripts/{team_sync.py → domain/task/team_sync.py} +101 -23
- package/templates/qoder/scripts/domain/task/zentao_sync.py +370 -0
- package/templates/qoder/scripts/foundation/__init__.py +0 -0
- package/templates/qoder/scripts/foundation/bootstrap.py +145 -0
- package/templates/qoder/scripts/foundation/core/__init__.py +0 -0
- package/templates/qoder/scripts/foundation/core/cmd_registry.py +112 -0
- package/templates/qoder/scripts/foundation/core/config.py +187 -0
- package/templates/qoder/scripts/{common → foundation/core}/paths.py +706 -400
- package/templates/qoder/scripts/foundation/data/__init__.py +0 -0
- package/templates/qoder/scripts/foundation/data/contract.py +317 -0
- package/templates/qoder/scripts/{common → foundation/data}/events.py +2 -2
- package/templates/qoder/scripts/foundation/data/result.py +223 -0
- package/templates/qoder/scripts/{common → foundation/data}/task_utils.py +427 -392
- package/templates/qoder/scripts/foundation/identity/__init__.py +0 -0
- package/templates/qoder/scripts/foundation/identity/check_publish.py +103 -0
- package/templates/qoder/scripts/{common → foundation/identity}/developer.py +239 -238
- package/templates/qoder/scripts/foundation/identity/guard.py +159 -0
- package/templates/qoder/scripts/{common → foundation/identity}/identity.py +132 -9
- package/templates/qoder/scripts/foundation/identity/roles.py +60 -0
- package/templates/qoder/scripts/foundation/integrations/__init__.py +0 -0
- package/templates/qoder/scripts/{common → foundation/integrations}/active_task.py +30 -15
- package/templates/qoder/scripts/{common → foundation/integrations}/feishu.py +11 -10
- package/templates/qoder/scripts/{common → foundation/integrations}/terms.py +3 -3
- package/templates/qoder/scripts/foundation/integrations/zentao_client.py +220 -0
- package/templates/qoder/scripts/foundation/io/__init__.py +0 -0
- package/templates/qoder/scripts/{common → foundation/io}/filelock.py +1 -1
- package/templates/qoder/scripts/{common → foundation/io}/reqid.py +1 -1
- package/templates/qoder/scripts/foundation/protocol/__init__.py +0 -0
- package/templates/qoder/scripts/foundation/protocol/duckdb_conn.py +39 -0
- package/templates/qoder/scripts/foundation/protocol/mcp_base.py +268 -0
- package/templates/qoder/scripts/foundation/utils/__init__.py +0 -0
- package/templates/qoder/scripts/{common → foundation/utils}/eval_api.py +12 -1
- package/templates/qoder/scripts/{common → foundation/utils}/pip_install.py +23 -6
- package/templates/qoder/scripts/{common → foundation/utils}/platform_guard.py +1 -1
- package/templates/qoder/scripts/orchestration/__init__.py +0 -0
- package/templates/qoder/scripts/orchestration/wlkj.py +185 -0
- package/templates/qoder/scripts/protocol/__init__.py +0 -0
- package/templates/qoder/scripts/protocol/browser/README.md +207 -0
- package/templates/qoder/scripts/protocol/browser/SKILL.md +265 -0
- package/templates/qoder/scripts/protocol/browser/config.env +9 -0
- package/templates/qoder/scripts/protocol/browser/references/cdp-api.md +110 -0
- package/templates/qoder/scripts/protocol/browser/references/migration-2.5.3.md +72 -0
- package/templates/qoder/scripts/protocol/browser/references/site-patterns/.gitkeep +0 -0
- package/templates/qoder/scripts/protocol/browser/scripts/browser-discovery.mjs +138 -0
- package/templates/qoder/scripts/protocol/browser/scripts/cdp-proxy.mjs +672 -0
- package/templates/qoder/scripts/protocol/browser/scripts/check-deps.mjs +206 -0
- package/templates/qoder/scripts/protocol/browser/scripts/find-url.mjs +253 -0
- package/templates/qoder/scripts/protocol/browser/scripts/match-site.mjs +46 -0
- package/templates/qoder/scripts/protocol/browser/templates/config.env.template +9 -0
- package/templates/qoder/scripts/protocol/mcp/__init__.py +11 -0
- package/templates/qoder/scripts/protocol/mcp/kg_mcp_server.py +478 -0
- package/templates/qoder/scripts/{lanhu_stdio_wrapper.py → protocol/mcp/lanhu_stdio_wrapper.py} +131 -119
- package/templates/qoder/scripts/protocol/mcp/mcp_doctor.py +548 -0
- package/templates/qoder/scripts/protocol/mcp/mcp_launcher.py +600 -0
- package/templates/qoder/scripts/protocol/mcp/mysql_mcp_server.py +461 -0
- package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +1920 -0
- package/templates/qoder/scripts/protocol/transports/__init__.py +56 -0
- package/templates/qoder/scripts/protocol/transports/base.py +87 -0
- package/templates/qoder/scripts/protocol/transports/cli.py +132 -0
- package/templates/qoder/scripts/protocol/transports/http.py +141 -0
- package/templates/qoder/scripts/protocol/transports/stdio.py +293 -0
- package/templates/qoder/scripts/run_weekly_update.bat +27 -18
- package/templates/qoder/scripts/run_weekly_update.sh +22 -13
- package/templates/qoder/scripts/validation/__init__.py +0 -0
- package/templates/qoder/scripts/validation/eval/__init__.py +0 -0
- package/templates/qoder/scripts/validation/eval/perf-report-2026-06-24.md +139 -0
- package/templates/qoder/scripts/validation/eval/qwork_harness.py +617 -0
- package/templates/qoder/scripts/validation/eval/report-commands.md +220 -0
- package/templates/qoder/scripts/validation/eval/transcript_timing.py +386 -0
- package/templates/qoder/scripts/validation/metrics/__init__.py +0 -0
- package/templates/qoder/scripts/{eval_prd.py → validation/metrics/eval_prd.py} +84 -15
- package/templates/qoder/scripts/validation/metrics/usability_score.py +750 -0
- package/templates/qoder/scripts/validation/test/__init__.py +11 -0
- package/templates/qoder/scripts/validation/test/assertion_gen.py +551 -0
- package/templates/qoder/scripts/{autotest.py → validation/test/autotest.py} +1234 -1751
- package/templates/qoder/scripts/validation/test/autotest_auth.py +109 -0
- package/templates/qoder/scripts/{autotest_batch.py → validation/test/autotest_batch.py} +255 -224
- package/templates/qoder/scripts/validation/test/autotest_data.py +680 -0
- package/templates/qoder/scripts/validation/test/autotest_opencli.py +646 -0
- package/templates/qoder/scripts/{autotest_run.py → validation/test/autotest_run.py} +323 -297
- package/templates/qoder/scripts/validation/test/autotest_webaccess.py +571 -0
- package/templates/qoder/scripts/{benchmark.py → validation/test/benchmark.py} +17 -5
- package/templates/qoder/scripts/{kg_auto_login.py → validation/test/kg_auto_login.py} +208 -196
- package/templates/qoder/scripts/{kg_test_runner.py → validation/test/kg_test_runner.py} +13 -2
- package/templates/qoder/scripts/{page_probe.py → validation/test/page_probe.py} +470 -459
- package/templates/qoder/scripts/validation/test/smoke_all_commands.py +170 -0
- package/templates/qoder/settings.json +23 -2
- package/templates/qoder/skills/design-import/SKILL.md +235 -226
- package/templates/qoder/skills/design-review/SKILL.md +91 -82
- package/templates/qoder/skills/prd-generator/SKILL.md +38 -24
- package/templates/qoder/skills/prd-review/SKILL.md +8 -6
- package/templates/qoder/skills/prototype-generator/SKILL.md +296 -247
- package/templates/qoder/skills/spec-coder/SKILL.md +24 -15
- package/templates/qoder/skills/spec-generator/SKILL.md +24 -12
- package/templates/qoder/skills/test-generator/SKILL.md +9 -7
- package/templates/qoder/skills/wl-code/SKILL.md +25 -13
- package/templates/qoder/skills/wl-commit/SKILL.md +10 -9
- package/templates/qoder/skills/wl-design/SKILL.md +7 -5
- package/templates/qoder/skills/wl-init/SKILL.md +8 -8
- package/templates/qoder/skills/wl-insight/SKILL.md +25 -12
- package/templates/qoder/skills/wl-prd-full/SKILL.md +59 -8
- package/templates/qoder/skills/wl-prd-quick/SKILL.md +7 -7
- package/templates/qoder/skills/wl-prd-review/SKILL.md +18 -6
- package/templates/qoder/skills/wl-report/SKILL.md +27 -25
- package/templates/qoder/skills/wl-search/SKILL.md +151 -80
- package/templates/qoder/skills/wl-spec/SKILL.md +9 -7
- package/templates/qoder/skills/wl-status/SKILL.md +50 -18
- package/templates/qoder/skills/wl-task/SKILL.md +94 -12
- package/templates/qoder/skills/wl-test/SKILL.md +154 -45
- package/templates/qoder/templates/prd-full-template.md +7 -0
- package/templates/root/AGENTS.md +275 -237
- package/templates/root/requirements.txt +3 -0
- package/templates/root//344/275/277/347/224/250/350/257/264/346/230/216.md +3 -3
- package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +2 -2
- package/templates/qoder/hooks/inject-workflow-state.py +0 -169
- package/templates/qoder/scripts/__pycache__/check_mcp_launch.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/__pycache__/install_qoderwork.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/__pycache__/mcp_launcher.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/__pycache__/platform_doctor.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/check_carriers.py +0 -238
- package/templates/qoder/scripts/check_mcp.py +0 -298
- package/templates/qoder/scripts/check_mcp_launch.py +0 -183
- package/templates/qoder/scripts/check_qoderwork_consistency.py +0 -166
- package/templates/qoder/scripts/collect_prds.py +0 -31
- package/templates/qoder/scripts/common/mentions.py +0 -134
- package/templates/qoder/scripts/common/utf8.py +0 -38
- package/templates/qoder/scripts/extract_api_params.py +0 -246
- package/templates/qoder/scripts/extract_routes.py +0 -54
- package/templates/qoder/scripts/extract_routes_tree.py +0 -78
- package/templates/qoder/scripts/handoff.py +0 -22
- package/templates/qoder/scripts/init_developer.py +0 -76
- package/templates/qoder/scripts/kg_mcp_server.py +0 -801
- package/templates/qoder/scripts/kg_semantic.py +0 -150
- package/templates/qoder/scripts/mcp_launcher.py +0 -414
- package/templates/qoder/scripts/mysql_mcp_server.py +0 -396
- package/templates/qoder/scripts/parse_prds.py +0 -33
- package/templates/qoder/scripts/role.py +0 -51
- package/templates/qoder/scripts/sync_carriers.py +0 -259
- package/templates/qoder/scripts/task.py +0 -1261
- package/templates/qoder/scripts/workspace_init.py +0 -102
- package/templates/qoder/scripts/zentao_mcp_server.py +0 -424
- package/templates/qoder/skills/prompt-enrich/SKILL.md +0 -90
- /package/templates/qoder/scripts/{common → deployment}/__init__.py +0 -0
- /package/templates/qoder/{skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp → scripts/domain/__init__.py} +0 -0
- /package/templates/qoder/scripts/{common → domain/kg/graph}/graph_traverse.py +0 -0
- /package/templates/qoder/scripts/{common → domain/kg/server}/repowiki.py +0 -0
- /package/templates/qoder/scripts/{common → foundation/io}/atomicio.py +0 -0
- /package/templates/qoder/scripts/{secure-ls.js → validation/test/secure-ls.js} +0 -0
|
@@ -1,1751 +1,1234 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"""
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
"""
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
' (
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
"""
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
'
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
'
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
'
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
'
|
|
412
|
-
'
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
if not
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
#
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
print('
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
print('
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
return
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
def
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
print('
|
|
801
|
-
print(
|
|
802
|
-
print()
|
|
803
|
-
print('
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
print()
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
if isinstance(
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
if
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
print('
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
print('
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
print()
|
|
1236
|
-
for c in cases:
|
|
1237
|
-
print('□ %s %s' % (c.get('case_id', '?'), c.get('title', '')))
|
|
1238
|
-
for s in (c.get('steps') or [])[:4]:
|
|
1239
|
-
print(' %s' % _fmt_step(s))
|
|
1240
|
-
print(' 预期: %s' % c.get('expected', ''))
|
|
1241
|
-
print()
|
|
1242
|
-
print('在 QoderWork 桌面端 (Settings → Connectors 授权浏览器/Computer) ')
|
|
1243
|
-
print('可让 Agent 自动执行; 结果用 --record 回传出报告。')
|
|
1244
|
-
print('-' * 60)
|
|
1245
|
-
return 0
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
def _record_and_report(task_dir, args, base, src, record_json):
|
|
1249
|
-
"""把执行结果落历史 jsonl 并打印汇总。"""
|
|
1250
|
-
try:
|
|
1251
|
-
results = json.loads(record_json)
|
|
1252
|
-
except json.JSONDecodeError as e:
|
|
1253
|
-
print('✗ --record 不是合法 JSON: %s' % e)
|
|
1254
|
-
return 1
|
|
1255
|
-
if not isinstance(results, dict):
|
|
1256
|
-
print('✗ --record 需为 {case_id: "pass"|"fail"} 形式')
|
|
1257
|
-
return 1
|
|
1258
|
-
|
|
1259
|
-
total = len(results)
|
|
1260
|
-
passed = sum(1 for v in results.values()
|
|
1261
|
-
if str(v).lower() in ('pass', 'passed', 'ok', 'true', '1'))
|
|
1262
|
-
failed_ids = [k for k, v in results.items()
|
|
1263
|
-
if str(v).lower() not in ('pass', 'passed', 'ok', 'true', '1')]
|
|
1264
|
-
status = 'pass' if (total > 0 and not failed_ids) else (
|
|
1265
|
-
'fail' if failed_ids else 'none')
|
|
1266
|
-
|
|
1267
|
-
task = load_task_json(task_dir) or {}
|
|
1268
|
-
record = {
|
|
1269
|
-
'time': datetime.now().isoformat(),
|
|
1270
|
-
'task': Path(task_dir).name,
|
|
1271
|
-
'task_title': task.get('title', ''),
|
|
1272
|
-
'env': args.env,
|
|
1273
|
-
'platform': args.platform,
|
|
1274
|
-
'base_url': base,
|
|
1275
|
-
'url_source': src,
|
|
1276
|
-
'total': total,
|
|
1277
|
-
'passed': passed,
|
|
1278
|
-
'failed': len(failed_ids),
|
|
1279
|
-
'status': status,
|
|
1280
|
-
'failed_cases': failed_ids,
|
|
1281
|
-
'runner': 'qoderwork-browser',
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
try:
|
|
1285
|
-
from common.atomicio import atomic_append_jsonl
|
|
1286
|
-
os.makedirs(os.path.dirname(HISTORY_PATH), exist_ok=True)
|
|
1287
|
-
atomic_append_jsonl(HISTORY_PATH, record, timeout=10)
|
|
1288
|
-
except Exception as e:
|
|
1289
|
-
print('⚠ 历史记录写入失败 (不影响本次结果): %s' % e, file=sys.stderr)
|
|
1290
|
-
|
|
1291
|
-
print('=' * 60)
|
|
1292
|
-
print('测试结果汇总')
|
|
1293
|
-
print('=' * 60)
|
|
1294
|
-
print('任务: %s' % record['task_title'])
|
|
1295
|
-
print('环境: %s 目标: %s' % (args.env, base))
|
|
1296
|
-
print('总计 %d 条 · 通过 %d · 失败 %d · 状态: %s'
|
|
1297
|
-
% (total, passed, len(failed_ids), status.upper()))
|
|
1298
|
-
if failed_ids:
|
|
1299
|
-
print('失败用例:')
|
|
1300
|
-
for cid in failed_ids:
|
|
1301
|
-
print(' ✗ %s' % cid)
|
|
1302
|
-
print('-' * 60)
|
|
1303
|
-
print('已记录到 %s' % HISTORY_PATH)
|
|
1304
|
-
if status == 'fail':
|
|
1305
|
-
print('建议: 查失败用例, 修复后重跑 /wl-test run %s --env %s'
|
|
1306
|
-
% (Path(task_dir).name, args.env))
|
|
1307
|
-
return 1
|
|
1308
|
-
print('全部通过! 可 /wl-commit 提交, 或 /wl-report 出报告。')
|
|
1309
|
-
return 0
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
# ---------------------------------------------------------------------------
|
|
1313
|
-
# 子命令: list
|
|
1314
|
-
# ---------------------------------------------------------------------------
|
|
1315
|
-
|
|
1316
|
-
def cmd_list(args):
|
|
1317
|
-
task_dir = resolve_task_dir(args.task)
|
|
1318
|
-
if not task_dir.is_dir():
|
|
1319
|
-
print('任务不存在: %s' % args.task)
|
|
1320
|
-
return 1
|
|
1321
|
-
cases = read_cases(task_dir)
|
|
1322
|
-
if not cases:
|
|
1323
|
-
print('任务 %s 暂无用例。' % Path(task_dir).name)
|
|
1324
|
-
print('生成: python .qoder/scripts/autotest.py generate %s'
|
|
1325
|
-
% Path(task_dir).name)
|
|
1326
|
-
return 0
|
|
1327
|
-
task = load_task_json(task_dir) or {}
|
|
1328
|
-
print('任务: %s' % task.get('title', Path(task_dir).name))
|
|
1329
|
-
print('用例文件: %s' % _cases_path(task_dir))
|
|
1330
|
-
print()
|
|
1331
|
-
print('%-18s %-8s %-6s %s' % ('CASE_ID', 'ENV', 'PLAT', '标题'))
|
|
1332
|
-
print('-' * 60)
|
|
1333
|
-
for c in cases:
|
|
1334
|
-
print('%-18s %-8s %-6s %s' % (
|
|
1335
|
-
c.get('case_id', '?'),
|
|
1336
|
-
c.get('env', '?'),
|
|
1337
|
-
c.get('platform', '?'),
|
|
1338
|
-
c.get('title', '')))
|
|
1339
|
-
print('-' * 60)
|
|
1340
|
-
print('共 %d 条' % len(cases))
|
|
1341
|
-
return 0
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
# ---------------------------------------------------------------------------
|
|
1345
|
-
# 子命令: set-data / list-data (个人测试数据, 本地 gitignored)
|
|
1346
|
-
# ---------------------------------------------------------------------------
|
|
1347
|
-
|
|
1348
|
-
def cmd_set_data(args):
|
|
1349
|
-
"""预置一条个人测试数据到本地文档(不进 git)。"""
|
|
1350
|
-
env = getattr(args, 'env', None)
|
|
1351
|
-
platform = getattr(args, 'platform', None)
|
|
1352
|
-
save_local_test_data(args.key, args.value, env=env, platform=platform)
|
|
1353
|
-
loc = ('%s.%s' % (env, platform)) if (env and platform) else (
|
|
1354
|
-
env or platform or '通用(顶层)')
|
|
1355
|
-
print('已保存到个人文档: %s' % local_data_path())
|
|
1356
|
-
print(' %s = %s [位置: %s]' % (args.key, args.value, loc))
|
|
1357
|
-
print()
|
|
1358
|
-
print('🔒 此文档已被 .gitignore 覆盖 (workspace/members/*/autotest-data.yaml),')
|
|
1359
|
-
print(' team_sync 不会 push, 团队/npm 包都看不到。')
|
|
1360
|
-
print(' run 时自动注入用例的 {{ask:%s}} 占位。' % args.key)
|
|
1361
|
-
if any(s in args.key for s in ('密码', '密', 'secret', 'token', '码')):
|
|
1362
|
-
print(' (敏感数据, 存个人文档比每次对话明文输入更安全)')
|
|
1363
|
-
return 0
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
def cmd_list_data(args):
|
|
1367
|
-
"""列出本地已预置的测试数据(可按 env/platform 过滤)。"""
|
|
1368
|
-
env = getattr(args, 'env', None)
|
|
1369
|
-
platform = getattr(args, 'platform', None)
|
|
1370
|
-
p = local_data_path()
|
|
1371
|
-
if not p.is_file():
|
|
1372
|
-
print('个人测试数据文档不存在: %s' % p)
|
|
1373
|
-
print()
|
|
1374
|
-
print('生成模板: python .qoder/scripts/autotest.py init-data')
|
|
1375
|
-
print('或直接预置: python .qoder/scripts/autotest.py set-data 登录账号 test')
|
|
1376
|
-
return 0
|
|
1377
|
-
data = load_local_test_data(env=env, platform=platform)
|
|
1378
|
-
if not data:
|
|
1379
|
-
scope = ('%s/%s' % (env, platform)) if (env and platform) else (
|
|
1380
|
-
env or platform or '全部')
|
|
1381
|
-
print('文档 %s 在 %s 范围内无数据。' % (p, scope))
|
|
1382
|
-
return 0
|
|
1383
|
-
print('个人测试数据 (%d 项)' % len(data))
|
|
1384
|
-
print('文档: %s' % p)
|
|
1385
|
-
print('-' * 50)
|
|
1386
|
-
for k, v in data.items():
|
|
1387
|
-
show = v
|
|
1388
|
-
if any(s in k for s in ('密码', '密', 'secret', 'token')):
|
|
1389
|
-
show = '*' * min(len(str(v)), 8) if str(v) else '(空)'
|
|
1390
|
-
print(' %-16s = %s' % (k, show))
|
|
1391
|
-
print('-' * 50)
|
|
1392
|
-
print('🔒 个人文档, 已 gitignore, 不会 push/共享。run 时自动注入 {{ask:描述}}。')
|
|
1393
|
-
return 0
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
def cmd_init_data(args):
|
|
1397
|
-
"""生成带注释的个人测试数据模板文档(若不存在)。"""
|
|
1398
|
-
path, created = init_local_data_doc()
|
|
1399
|
-
if created:
|
|
1400
|
-
print('已生成模板: %s' % path)
|
|
1401
|
-
print()
|
|
1402
|
-
print('打开它, 填入你的测试账号密码(例子已给)。每人只填自己的 members/{自己}/。')
|
|
1403
|
-
print('🔒 已 gitignore, 不会 push/共享给团队。')
|
|
1404
|
-
else:
|
|
1405
|
-
print('文档已存在(不覆盖): %s' % path)
|
|
1406
|
-
print('直接编辑它, 或用 set-data 命令追加。')
|
|
1407
|
-
return 0
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
# ---------------------------------------------------------------------------
|
|
1411
|
-
# 子命令: login-state (登录态: 已登录就别重复登录, 尤其验证码慢)
|
|
1412
|
-
# ---------------------------------------------------------------------------
|
|
1413
|
-
|
|
1414
|
-
def cmd_login_state(args):
|
|
1415
|
-
"""查看/标记/清除 登录态。
|
|
1416
|
-
|
|
1417
|
-
QoderWork 保持同一个浏览器会话 → 登录一次后 cookie 不丢。
|
|
1418
|
-
后续测新功能直接 goto 目标页, 不用重复登录(数学题验证码很慢)。
|
|
1419
|
-
"""
|
|
1420
|
-
action = args.action # show / mark / clear
|
|
1421
|
-
env = getattr(args, 'env', None) or _default_env()
|
|
1422
|
-
platform = getattr(args, 'platform', None) or 'web'
|
|
1423
|
-
|
|
1424
|
-
if action == 'mark':
|
|
1425
|
-
base, src = resolve_base_url(env, platform)
|
|
1426
|
-
if not base:
|
|
1427
|
-
print('✗ %s/%s 未配域名, 无法标记登录。' % (env, platform))
|
|
1428
|
-
return 1
|
|
1429
|
-
mark_logged_in(env, platform, base)
|
|
1430
|
-
print('已标记 %s/%s 为已登录 (浏览器会话保持中, cookie 有效)。' % (env, platform))
|
|
1431
|
-
print('🔒 存个人文档 (gitignored), 不会 push。')
|
|
1432
|
-
print()
|
|
1433
|
-
print('后续 quick/browser 测新功能: 直接 goto 目标页, 不用再登录。')
|
|
1434
|
-
return 0
|
|
1435
|
-
|
|
1436
|
-
if action == 'clear':
|
|
1437
|
-
clear_login_state(env if env and env != 'all' else None,
|
|
1438
|
-
platform if env != 'all' else None)
|
|
1439
|
-
print('已清除登录态: %s/%s。' % (env, platform))
|
|
1440
|
-
print('下次测试会重新走登录流程。')
|
|
1441
|
-
return 0
|
|
1442
|
-
|
|
1443
|
-
# show (默认)
|
|
1444
|
-
state = load_login_state()
|
|
1445
|
-
if not state:
|
|
1446
|
-
print('未记录任何登录态。')
|
|
1447
|
-
print('登录用例通过后自动标记, 或手动: login-state mark --env %s --platform %s'
|
|
1448
|
-
% (env, platform))
|
|
1449
|
-
return 0
|
|
1450
|
-
print('已记录登录态 (QoderWork 会话保持中, 后续测新功能可直接 goto 目标页):')
|
|
1451
|
-
print('-' * 50)
|
|
1452
|
-
for e, plats in state.items():
|
|
1453
|
-
for pf, info in (plats or {}).items():
|
|
1454
|
-
t = (info or {}).get('time', '?')[:19].replace('T', ' ')
|
|
1455
|
-
url = (info or {}).get('url', '')
|
|
1456
|
-
print(' %s/%s 已登录 (%s) %s' % (e, pf, t, url))
|
|
1457
|
-
print('-' * 50)
|
|
1458
|
-
print('🔒 个人文档, gitignored, 不 push。清除: login-state clear --env <环境>')
|
|
1459
|
-
return 0
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
# ---------------------------------------------------------------------------
|
|
1463
|
-
# 子命令: learn / recall (页面锚点沉淀 + 查询, A方案核心)
|
|
1464
|
-
# ---------------------------------------------------------------------------
|
|
1465
|
-
|
|
1466
|
-
def cmd_learn(args):
|
|
1467
|
-
"""把 read_page 返回的页面元素沉淀成稳定锚点 → data/index/test-pages.json。
|
|
1468
|
-
|
|
1469
|
-
AI 执行 observe(read_page) 后, 把元素 JSON 回传, 本命令提取稳定锚点存盘。
|
|
1470
|
-
下次 recall 同页直接命中锚点, 跳过 LLM 匹配(Stagehand 式 action 缓存)。
|
|
1471
|
-
"""
|
|
1472
|
-
url_pattern = _normalize_url_pattern(args.url, getattr(args, '_base', None))
|
|
1473
|
-
# 解析 page-json (read_page 返回的元素数组, 或 {elements:[...]})
|
|
1474
|
-
try:
|
|
1475
|
-
parsed = json.loads(args.page_json)
|
|
1476
|
-
except json.JSONDecodeError as e:
|
|
1477
|
-
print('✗ --page-json 不是合法 JSON: %s' % e)
|
|
1478
|
-
return 1
|
|
1479
|
-
if isinstance(parsed, dict):
|
|
1480
|
-
elements = parsed.get('elements') or parsed.get('elements') or []
|
|
1481
|
-
elif isinstance(parsed, list):
|
|
1482
|
-
elements = parsed
|
|
1483
|
-
else:
|
|
1484
|
-
print('✗ --page-json 应为元素数组或含 elements 的对象')
|
|
1485
|
-
return 1
|
|
1486
|
-
# 可选 intent-map
|
|
1487
|
-
intent_map = None
|
|
1488
|
-
if args.intent_map:
|
|
1489
|
-
try:
|
|
1490
|
-
intent_map = json.loads(args.intent_map)
|
|
1491
|
-
except json.JSONDecodeError as e:
|
|
1492
|
-
print('⚠ --intent-map 不是合法 JSON, 忽略: %s' % e)
|
|
1493
|
-
new_n, total_n = learn_page_anchors(url_pattern, elements, intent_map=intent_map)
|
|
1494
|
-
print('=' * 56)
|
|
1495
|
-
print('锚点沉淀 → data/index/test-pages.json')
|
|
1496
|
-
print('=' * 56)
|
|
1497
|
-
print('页面: %s' % url_pattern)
|
|
1498
|
-
print('本次新增 %d 个锚点, 该页累计 %d 个' % (new_n, total_n))
|
|
1499
|
-
print('🔒 团队共享(随 team_sync 分发), 只存 role/name/placeholder, 不含密码/ref。')
|
|
1500
|
-
print()
|
|
1501
|
-
print('下次 recall %s 即可直接用锚点生成用例, 跳过 LLM 匹配。' % url_pattern)
|
|
1502
|
-
return 0
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
def cmd_recall(args):
|
|
1506
|
-
"""查某页已沉淀的锚点。AI 生成用例前查它, 有锚点就直接填进 step。"""
|
|
1507
|
-
url_pattern = _normalize_url_pattern(args.url)
|
|
1508
|
-
anchors = recall_page_anchors(url_pattern)
|
|
1509
|
-
print('=' * 56)
|
|
1510
|
-
print('已沉淀锚点: %s' % url_pattern)
|
|
1511
|
-
print('=' * 56)
|
|
1512
|
-
if not anchors:
|
|
1513
|
-
print('该页暂无锚点。执行 observe(read_page) 后用 learn 沉淀:')
|
|
1514
|
-
print(' python .qoder/scripts/autotest.py learn --url %s --page-json \'<read_page结果>\'' % url_pattern)
|
|
1515
|
-
return 0
|
|
1516
|
-
print('%-10s %-22s %s' % ('INTENT', 'ANCHOR', '描述'))
|
|
1517
|
-
print('-' * 56)
|
|
1518
|
-
for a in anchors:
|
|
1519
|
-
print('%-10s %-22s %s' % (
|
|
1520
|
-
a.get('intent', '?'),
|
|
1521
|
-
_fmt_anchor(a.get('anchor')),
|
|
1522
|
-
a.get('desc', '')[:30]))
|
|
1523
|
-
print('-' * 56)
|
|
1524
|
-
print('共 %d 个锚点。生成用例时可直接填入 step.anchor, 跳过 LLM 匹配。' % len(anchors))
|
|
1525
|
-
return 0
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
# ---------------------------------------------------------------------------
|
|
1529
|
-
# 子命令: page (页面按钮+接口画像, 开发→测试交接物)
|
|
1530
|
-
# ---------------------------------------------------------------------------
|
|
1531
|
-
|
|
1532
|
-
def cmd_page(args):
|
|
1533
|
-
"""查页面按钮+接口画像。薄入口, 实际逻辑在 page_probe.py。
|
|
1534
|
-
数据来自知识图谱 traces 表, 无图谱则静默提示。
|
|
1535
|
-
"""
|
|
1536
|
-
try:
|
|
1537
|
-
import page_probe
|
|
1538
|
-
except ImportError:
|
|
1539
|
-
# 直接当脚本跑时, 补一下 sys.path
|
|
1540
|
-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
1541
|
-
import page_probe
|
|
1542
|
-
|
|
1543
|
-
profile = page_probe.build_page_profile(keyword=getattr(args, 'kw', None),
|
|
1544
|
-
url=getattr(args, 'url', None))
|
|
1545
|
-
label = args.kw or args.url
|
|
1546
|
-
print('## 页面画像: %s' % label)
|
|
1547
|
-
print()
|
|
1548
|
-
print(page_probe.render_profile_md(profile))
|
|
1549
|
-
if profile.get('ok'):
|
|
1550
|
-
n_pages = len(profile['pages'])
|
|
1551
|
-
n_btns = sum(len(pg['buttons']) for pg in profile['pages'])
|
|
1552
|
-
print()
|
|
1553
|
-
print('[page] %d 个页面, %d 个交互点' % (n_pages, n_btns))
|
|
1554
|
-
return 0
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
# ---------------------------------------------------------------------------
|
|
1558
|
-
# 子命令: report
|
|
1559
|
-
# ---------------------------------------------------------------------------
|
|
1560
|
-
|
|
1561
|
-
def cmd_report(args):
|
|
1562
|
-
task_dir = resolve_task_dir(args.task)
|
|
1563
|
-
task_name = Path(task_dir).name
|
|
1564
|
-
task = load_task_json(task_dir) or {}
|
|
1565
|
-
|
|
1566
|
-
if not os.path.isfile(HISTORY_PATH):
|
|
1567
|
-
print('暂无测试执行历史 (%s)' % HISTORY_PATH)
|
|
1568
|
-
print('先跑: python .qoder/scripts/autotest.py run %s --env test'
|
|
1569
|
-
% task_name)
|
|
1570
|
-
return 0
|
|
1571
|
-
|
|
1572
|
-
# 读该任务的全部历史 (倒序)
|
|
1573
|
-
records = []
|
|
1574
|
-
with open(HISTORY_PATH, encoding='utf-8') as f:
|
|
1575
|
-
for line in f:
|
|
1576
|
-
line = line.strip()
|
|
1577
|
-
if not line:
|
|
1578
|
-
continue
|
|
1579
|
-
try:
|
|
1580
|
-
r = json.loads(line)
|
|
1581
|
-
except json.JSONDecodeError:
|
|
1582
|
-
continue
|
|
1583
|
-
if r.get('task') == task_name:
|
|
1584
|
-
records.append(r)
|
|
1585
|
-
if not records:
|
|
1586
|
-
print('任务 %s 无执行记录。' % task_name)
|
|
1587
|
-
return 0
|
|
1588
|
-
|
|
1589
|
-
records.sort(key=lambda r: r.get('time', ''), reverse=True)
|
|
1590
|
-
print('=' * 60)
|
|
1591
|
-
print('测试历史 · %s' % task.get('title', task_name))
|
|
1592
|
-
print('=' * 60)
|
|
1593
|
-
print('%-20s %-6s %-7s %-5s %s' % (
|
|
1594
|
-
'时间', 'ENV', '状态', 'P/F', '目标'))
|
|
1595
|
-
print('-' * 60)
|
|
1596
|
-
for r in records[:20]:
|
|
1597
|
-
t = r.get('time', '')[:19].replace('T', ' ')
|
|
1598
|
-
print('%-20s %-6s %-7s %d/%d %s' % (
|
|
1599
|
-
t,
|
|
1600
|
-
r.get('env', '?'),
|
|
1601
|
-
r.get('status', '?').upper(),
|
|
1602
|
-
r.get('passed', 0),
|
|
1603
|
-
r.get('total', 0),
|
|
1604
|
-
r.get('base_url', '')))
|
|
1605
|
-
print('-' * 60)
|
|
1606
|
-
total_runs = len(records)
|
|
1607
|
-
pass_runs = sum(1 for r in records if r.get('status') == 'pass')
|
|
1608
|
-
print('历史 %d 次执行 · 通过率 %.0f%% (最近 %d 次)'
|
|
1609
|
-
% (total_runs, 100 * pass_runs / total_runs if total_runs else 0,
|
|
1610
|
-
min(total_runs, 20)))
|
|
1611
|
-
last = records[0]
|
|
1612
|
-
if last.get('failed_cases'):
|
|
1613
|
-
print('最近一次失败用例: %s' % ', '.join(last['failed_cases']))
|
|
1614
|
-
return 0
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
# ---------------------------------------------------------------------------
|
|
1618
|
-
# argparse
|
|
1619
|
-
# ---------------------------------------------------------------------------
|
|
1620
|
-
|
|
1621
|
-
def build_parser():
|
|
1622
|
-
p = argparse.ArgumentParser(
|
|
1623
|
-
prog='autotest.py',
|
|
1624
|
-
description='浏览器自动化测试: 用例生成 + 运行编排 (QoderWork 浏览器执行, 纯 Qoder 降级人工)',
|
|
1625
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1626
|
-
)
|
|
1627
|
-
sub = p.add_subparsers(dest='cmd')
|
|
1628
|
-
|
|
1629
|
-
def add_env_platform(sp):
|
|
1630
|
-
sp.add_argument('--env', default=_default_env(),
|
|
1631
|
-
choices=SUPPORTED_ENVS,
|
|
1632
|
-
help='环境 (test/pre/prod), 默认 %(default)s')
|
|
1633
|
-
sp.add_argument('--platform', default='web',
|
|
1634
|
-
choices=SUPPORTED_PLATFORMS,
|
|
1635
|
-
help='平台 (web=管理端 / app=移动端), 默认 %(default)s')
|
|
1636
|
-
|
|
1637
|
-
pg = sub.add_parser('generate', help='从任务生成测试用例骨架')
|
|
1638
|
-
pg.add_argument('task', help='任务名/路径 (如 06-14-login)')
|
|
1639
|
-
add_env_platform(pg)
|
|
1640
|
-
pg.set_defaults(func=cmd_generate)
|
|
1641
|
-
|
|
1642
|
-
pr = sub.add_parser('run', help='生成(若无)+输出浏览器执行指令/人工清单')
|
|
1643
|
-
pr.add_argument('task', help='任务名/路径')
|
|
1644
|
-
add_env_platform(pr)
|
|
1645
|
-
pr.add_argument('--url', default=None,
|
|
1646
|
-
help='临时覆盖目标域名 (优先于 --env 配置)')
|
|
1647
|
-
pr.add_argument('--case', nargs='*', default=None,
|
|
1648
|
-
help='只执行指定 case_id (可多个)')
|
|
1649
|
-
pr.add_argument('--data', default=None,
|
|
1650
|
-
help='测试数据 JSON, 注入 {{ask:描述}} 占位, '
|
|
1651
|
-
'如 \'{"登录账号":"test","登录密码":"123456"}\'')
|
|
1652
|
-
pr.add_argument('--record', default=None,
|
|
1653
|
-
help='回传执行结果 JSON, 如 \'{"AT-x-001":"pass"}\'')
|
|
1654
|
-
pr.set_defaults(func=cmd_run)
|
|
1655
|
-
|
|
1656
|
-
pq = sub.add_parser('quick', help='快速测试: 不需任务/PRD, AI 直接据描述生成并执行')
|
|
1657
|
-
pq.add_argument('--cases', default=None,
|
|
1658
|
-
help='JSON 用例 (单条对象或数组), AI 据用户描述生成后传入')
|
|
1659
|
-
pq.add_argument('--cases-file', dest='cases_file', default=None,
|
|
1660
|
-
help='用例 JSON 文件路径(推荐, 不截断)。AI 写文件后传路径')
|
|
1661
|
-
pq.add_argument('--desc', default=None,
|
|
1662
|
-
help='本次快速测试的描述/标题 (可选)')
|
|
1663
|
-
add_env_platform(pq)
|
|
1664
|
-
pq.add_argument('--url', default=None, help='临时覆盖目标域名')
|
|
1665
|
-
pq.add_argument('--data', default=None, help='测试数据 JSON, 注入 {{ask:描述}}')
|
|
1666
|
-
pq.add_argument('--record', default=None, help='回传执行结果 JSON')
|
|
1667
|
-
pq.add_argument('--page-json', nargs='*', default=None,
|
|
1668
|
-
help='read_page 返回的页面元素 JSON(多个), --record 时自动沉淀锚点')
|
|
1669
|
-
pq.set_defaults(func=cmd_quick)
|
|
1670
|
-
|
|
1671
|
-
pl = sub.add_parser('list', help='列出任务已有用例')
|
|
1672
|
-
pl.add_argument('task', help='任务名/路径')
|
|
1673
|
-
pl.set_defaults(func=cmd_list)
|
|
1674
|
-
|
|
1675
|
-
pr2 = sub.add_parser('report', help='查看任务测试执行历史')
|
|
1676
|
-
pr2.add_argument('task', help='任务名/路径')
|
|
1677
|
-
pr2.set_defaults(func=cmd_report)
|
|
1678
|
-
|
|
1679
|
-
psd = sub.add_parser('set-data', help='预置个人测试数据到个人文档(gitignored, 不共享)')
|
|
1680
|
-
psd.add_argument('key', help='数据名(对应用例 {{ask:数据名}} 占位), 如 登录账号')
|
|
1681
|
-
psd.add_argument('value', help='数据值, 如 test')
|
|
1682
|
-
psd.add_argument('--env', default=None, choices=SUPPORTED_ENVS,
|
|
1683
|
-
help='只对该环境生效(如 test/pre/prod)')
|
|
1684
|
-
psd.add_argument('--platform', default=None, choices=SUPPORTED_PLATFORMS,
|
|
1685
|
-
help='只对该平台生效(web/app)')
|
|
1686
|
-
psd.set_defaults(func=cmd_set_data)
|
|
1687
|
-
|
|
1688
|
-
pld = sub.add_parser('list-data', help='列出个人已预置的测试数据(可按 env/platform 过滤)')
|
|
1689
|
-
pld.add_argument('--env', default=None, choices=SUPPORTED_ENVS)
|
|
1690
|
-
pld.add_argument('--platform', default=None, choices=SUPPORTED_PLATFORMS)
|
|
1691
|
-
pld.set_defaults(func=cmd_list_data)
|
|
1692
|
-
|
|
1693
|
-
pid = sub.add_parser('init-data', help='生成个人测试数据模板文档(members/{dev}/autotest-data.yaml)')
|
|
1694
|
-
pid.set_defaults(func=cmd_init_data)
|
|
1695
|
-
|
|
1696
|
-
pls = sub.add_parser('login-state', help='登录态: 已登录就别重复登录(QoderWork 会话保持)')
|
|
1697
|
-
pls.add_argument('action', nargs='?', default='show',
|
|
1698
|
-
choices=['show', 'mark', 'clear'],
|
|
1699
|
-
help='show=查看 / mark=标记已登录 / clear=清除(强制重新登录)')
|
|
1700
|
-
add_env_platform(pls)
|
|
1701
|
-
pls.set_defaults(func=cmd_login_state)
|
|
1702
|
-
|
|
1703
|
-
ple = sub.add_parser('learn', help='沉淀页面锚点到 test-pages.json(observe后调, 团队共享)')
|
|
1704
|
-
ple.add_argument('--url', required=True, help='页面 URL 或路径(如 /auth/login 或完整URL)')
|
|
1705
|
-
ple.add_argument('--page-json', required=True,
|
|
1706
|
-
help="read_page 返回的元素 JSON (数组 或 {elements:[...]})")
|
|
1707
|
-
ple.add_argument('--intent-map', default=None,
|
|
1708
|
-
help='可选: {元素描述:{intent,anchor}} 标注意图')
|
|
1709
|
-
ple.set_defaults(func=cmd_learn)
|
|
1710
|
-
|
|
1711
|
-
prc = sub.add_parser('recall', help='查某页已沉淀的锚点(生成用例前查, 跳过LLM匹配)')
|
|
1712
|
-
prc.add_argument('--url', required=True, help='页面 URL 或路径(如 /auth/login)')
|
|
1713
|
-
prc.set_defaults(func=cmd_recall)
|
|
1714
|
-
|
|
1715
|
-
# page: 查页面按钮+接口画像 (开发→测试交接物, 数据来自知识图谱 traces 表)
|
|
1716
|
-
ppg = sub.add_parser('page', help='查页面按钮+接口画像(开发交接给测试,照着写用例)')
|
|
1717
|
-
ppg_grp = ppg.add_mutually_exclusive_group(required=True)
|
|
1718
|
-
ppg_grp.add_argument('--kw', help='业务关键词(如 保险 / 排班)')
|
|
1719
|
-
ppg_grp.add_argument('--url', help='页面 URL 或路径关键词(如 /veh/insurance)')
|
|
1720
|
-
ppg.set_defaults(func=cmd_page)
|
|
1721
|
-
|
|
1722
|
-
return p
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
def _default_env():
|
|
1726
|
-
"""config.yaml autotest.default_env, 缺失回退 test。"""
|
|
1727
|
-
try:
|
|
1728
|
-
return load_config_section('autotest').get('default_env') or 'test'
|
|
1729
|
-
except Exception:
|
|
1730
|
-
return 'test'
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
def main(argv=None):
|
|
1734
|
-
parser = build_parser()
|
|
1735
|
-
args = parser.parse_args(argv)
|
|
1736
|
-
if not getattr(args, 'cmd', None):
|
|
1737
|
-
parser.print_help()
|
|
1738
|
-
return 1
|
|
1739
|
-
try:
|
|
1740
|
-
return args.func(args)
|
|
1741
|
-
except RuntimeError as e:
|
|
1742
|
-
# 身份缺失等 (多人共用 workspace 时的隔离保障)
|
|
1743
|
-
print('✗ %s' % e)
|
|
1744
|
-
return 1
|
|
1745
|
-
except (ValueError, FileNotFoundError) as e:
|
|
1746
|
-
print('✗ %s' % e)
|
|
1747
|
-
return 1
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
if __name__ == '__main__':
|
|
1751
|
-
sys.exit(main())
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# v3.0 路径自举: 引导到 common/bootstrap, 统一 sys.path 逻辑
|
|
4
|
+
import os as _o, sys as _s
|
|
5
|
+
_f = _o.path.abspath(__file__)
|
|
6
|
+
for _ in range(10):
|
|
7
|
+
_f = _o.path.dirname(_f)
|
|
8
|
+
_cp = _o.path.join(_f, 'foundation')
|
|
9
|
+
if _o.path.isfile(_o.path.join(_cp, 'bootstrap.py')):
|
|
10
|
+
break
|
|
11
|
+
if _cp not in _s.path: _s.path.insert(0, _cp)
|
|
12
|
+
from bootstrap import setup; setup()
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
QODER Pipeline - 浏览器自动化测试 (用例生成 + 运行编排)
|
|
16
|
+
|
|
17
|
+
设计原则 (遵循规则 8: QoderWork 只做可选增强, 绝不强依赖):
|
|
18
|
+
- 本脚本是"用例生成器 + 运行编排器", 不内嵌任何浏览器驱动库
|
|
19
|
+
(不 import playwright/selenium —— PM 本机装不动)。
|
|
20
|
+
- 真正"开浏览器点击"由 QoderWork 的 Browser Use / Computer 控制连接器
|
|
21
|
+
在运行时承担 (Agent 读 run 指令后调用连接器)。
|
|
22
|
+
- 纯 Qoder IDE / CLI 无连接器 → 静默降级为"人工核对清单", 不报错不阻塞。
|
|
23
|
+
- 本地 (workspace/tasks/<task>/autotest-cases.jsonl) 是唯一事实源。
|
|
24
|
+
|
|
25
|
+
用例格式 (autotest-cases.jsonl, 每行一条) —— 统一用意图式(intent+anchor), 禁止 CSS selector:
|
|
26
|
+
|
|
27
|
+
intent 取值: goto / observe / click / fill / extract / assert / ask_human
|
|
28
|
+
anchor 取值: {role, name, placeholder, text} 稳定锚点(来自 a11y 树, 不随刷新变)
|
|
29
|
+
无锚点时 anchor 留空或省略, 执行时靠 snapshot 语义匹配(LLM 兜底)。
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
"case_id": "AT-login-001",
|
|
33
|
+
"title": "正确账号密码登录成功",
|
|
34
|
+
"platform": "web", "env": "test",
|
|
35
|
+
"preconditions": "已注册账号 test/123456",
|
|
36
|
+
"steps": [
|
|
37
|
+
{"intent":"goto","desc":"登录页","value":"{{base_url}}/auth/login"},
|
|
38
|
+
{"intent":"fill","desc":"账号输入框","value":"{{ask:登录账号}}","anchor":{"role":"textbox","name":"尊敬的管理员"}},
|
|
39
|
+
{"intent":"fill","desc":"密码输入框","value":"{{ask:登录密码}}","anchor":{"role":"textbox","name":"请输入您的密码"}},
|
|
40
|
+
{"intent":"click","desc":"登录按钮","anchor":{"role":"button","name":"登录"}},
|
|
41
|
+
{"intent":"extract","desc":"是否已离开登录页进入系统"}
|
|
42
|
+
],
|
|
43
|
+
"expected": "登录成功进入首页"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
⚠️ URL 必须写成 {{base_url}}/auth/login (带斜杠)。base_url 已去尾斜杠。
|
|
47
|
+
⚠️ 首次跑无锚点时, AI 应先 navigate+snapshot 拿到真实 anchor 再填, 禁止预猜 CSS selector。
|
|
48
|
+
⚠️ 账号/密码等真实数据用 {{ask:描述}} 占位, 绝不编造; 执行时从个人文档注入。
|
|
49
|
+
|
|
50
|
+
占位符 (run 时注入):
|
|
51
|
+
{{base_url}} — 按环境(test/pre/prod)解析的目标域名(已去尾斜杠)
|
|
52
|
+
{{ask:描述}} — 需要用户提供的测试数据(账号/密码/手机号等)。
|
|
53
|
+
生成用例时: 凡是需要真实测试数据的, 用 {{ask:描述}} 占位, 绝不编造。
|
|
54
|
+
执行时: 用 --data '{"登录账号":"test","登录密码":"123456"}' 传入;
|
|
55
|
+
未提供的会列清单提示补齐后再执行。
|
|
56
|
+
敏感数据(密码/验证码)建议走 --data 命令传, 避免明文进对话记录。
|
|
57
|
+
|
|
58
|
+
⚠️ recall 查锚点时, --url 用相对路径(如 auth/login)避开 Git-bash 路径转换坑
|
|
59
|
+
(Windows 上 /auth/login 会被 MSYS 转成 /D:/.../Git/auth/login)。
|
|
60
|
+
|
|
61
|
+
域名解析优先级: --url > --env(查 config.yaml) > 报错
|
|
62
|
+
|
|
63
|
+
Usage:
|
|
64
|
+
python autotest.py generate <task> [--env test|pre|prod] [--platform web|app]
|
|
65
|
+
python autotest.py run <task> [--env ...] [--url <url>] [--platform ...]
|
|
66
|
+
[--case <case_id> ...] [--data '<json>']
|
|
67
|
+
[--record <result>]
|
|
68
|
+
python autotest.py quick --cases '<JSON用例>' [--desc '<描述>'] [--env ...]
|
|
69
|
+
[--url <url>] [--platform ...] [--data '<json>']
|
|
70
|
+
[--record <result>] # 快速测试, 不需任务/PRD
|
|
71
|
+
python autotest.py list <task>
|
|
72
|
+
python autotest.py report <task>
|
|
73
|
+
|
|
74
|
+
Exit: 0 = 成功, 1 = 一般错误(含缺测试数据)
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
import argparse
|
|
78
|
+
import json
|
|
79
|
+
import os
|
|
80
|
+
import sys
|
|
81
|
+
from datetime import datetime
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
|
|
84
|
+
if sys.platform == 'win32':
|
|
85
|
+
try:
|
|
86
|
+
sys.stdout.reconfigure(encoding='utf-8')
|
|
87
|
+
except (AttributeError, IOError):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
91
|
+
|
|
92
|
+
from foundation.core.paths import get_repo_root, get_tasks_dir, get_developer, MEMBERS_DIR, index_path # noqa: E402
|
|
93
|
+
from foundation.data.task_utils import load_task_json, resolve_task_dir # noqa: E402
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
# v3.0 f5: 数据层拆到 autotest_data.py
|
|
98
|
+
from autotest_data import * # noqa: F401,F403 (常量+数据函数)
|
|
99
|
+
|
|
100
|
+
def _resolve_env_platform(args):
|
|
101
|
+
"""解析域名 + 校验。返回 (base, src) 或 (None, None)。"""
|
|
102
|
+
base, src = resolve_base_url(args.env, args.platform, override=getattr(args, 'url', None))
|
|
103
|
+
if base is None:
|
|
104
|
+
print('✗ %s 环境未配 base_url, 无法执行测试。' % args.env)
|
|
105
|
+
print(' 解决方式 (任选其一):')
|
|
106
|
+
print(' 1. 命令行临时指定: --url https://你的地址')
|
|
107
|
+
print(' 2. 配 config.yaml: autotest.environments.%s.%s.base_url'
|
|
108
|
+
% (args.env, args.platform))
|
|
109
|
+
return None, None
|
|
110
|
+
return base, src
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _inject_and_check_data(cases, args):
|
|
114
|
+
"""注入 base_url + 测试数据, 检查完整性。返回 (cases, ok)。ok=False 表示缺数据已提示。"""
|
|
115
|
+
cases = _inject_base_url(cases, getattr(args, '_base', ''))
|
|
116
|
+
local_data = load_local_test_data(env=args.env, platform=args.platform)
|
|
117
|
+
try:
|
|
118
|
+
cli_data = json.loads(args.data) if getattr(args, 'data', None) else {}
|
|
119
|
+
except json.JSONDecodeError as e:
|
|
120
|
+
print('✗ --data 不是合法 JSON: %s' % e)
|
|
121
|
+
print(' 示例: --data \'{"登录账号":"test","登录密码":"123456"}\'')
|
|
122
|
+
return cases, False
|
|
123
|
+
merged = dict(local_data)
|
|
124
|
+
merged.update(cli_data)
|
|
125
|
+
cases, missing = inject_data(cases, merged)
|
|
126
|
+
if missing:
|
|
127
|
+
print('=' * 60)
|
|
128
|
+
print('⚠️ 需要测试数据才能执行 (%d 项)' % len(missing))
|
|
129
|
+
print('=' * 60)
|
|
130
|
+
print('以下用例引用了真实测试数据(账号/密码/手机号等), 不能编造:')
|
|
131
|
+
for i, desc in enumerate(missing, 1):
|
|
132
|
+
print(' %d. %s' % (i, desc))
|
|
133
|
+
print('-' * 60)
|
|
134
|
+
print('请向用户收集后, 用 --data 传入:')
|
|
135
|
+
print(' --data \'{"%s":"<值>", "%s":"<值>"}\''
|
|
136
|
+
% (missing[0], missing[1] if len(missing) > 1 else missing[0]))
|
|
137
|
+
print()
|
|
138
|
+
print('常用账号预置到个人文档(不进 git, 团队看不到):')
|
|
139
|
+
print(' python .qoder/scripts/autotest.py set-data 登录账号 test --env test')
|
|
140
|
+
return cases, False
|
|
141
|
+
return cases, True
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _fmt_anchor(anc):
|
|
145
|
+
"""格式化 anchor 为简短可读串, 如 "button '登录'" 或 "placeholder='尊敬的管理员'"。"""
|
|
146
|
+
if not isinstance(anc, dict) or not anc:
|
|
147
|
+
return ''
|
|
148
|
+
parts = []
|
|
149
|
+
for k in ('role', 'name', 'placeholder', 'text'):
|
|
150
|
+
v = anc.get(k)
|
|
151
|
+
if v:
|
|
152
|
+
parts.append("%s='%s'" % (k, v))
|
|
153
|
+
return ' '.join(parts)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _fmt_step(s, with_value=True):
|
|
157
|
+
"""格式化单步为可读文本。支持新意图式(intent+anchor)和旧命令式(action+target)。"""
|
|
158
|
+
desc = s.get('desc', '')
|
|
159
|
+
# ── 新意图式 ──
|
|
160
|
+
if is_intent_step(s):
|
|
161
|
+
it = s.get('intent', '?')
|
|
162
|
+
anc = s.get('anchor')
|
|
163
|
+
val = s.get('value')
|
|
164
|
+
anchor_str = _fmt_anchor(anc)
|
|
165
|
+
if it == 'ask_human':
|
|
166
|
+
return '%-12s ⏸ %s' % ('人机协同', desc or '(请用户操作)')
|
|
167
|
+
if it == 'extract':
|
|
168
|
+
return '%-12s ✓ %s' % ('extract', desc or '(语义断言)')
|
|
169
|
+
if it == 'observe':
|
|
170
|
+
return '%-12s 👁 %s' % ('observe', desc or '读取页面可交互元素')
|
|
171
|
+
if it == 'goto':
|
|
172
|
+
return '%-12s → %s' % ('goto', val or desc or '')
|
|
173
|
+
# click / fill / assert
|
|
174
|
+
tag = '[锚点: %s]' % anchor_str if anchor_str else '(待匹配·LLM兜底)'
|
|
175
|
+
extra = ''
|
|
176
|
+
if val is not None:
|
|
177
|
+
extra = ' 输入=%s' % val
|
|
178
|
+
return '%-12s %s %s%s' % (it, tag, desc, extra)
|
|
179
|
+
# ── 旧命令式(兼容) ──
|
|
180
|
+
act = s.get('action', '?')
|
|
181
|
+
tgt = s.get('target', '')
|
|
182
|
+
val = s.get('value')
|
|
183
|
+
if act == 'solve_captcha':
|
|
184
|
+
fill = s.get('fill_target', '')
|
|
185
|
+
detail = '截图 %s → 用视觉识别(算数学题) → 答案填入 %s' % (tgt, fill)
|
|
186
|
+
if desc:
|
|
187
|
+
detail += ' (%s)' % desc
|
|
188
|
+
return '%-16s %s' % (act, detail)
|
|
189
|
+
detail = tgt
|
|
190
|
+
if with_value and val is not None:
|
|
191
|
+
detail = '%s 输入=%s' % (tgt, val)
|
|
192
|
+
return '%-16s %s%s' % (act, detail, (' — ' + desc) if desc else '')
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _print_execution_plan(cases, base, src, env, platform, title, record_hint):
|
|
196
|
+
"""打印执行编排指令 (QoderWork 浏览器执行 / 纯 Qoder 降级人工核对)。
|
|
197
|
+
|
|
198
|
+
record_hint: 回收结果时的命令提示文本 (run 用任务名, quick 用 --quick)。
|
|
199
|
+
"""
|
|
200
|
+
is_qw, qw_hint = _detect_qoderwork()
|
|
201
|
+
print('=' * 60)
|
|
202
|
+
print('浏览器自动化测试 · 执行编排')
|
|
203
|
+
print('=' * 60)
|
|
204
|
+
print('测试: %s' % title)
|
|
205
|
+
print('环境: %s 平台: %s' % (env, platform))
|
|
206
|
+
print('目标: %s (来源: %s)' % (base, src))
|
|
207
|
+
print('用例: %d 条' % len(cases))
|
|
208
|
+
print('-' * 60)
|
|
209
|
+
|
|
210
|
+
if is_qw:
|
|
211
|
+
print()
|
|
212
|
+
print('🧩 QoderWork 浏览器执行 (已检测到: %s)' % qw_hint)
|
|
213
|
+
print('请用 Browser Use / Computer 控制连接器, 按下列要求执行:')
|
|
214
|
+
print()
|
|
215
|
+
print('1. 打开浏览器, 访问: %s' % base)
|
|
216
|
+
print('2. 逐条执行用例 (共 %d 条):' % len(cases))
|
|
217
|
+
for c in cases:
|
|
218
|
+
print(' ▸ %s %s' % (c.get('case_id', '?'), c.get('title', '')))
|
|
219
|
+
for s in (c.get('steps') or [])[:6]:
|
|
220
|
+
print(' %s' % _fmt_step(s))
|
|
221
|
+
print(' 预期: %s' % c.get('expected', ''))
|
|
222
|
+
print()
|
|
223
|
+
print('3. 每条用例记录 pass/fail, 跑完回传结果:')
|
|
224
|
+
print(' %s' % record_hint)
|
|
225
|
+
print(' (或直接把结果贴给我, 我帮你出报告)')
|
|
226
|
+
else:
|
|
227
|
+
print()
|
|
228
|
+
print('[非 QoderWork 或未授权浏览器连接器 → 降级为人工核对]')
|
|
229
|
+
print('目标地址: %s' % base)
|
|
230
|
+
print()
|
|
231
|
+
for c in cases:
|
|
232
|
+
print('□ %s %s' % (c.get('case_id', '?'), c.get('title', '')))
|
|
233
|
+
for s in (c.get('steps') or [])[:4]:
|
|
234
|
+
print(' %s' % _fmt_step(s))
|
|
235
|
+
print(' 预期: %s' % c.get('expected', ''))
|
|
236
|
+
print()
|
|
237
|
+
print('在 QoderWork 桌面端 (Settings → Connectors 授权浏览器/Computer)')
|
|
238
|
+
print('可让 Agent 自动执行; 结果用 --record 回传出报告。')
|
|
239
|
+
print('-' * 60)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# 子命令: generate
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def cmd_generate(args):
|
|
247
|
+
task_dir = resolve_task_dir(args.task)
|
|
248
|
+
if not task_dir.is_dir():
|
|
249
|
+
print('任务不存在: %s' % args.task)
|
|
250
|
+
print('提示: python .qoder/scripts/task.py list 查看已有任务')
|
|
251
|
+
return 1
|
|
252
|
+
task = load_task_json(task_dir) or {}
|
|
253
|
+
|
|
254
|
+
env = args.env
|
|
255
|
+
base, src = resolve_base_url(env, args.platform)
|
|
256
|
+
# generate 不强求域名 (用例用 {{base_url}} 占位), 但提示一下
|
|
257
|
+
if base is None:
|
|
258
|
+
print('提示: %s 环境未配 base_url, 用例将用 {{base_url}} 占位, '
|
|
259
|
+
'run 时再注入 (或在 config.yaml autotest.environments 配置)。'
|
|
260
|
+
% env)
|
|
261
|
+
|
|
262
|
+
new_cases = generate_skeleton(task_dir, env, args.platform)
|
|
263
|
+
all_cases = read_cases(task_dir)
|
|
264
|
+
ok, warns = validate_cases(all_cases)
|
|
265
|
+
|
|
266
|
+
print('已生成 %d 条用例骨架 → %s' % (len(new_cases), _cases_path(task_dir)))
|
|
267
|
+
print('任务: %s 环境: %s 平台: %s' % (
|
|
268
|
+
task.get('title', Path(task_dir).name), env, args.platform))
|
|
269
|
+
print()
|
|
270
|
+
print('⚠️ 这些是骨架占位。请按任务 PRD「验收标准」补充每条的:')
|
|
271
|
+
print(' - title: 用例标题')
|
|
272
|
+
print(' - steps: 操作步骤 (goto/click/fill/assert_visible ...)')
|
|
273
|
+
print(' - expected: 预期结果')
|
|
274
|
+
print(' (用 /wl-test 触发 AI 自动据 PRD 填充)')
|
|
275
|
+
if warns:
|
|
276
|
+
print()
|
|
277
|
+
print('校验提醒:')
|
|
278
|
+
for w in warns:
|
|
279
|
+
print(' - ' + w)
|
|
280
|
+
print()
|
|
281
|
+
print('下一步: /wl-test run %s --env %s (生成+执行)'
|
|
282
|
+
% (Path(task_dir).name, env))
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# 子命令: run
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def _detect_qoderwork():
|
|
291
|
+
"""探测当前是否在 QoderWork 环境 (有浏览器连接器)。
|
|
292
|
+
|
|
293
|
+
QoderWork 会暴露运行时工具/环境标记。探测而非假设: 看不到就回退。
|
|
294
|
+
返回 (is_qoderwork: bool, hint: str)
|
|
295
|
+
"""
|
|
296
|
+
# 1. 显式环境变量标记 (QoderWork 桌面端/CLI 会设置)
|
|
297
|
+
for key in ('QODERWORK_SESSION', 'QODER_WORK', 'QW_CONNECTOR'):
|
|
298
|
+
if os.environ.get(key):
|
|
299
|
+
return True, 'env:' + key
|
|
300
|
+
# 2. QoderWork 配置目录存在 (~/.qoderwork/)
|
|
301
|
+
home = os.path.expanduser('~')
|
|
302
|
+
if os.path.isdir(os.path.join(home, '.qoderwork')):
|
|
303
|
+
return True, '~/.qoderwork'
|
|
304
|
+
return False, ''
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _iter_injectable_strings(s):
|
|
308
|
+
"""遍历一个 step 里所有可做占位符替换的字符串字段, yield (setter, value)。
|
|
309
|
+
|
|
310
|
+
覆盖旧格式(target/value/desc) + 新格式(intent式 value/desc/anchor.* )。
|
|
311
|
+
setter 是个闭包, 传新值回去。anchor(dict) 递归到它的每个 str 值。
|
|
312
|
+
"""
|
|
313
|
+
for field in ('target', 'value', 'desc'):
|
|
314
|
+
val = s.get(field)
|
|
315
|
+
if isinstance(val, str):
|
|
316
|
+
yield (lambda v, f=field: s.__setitem__(f, v)), val
|
|
317
|
+
# anchor (新格式): dict, 每个值都可能是占位符串
|
|
318
|
+
anc = s.get('anchor')
|
|
319
|
+
if isinstance(anc, dict):
|
|
320
|
+
for k, v in list(anc.items()):
|
|
321
|
+
if isinstance(v, str):
|
|
322
|
+
yield (lambda nv, kk=k: anc.__setitem__(kk, nv)), v
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _inject_base_url(cases, base_url):
|
|
326
|
+
"""把 steps 里的 {{base_url}} 占位替换为真实域名(覆盖 anchor.*)。
|
|
327
|
+
|
|
328
|
+
base_url 已去尾斜杠(见 resolve_base_url), 所以用例里必须写成
|
|
329
|
+
{{base_url}}/auth/login (带斜杠)。若检测到 {{base_url}} 后紧跟非斜杠字符
|
|
330
|
+
(会拼成 https://xxxauth/login), 打印告警提醒——不阻断, 但提示 AI 修正。
|
|
331
|
+
"""
|
|
332
|
+
import re as _re
|
|
333
|
+
bad_pat = _re.compile(r'\{\{base_url\}\}([^\s/&?#])') # {{base_url}}后紧跟非分隔符 = 少斜杠
|
|
334
|
+
warned = False
|
|
335
|
+
for c in cases:
|
|
336
|
+
for s in c.get('steps') or []:
|
|
337
|
+
for setter, val in _iter_injectable_strings(s):
|
|
338
|
+
if isinstance(val, str) and '{{base_url}}' in val:
|
|
339
|
+
if not warned and bad_pat.search(val):
|
|
340
|
+
warned = True
|
|
341
|
+
setter(val.replace('{{base_url}}', base_url))
|
|
342
|
+
if warned:
|
|
343
|
+
print('⚠️ 检测到 {{base_url}} 后缺少斜杠(如 {{base_url}}auth/login)。'
|
|
344
|
+
'base_url 已去尾斜杠, 正确写法是 {{base_url}}/auth/login。',
|
|
345
|
+
file=sys.stderr)
|
|
346
|
+
print(' 本次已按原样拼接(可能产生 https://xxxauth/login 这样的坏地址), '
|
|
347
|
+
'请修正用例后重跑。', file=sys.stderr)
|
|
348
|
+
return cases
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def collect_ask_placeholders(cases):
|
|
352
|
+
"""扫出全部 {{ask:描述}} 占位, 返回去重后的描述列表(保持出现顺序)。"""
|
|
353
|
+
import re as _re
|
|
354
|
+
asks = []
|
|
355
|
+
seen = set()
|
|
356
|
+
pat = _re.compile(r'\{\{ask:([^}]+)\}\}')
|
|
357
|
+
for c in cases:
|
|
358
|
+
for s in c.get('steps') or []:
|
|
359
|
+
for setter, val in _iter_injectable_strings(s):
|
|
360
|
+
if not isinstance(val, str):
|
|
361
|
+
continue
|
|
362
|
+
for desc in pat.findall(val):
|
|
363
|
+
if desc.strip() not in seen:
|
|
364
|
+
seen.add(desc.strip())
|
|
365
|
+
asks.append(desc.strip())
|
|
366
|
+
return asks
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def inject_data(cases, data):
|
|
370
|
+
"""把 {{ask:描述}} 用 data 字典注入(覆盖 anchor.*)。返回 (cases, still_missing)。"""
|
|
371
|
+
import re as _re
|
|
372
|
+
pat = _re.compile(r'\{\{ask:([^}]+)\}\}')
|
|
373
|
+
provided = {k.strip(): v for k, v in (data or {}).items()}
|
|
374
|
+
still_missing = []
|
|
375
|
+
for c in cases:
|
|
376
|
+
for s in c.get('steps') or []:
|
|
377
|
+
for setter, val in _iter_injectable_strings(s):
|
|
378
|
+
if not isinstance(val, str) or '{{ask:' not in val:
|
|
379
|
+
continue
|
|
380
|
+
for desc in pat.findall(val):
|
|
381
|
+
key = desc.strip()
|
|
382
|
+
if key in provided:
|
|
383
|
+
val = val.replace('{{ask:%s}}' % desc, str(provided[key]))
|
|
384
|
+
for desc in pat.findall(val):
|
|
385
|
+
if desc.strip() not in provided and desc.strip() not in still_missing:
|
|
386
|
+
still_missing.append(desc.strip())
|
|
387
|
+
setter(val)
|
|
388
|
+
return cases, still_missing
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def cmd_quick(args):
|
|
392
|
+
"""快速测试: 不需要任务/PRD。AI 据描述直接生成用例并执行。
|
|
393
|
+
|
|
394
|
+
用法: autotest.py quick --cases '<JSON用例>' --env test --platform web
|
|
395
|
+
(AI 把据用户描述生成的用例, 以 JSON 字符串传入; 脚本负责注入+执行编排)
|
|
396
|
+
|
|
397
|
+
也支持 --record 回收结果 (临时用例, 不落 task 历史, 只打印汇总)。
|
|
398
|
+
"""
|
|
399
|
+
# 1. 解析用例: 优先 --cases-file (文件, 不截断), 其次 --cases (内联, 短用例)
|
|
400
|
+
raw = ''
|
|
401
|
+
cases_file = getattr(args, 'cases_file', None)
|
|
402
|
+
if cases_file:
|
|
403
|
+
try:
|
|
404
|
+
raw = open(cases_file, encoding='utf-8').read().strip()
|
|
405
|
+
except (OSError, IOError) as e:
|
|
406
|
+
print('✗ 读 --cases-file 失败: %s' % e)
|
|
407
|
+
return 1
|
|
408
|
+
if not raw:
|
|
409
|
+
raw = (args.cases or '').strip()
|
|
410
|
+
if not raw:
|
|
411
|
+
print('✗ quick 模式需要用例 JSON。用文件传(推荐, 不截断):')
|
|
412
|
+
print(' --cases-file <路径> # AI 把用例 JSON 写文件, 再传路径')
|
|
413
|
+
print('或内联传(短用例): --cases \'<JSON>\'')
|
|
414
|
+
return 1
|
|
415
|
+
try:
|
|
416
|
+
parsed = json.loads(raw)
|
|
417
|
+
except json.JSONDecodeError as e:
|
|
418
|
+
print('✗ 用例 JSON 解析失败: %s' % e)
|
|
419
|
+
if cases_file:
|
|
420
|
+
print(' (来自文件: %s)' % cases_file)
|
|
421
|
+
return 1
|
|
422
|
+
cases = parsed if isinstance(parsed, list) else [parsed]
|
|
423
|
+
# 规范化: 补默认字段
|
|
424
|
+
for i, c in enumerate(cases):
|
|
425
|
+
if not isinstance(c, dict):
|
|
426
|
+
print('✗ 第 %d 条用例不是对象' % (i + 1))
|
|
427
|
+
return 1
|
|
428
|
+
c.setdefault('case_id', 'Q-%d' % (i + 1))
|
|
429
|
+
c.setdefault('platform', args.platform)
|
|
430
|
+
c.setdefault('env', args.env)
|
|
431
|
+
c.setdefault('steps', [])
|
|
432
|
+
c.setdefault('expected', '')
|
|
433
|
+
|
|
434
|
+
# 2. 回收结果模式 (临时用例, 只打印汇总)
|
|
435
|
+
if args.record:
|
|
436
|
+
try:
|
|
437
|
+
results = json.loads(args.record)
|
|
438
|
+
except json.JSONDecodeError as e:
|
|
439
|
+
print('✗ --record 不是合法 JSON: %s' % e)
|
|
440
|
+
return 1
|
|
441
|
+
total = len(results)
|
|
442
|
+
passed = sum(1 for v in results.values()
|
|
443
|
+
if str(v).lower() in ('pass', 'passed', 'ok', 'true', '1'))
|
|
444
|
+
failed_ids = [k for k, v in results.items()
|
|
445
|
+
if str(v).lower() not in ('pass', 'passed', 'ok', 'true', '1')]
|
|
446
|
+
# 登录用例通过 → 自动标记该 env/platform 已登录 (QoderWork 会话保持, 后续免登录)
|
|
447
|
+
login_passed = False
|
|
448
|
+
for c in cases:
|
|
449
|
+
cid = c.get('case_id', '')
|
|
450
|
+
if str(results.get(cid, '')).lower() in ('pass', 'passed', 'ok', 'true', '1'):
|
|
451
|
+
steps_txt = json.dumps(c.get('steps', []), ensure_ascii=False)
|
|
452
|
+
if '/auth/login' in steps_txt or 'solve_captcha' in steps_txt:
|
|
453
|
+
login_passed = True
|
|
454
|
+
break
|
|
455
|
+
if login_passed:
|
|
456
|
+
base, _ = resolve_base_url(args.env, args.platform)
|
|
457
|
+
if base:
|
|
458
|
+
mark_logged_in(args.env, args.platform, base)
|
|
459
|
+
# 🧠 自动沉淀锚点 (测完就学会, 下次跳过LLM):
|
|
460
|
+
# ① 若传了 --page-json (read_page 结果) → 沉淀该页元素锚点
|
|
461
|
+
# ② 从通过的 intent 式用例里提取 goto URL + anchor → 沉淀 (用例自带锚点也存)
|
|
462
|
+
learned_pages = []
|
|
463
|
+
# ① page-json 方式: 每个 pj 是 {"url":"...", "elements":[...]} 或元素数组
|
|
464
|
+
for pj_raw in (args.page_json or []):
|
|
465
|
+
try:
|
|
466
|
+
parsed = json.loads(pj_raw)
|
|
467
|
+
except Exception:
|
|
468
|
+
continue
|
|
469
|
+
if isinstance(parsed, dict) and 'url' in parsed:
|
|
470
|
+
url_pattern = _normalize_url_pattern(parsed.get('url', ''))
|
|
471
|
+
els = parsed.get('elements', parsed.get('elements', []))
|
|
472
|
+
elif isinstance(parsed, list):
|
|
473
|
+
url_pattern = '/auto'
|
|
474
|
+
els = parsed
|
|
475
|
+
else:
|
|
476
|
+
continue
|
|
477
|
+
new_n, _ = learn_page_anchors(url_pattern, els if isinstance(els, list) else [])
|
|
478
|
+
if new_n:
|
|
479
|
+
learned_pages.append('%s (+%d)' % (url_pattern, new_n))
|
|
480
|
+
# ② 从通过的 intent 用例提取 goto URL + anchor 自动沉淀
|
|
481
|
+
for c in cases:
|
|
482
|
+
cid = c.get('case_id', '')
|
|
483
|
+
if str(results.get(cid, '')).lower() not in ('pass', 'passed', 'ok', 'true', '1'):
|
|
484
|
+
continue # 只沉淀通过用例的锚点 (失败的锚点可能不准)
|
|
485
|
+
current_url = '/auto'
|
|
486
|
+
for s in c.get('steps') or []:
|
|
487
|
+
if not is_intent_step(s):
|
|
488
|
+
continue
|
|
489
|
+
it = s.get('intent', '')
|
|
490
|
+
if it == 'goto' and s.get('value'):
|
|
491
|
+
current_url = _normalize_url_pattern(s['value'])
|
|
492
|
+
elif it in ('click', 'fill') and s.get('anchor'):
|
|
493
|
+
# 用例自带的 anchor → 直接沉淀(执行时验证过有效)
|
|
494
|
+
desc = s.get('desc', '')
|
|
495
|
+
im = {desc: {'intent': it, 'anchor': s['anchor']}} if desc else None
|
|
496
|
+
new_n, _ = learn_page_anchors(current_url, [s], intent_map=im)
|
|
497
|
+
if new_n and current_url not in [p.split(' ')[0] for p in learned_pages]:
|
|
498
|
+
learned_pages.append('%s (+%d)' % (current_url, new_n))
|
|
499
|
+
print('=' * 60)
|
|
500
|
+
print('快速测试结果')
|
|
501
|
+
print('=' * 60)
|
|
502
|
+
print('总计 %d · 通过 %d · 失败 %d' % (total, passed, len(failed_ids)))
|
|
503
|
+
if failed_ids:
|
|
504
|
+
print('失败: %s' % ', '.join(failed_ids))
|
|
505
|
+
if login_passed:
|
|
506
|
+
print('✓ 登录用例通过 → 已标记 %s/%s 为已登录' % (args.env, args.platform))
|
|
507
|
+
print(' 后续测新功能: 直接 goto 目标页, 不用重复登录(验证码慢)。')
|
|
508
|
+
if learned_pages:
|
|
509
|
+
print('🧠 自动沉淀锚点: %s' % ', '.join(learned_pages))
|
|
510
|
+
print(' 下次测同页 recall 命中 → 跳过 LLM → 秒级。')
|
|
511
|
+
print('=' * 60)
|
|
512
|
+
return 0 if not failed_ids else 1
|
|
513
|
+
|
|
514
|
+
# 3. 解析环境
|
|
515
|
+
base, src = _resolve_env_platform(args)
|
|
516
|
+
if base is None:
|
|
517
|
+
return 1
|
|
518
|
+
args._base = base # _inject_and_check_data 用
|
|
519
|
+
|
|
520
|
+
# 4. 注入数据 + 校验
|
|
521
|
+
cases, ok = _inject_and_check_data(cases, args)
|
|
522
|
+
if not ok:
|
|
523
|
+
return 1
|
|
524
|
+
|
|
525
|
+
# 5. 打印执行计划 (登录态提示)
|
|
526
|
+
logged, login_time = is_logged_in(args.env, args.platform)
|
|
527
|
+
title = args.desc or ('快速测试 (%d 条)' % len(cases))
|
|
528
|
+
record_hint = ('python .qoder/scripts/autotest.py quick '
|
|
529
|
+
'--env %s --record \'{"%s":"pass"}\''
|
|
530
|
+
% (args.env, cases[0].get('case_id', 'Q-1')))
|
|
531
|
+
if logged:
|
|
532
|
+
print('=' * 60)
|
|
533
|
+
print('💡 已登录 %s/%s (%s) — QoderWork 会话保持中' % (args.env, args.platform, login_time))
|
|
534
|
+
print(' 浏览器 cookie 有效, 直接 goto 目标页即可, 不用重复登录!')
|
|
535
|
+
print(' (若已过期/被踢, 先 login-state clear 再重新登录)')
|
|
536
|
+
print('=' * 60)
|
|
537
|
+
_print_execution_plan(cases, base, src, args.env, args.platform, title, record_hint)
|
|
538
|
+
return 0
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def cmd_run(args):
|
|
542
|
+
task_dir = resolve_task_dir(args.task)
|
|
543
|
+
if not task_dir.is_dir():
|
|
544
|
+
print('任务不存在: %s' % args.task)
|
|
545
|
+
return 1
|
|
546
|
+
task = load_task_json(task_dir) or {}
|
|
547
|
+
|
|
548
|
+
# 1. 域名解析 (--url > --env > config > 报错)
|
|
549
|
+
base, src = resolve_base_url(args.env, args.platform, override=args.url)
|
|
550
|
+
if base is None:
|
|
551
|
+
print('✗ %s 环境未配 base_url, 无法执行测试。' % args.env)
|
|
552
|
+
print(' 解决方式 (任选其一):')
|
|
553
|
+
print(' 1. 命令行临时指定: --url https://你的地址')
|
|
554
|
+
print(' 2. 配 config.yaml: autotest.environments.%s.%s.base_url'
|
|
555
|
+
% (args.env, args.platform))
|
|
556
|
+
return 1
|
|
557
|
+
|
|
558
|
+
# 2. 取用例 (无则自动生成骨架)
|
|
559
|
+
cases = read_cases(task_dir)
|
|
560
|
+
if not cases:
|
|
561
|
+
print('未找到用例, 自动生成骨架...')
|
|
562
|
+
cases = generate_skeleton(task_dir, args.env, args.platform)
|
|
563
|
+
cases = read_cases(task_dir)
|
|
564
|
+
|
|
565
|
+
# 3. 指定 case_id 过滤
|
|
566
|
+
if args.case:
|
|
567
|
+
want = set(args.case)
|
|
568
|
+
cases = [c for c in cases if c.get('case_id') in want]
|
|
569
|
+
if not cases:
|
|
570
|
+
print('未匹配到指定 case_id: %s' % ', '.join(args.case))
|
|
571
|
+
print(' 已有用例: %s'
|
|
572
|
+
% ', '.join(c.get('case_id', '?')
|
|
573
|
+
for c in read_cases(task_dir)))
|
|
574
|
+
return 1
|
|
575
|
+
|
|
576
|
+
# 4. 占位符注入: 先 base_url, 再测试数据
|
|
577
|
+
cases = _inject_base_url(cases, base)
|
|
578
|
+
# 测试数据 = 个人文档(members/{dev}/autotest-data.yaml, gitignored 不共享)
|
|
579
|
+
# + 命令行 --data(覆盖)。按 env/platform 取最精确的值。
|
|
580
|
+
# ⚠️ 绝不从 config.yaml 读测试账号 —— config 是团队共用会 push, 放账号=泄漏。
|
|
581
|
+
local_data = load_local_test_data(env=args.env, platform=args.platform)
|
|
582
|
+
try:
|
|
583
|
+
cli_data = json.loads(args.data) if args.data else {}
|
|
584
|
+
except json.JSONDecodeError as e:
|
|
585
|
+
print('✗ --data 不是合法 JSON: %s' % e)
|
|
586
|
+
print(' 示例: --data \'{"登录账号":"test","登录密码":"123456"}\'')
|
|
587
|
+
return 1
|
|
588
|
+
merged_data = dict(local_data)
|
|
589
|
+
merged_data.update(cli_data) # 命令行覆盖个人预置
|
|
590
|
+
cases, missing = inject_data(cases, merged_data)
|
|
591
|
+
|
|
592
|
+
# 测试数据完整性检查: 有未注入的 {{ask:...}} → 列清单让 AI 问用户
|
|
593
|
+
if missing:
|
|
594
|
+
print('=' * 60)
|
|
595
|
+
print('⚠️ 需要测试数据才能执行 (%d 项)' % len(missing))
|
|
596
|
+
print('=' * 60)
|
|
597
|
+
print('以下用例引用了真实测试数据(账号/密码/手机号等), 不能编造:')
|
|
598
|
+
for i, desc in enumerate(missing, 1):
|
|
599
|
+
print(' %d. %s' % (i, desc))
|
|
600
|
+
print('-' * 60)
|
|
601
|
+
print('请向用户收集后, 用 --data 传入:')
|
|
602
|
+
print(' --data \'{"%s":"<值>", "%s":"<值>"}\''
|
|
603
|
+
% (missing[0], missing[1] if len(missing) > 1 else missing[0]))
|
|
604
|
+
print()
|
|
605
|
+
print('常用账号预置到个人文档(不进 git, 团队看不到):')
|
|
606
|
+
print(' 先生成: python .qoder/scripts/autotest.py init-data')
|
|
607
|
+
print(' 再填: python .qoder/scripts/autotest.py set-data 登录账号 test')
|
|
608
|
+
return 1
|
|
609
|
+
|
|
610
|
+
# 5. 校验
|
|
611
|
+
ok, warns = validate_cases(cases)
|
|
612
|
+
has_skeleton = any('待补充' in (c.get('title', '') + c.get('expected', ''))
|
|
613
|
+
for c in cases)
|
|
614
|
+
|
|
615
|
+
# 6. 如果调用方已传入执行结果 (--record), 落历史并汇总
|
|
616
|
+
if args.record:
|
|
617
|
+
return _record_and_report(task_dir, args, base, src, args.record)
|
|
618
|
+
|
|
619
|
+
# 7. 探测 QoderWork, 输出执行指令 (核心: 增强层)
|
|
620
|
+
is_qw, qw_hint = _detect_qoderwork()
|
|
621
|
+
|
|
622
|
+
cases_path = _cases_path(task_dir)
|
|
623
|
+
print('=' * 60)
|
|
624
|
+
print('浏览器自动化测试 · 执行编排')
|
|
625
|
+
print('=' * 60)
|
|
626
|
+
print('任务: %s' % task.get('title', Path(task_dir).name))
|
|
627
|
+
print('环境: %s 平台: %s' % (args.env, args.platform))
|
|
628
|
+
print('目标: %s (来源: %s)' % (base, src))
|
|
629
|
+
print('用例: %d 条 → %s' % (len(cases), cases_path))
|
|
630
|
+
if has_skeleton:
|
|
631
|
+
print('⚠️ 含骨架占位用例 (标题/预期标"待补充"), 建议先补全再执行。')
|
|
632
|
+
if warns:
|
|
633
|
+
print('校验提醒:')
|
|
634
|
+
for w in warns[:10]:
|
|
635
|
+
print(' - ' + w)
|
|
636
|
+
print('-' * 60)
|
|
637
|
+
|
|
638
|
+
if is_qw:
|
|
639
|
+
print()
|
|
640
|
+
print('🧩 QoderWork 浏览器执行 (已检测到: %s)' % qw_hint)
|
|
641
|
+
print('请用 Browser Use / Computer 控制连接器, 按下列要求执行:')
|
|
642
|
+
print()
|
|
643
|
+
print('1. 打开浏览器, 访问: %s' % base)
|
|
644
|
+
print('2. 逐条执行 %s 中的用例 (共 %d 条):' % (CASES_FILENAME, len(cases)))
|
|
645
|
+
for c in cases:
|
|
646
|
+
print(' ▸ %s %s' % (c.get('case_id', '?'), c.get('title', '')))
|
|
647
|
+
for s in (c.get('steps') or [])[:6]:
|
|
648
|
+
print(' %s' % _fmt_step(s))
|
|
649
|
+
print(' 预期: %s' % c.get('expected', ''))
|
|
650
|
+
print()
|
|
651
|
+
print('3. 每条用例记录 pass/fail, 全部跑完后回传结果:')
|
|
652
|
+
print(' python .qoder/scripts/autotest.py run %s \\'
|
|
653
|
+
% Path(task_dir).name)
|
|
654
|
+
print(' --env %s --record \'{"AT-...-001":"pass",'
|
|
655
|
+
'"AT-...-002":"fail"}\'' % args.env)
|
|
656
|
+
print(' (或直接把结果贴给我, 我帮你落历史 + 出报告)')
|
|
657
|
+
else:
|
|
658
|
+
print()
|
|
659
|
+
print('[非 QoderWork 或未授权浏览器连接器 → 降级为人工核对]')
|
|
660
|
+
print('用例已生成, 请人工按下方清单逐项检查 (或复制到 QoderWork 执行):')
|
|
661
|
+
print()
|
|
662
|
+
print('目标地址: %s' % base)
|
|
663
|
+
print()
|
|
664
|
+
for c in cases:
|
|
665
|
+
print('□ %s %s' % (c.get('case_id', '?'), c.get('title', '')))
|
|
666
|
+
for s in (c.get('steps') or [])[:4]:
|
|
667
|
+
print(' %s' % _fmt_step(s))
|
|
668
|
+
print(' 预期: %s' % c.get('expected', ''))
|
|
669
|
+
print()
|
|
670
|
+
print('在 QoderWork 桌面端 (Settings → Connectors 授权浏览器/Computer) ')
|
|
671
|
+
print('可让 Agent 自动执行; 结果用 --record 回传出报告。')
|
|
672
|
+
print('-' * 60)
|
|
673
|
+
return 0
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _record_and_report(task_dir, args, base, src, record_json):
|
|
677
|
+
"""把执行结果落历史 jsonl 并打印汇总。"""
|
|
678
|
+
try:
|
|
679
|
+
results = json.loads(record_json)
|
|
680
|
+
except json.JSONDecodeError as e:
|
|
681
|
+
print('✗ --record 不是合法 JSON: %s' % e)
|
|
682
|
+
return 1
|
|
683
|
+
if not isinstance(results, dict):
|
|
684
|
+
print('✗ --record 需为 {case_id: "pass"|"fail"} 形式')
|
|
685
|
+
return 1
|
|
686
|
+
|
|
687
|
+
total = len(results)
|
|
688
|
+
passed = sum(1 for v in results.values()
|
|
689
|
+
if str(v).lower() in ('pass', 'passed', 'ok', 'true', '1'))
|
|
690
|
+
failed_ids = [k for k, v in results.items()
|
|
691
|
+
if str(v).lower() not in ('pass', 'passed', 'ok', 'true', '1')]
|
|
692
|
+
status = 'pass' if (total > 0 and not failed_ids) else (
|
|
693
|
+
'fail' if failed_ids else 'none')
|
|
694
|
+
|
|
695
|
+
task = load_task_json(task_dir) or {}
|
|
696
|
+
record = {
|
|
697
|
+
'time': datetime.now().isoformat(),
|
|
698
|
+
'task': Path(task_dir).name,
|
|
699
|
+
'task_title': task.get('title', ''),
|
|
700
|
+
'env': args.env,
|
|
701
|
+
'platform': args.platform,
|
|
702
|
+
'base_url': base,
|
|
703
|
+
'url_source': src,
|
|
704
|
+
'total': total,
|
|
705
|
+
'passed': passed,
|
|
706
|
+
'failed': len(failed_ids),
|
|
707
|
+
'status': status,
|
|
708
|
+
'failed_cases': failed_ids,
|
|
709
|
+
'runner': 'qoderwork-browser',
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
try:
|
|
713
|
+
from foundation.io.atomicio import atomic_append_jsonl
|
|
714
|
+
os.makedirs(os.path.dirname(HISTORY_PATH), exist_ok=True)
|
|
715
|
+
atomic_append_jsonl(HISTORY_PATH, record, timeout=10)
|
|
716
|
+
except Exception as e:
|
|
717
|
+
print('⚠ 历史记录写入失败 (不影响本次结果): %s' % e, file=sys.stderr)
|
|
718
|
+
|
|
719
|
+
print('=' * 60)
|
|
720
|
+
print('测试结果汇总')
|
|
721
|
+
print('=' * 60)
|
|
722
|
+
print('任务: %s' % record['task_title'])
|
|
723
|
+
print('环境: %s 目标: %s' % (args.env, base))
|
|
724
|
+
print('总计 %d 条 · 通过 %d · 失败 %d · 状态: %s'
|
|
725
|
+
% (total, passed, len(failed_ids), status.upper()))
|
|
726
|
+
if failed_ids:
|
|
727
|
+
print('失败用例:')
|
|
728
|
+
for cid in failed_ids:
|
|
729
|
+
print(' ✗ %s' % cid)
|
|
730
|
+
print('-' * 60)
|
|
731
|
+
print('已记录到 %s' % HISTORY_PATH)
|
|
732
|
+
if status == 'fail':
|
|
733
|
+
print('建议: 查失败用例, 修复后重跑 /wl-test run %s --env %s'
|
|
734
|
+
% (Path(task_dir).name, args.env))
|
|
735
|
+
return 1
|
|
736
|
+
print('全部通过! 可 /wl-commit 提交, 或 /wl-report 出报告。')
|
|
737
|
+
return 0
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
# ---------------------------------------------------------------------------
|
|
741
|
+
# 子命令: list
|
|
742
|
+
# ---------------------------------------------------------------------------
|
|
743
|
+
|
|
744
|
+
def cmd_list(args):
|
|
745
|
+
task_dir = resolve_task_dir(args.task)
|
|
746
|
+
if not task_dir.is_dir():
|
|
747
|
+
print('任务不存在: %s' % args.task)
|
|
748
|
+
return 1
|
|
749
|
+
cases = read_cases(task_dir)
|
|
750
|
+
if not cases:
|
|
751
|
+
print('任务 %s 暂无用例。' % Path(task_dir).name)
|
|
752
|
+
print('生成: python .qoder/scripts/autotest.py generate %s'
|
|
753
|
+
% Path(task_dir).name)
|
|
754
|
+
return 0
|
|
755
|
+
task = load_task_json(task_dir) or {}
|
|
756
|
+
print('任务: %s' % task.get('title', Path(task_dir).name))
|
|
757
|
+
print('用例文件: %s' % _cases_path(task_dir))
|
|
758
|
+
print()
|
|
759
|
+
print('%-18s %-8s %-6s %s' % ('CASE_ID', 'ENV', 'PLAT', '标题'))
|
|
760
|
+
print('-' * 60)
|
|
761
|
+
for c in cases:
|
|
762
|
+
print('%-18s %-8s %-6s %s' % (
|
|
763
|
+
c.get('case_id', '?'),
|
|
764
|
+
c.get('env', '?'),
|
|
765
|
+
c.get('platform', '?'),
|
|
766
|
+
c.get('title', '')))
|
|
767
|
+
print('-' * 60)
|
|
768
|
+
print('共 %d 条' % len(cases))
|
|
769
|
+
return 0
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
# ---------------------------------------------------------------------------
|
|
773
|
+
# 子命令: set-data / list-data (个人测试数据, 本地 gitignored)
|
|
774
|
+
# ---------------------------------------------------------------------------
|
|
775
|
+
|
|
776
|
+
def cmd_set_data(args):
|
|
777
|
+
"""预置一条个人测试数据到本地文档(不进 git)。"""
|
|
778
|
+
env = getattr(args, 'env', None)
|
|
779
|
+
platform = getattr(args, 'platform', None)
|
|
780
|
+
save_local_test_data(args.key, args.value, env=env, platform=platform)
|
|
781
|
+
loc = ('%s.%s' % (env, platform)) if (env and platform) else (
|
|
782
|
+
env or platform or '通用(顶层)')
|
|
783
|
+
print('已保存到个人文档: %s' % local_data_path())
|
|
784
|
+
print(' %s = %s [位置: %s]' % (args.key, args.value, loc))
|
|
785
|
+
print()
|
|
786
|
+
print('🔒 此文档已被 .gitignore 覆盖 (workspace/members/*/autotest-data.yaml),')
|
|
787
|
+
print(' team_sync 不会 push, 团队/npm 包都看不到。')
|
|
788
|
+
print(' run 时自动注入用例的 {{ask:%s}} 占位。' % args.key)
|
|
789
|
+
if any(s in args.key for s in ('密码', '密', 'secret', 'token', '码')):
|
|
790
|
+
print(' (敏感数据, 存个人文档比每次对话明文输入更安全)')
|
|
791
|
+
return 0
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def cmd_list_data(args):
|
|
795
|
+
"""列出本地已预置的测试数据(可按 env/platform 过滤)。"""
|
|
796
|
+
env = getattr(args, 'env', None)
|
|
797
|
+
platform = getattr(args, 'platform', None)
|
|
798
|
+
p = local_data_path()
|
|
799
|
+
if not p.is_file():
|
|
800
|
+
print('个人测试数据文档不存在: %s' % p)
|
|
801
|
+
print()
|
|
802
|
+
print('生成模板: python .qoder/scripts/autotest.py init-data')
|
|
803
|
+
print('或直接预置: python .qoder/scripts/autotest.py set-data 登录账号 test')
|
|
804
|
+
return 0
|
|
805
|
+
data = load_local_test_data(env=env, platform=platform)
|
|
806
|
+
if not data:
|
|
807
|
+
scope = ('%s/%s' % (env, platform)) if (env and platform) else (
|
|
808
|
+
env or platform or '全部')
|
|
809
|
+
print('文档 %s 在 %s 范围内无数据。' % (p, scope))
|
|
810
|
+
return 0
|
|
811
|
+
print('个人测试数据 (%d 项)' % len(data))
|
|
812
|
+
print('文档: %s' % p)
|
|
813
|
+
print('-' * 50)
|
|
814
|
+
for k, v in data.items():
|
|
815
|
+
show = v
|
|
816
|
+
if any(s in k for s in ('密码', '密', 'secret', 'token')):
|
|
817
|
+
show = '*' * min(len(str(v)), 8) if str(v) else '(空)'
|
|
818
|
+
print(' %-16s = %s' % (k, show))
|
|
819
|
+
print('-' * 50)
|
|
820
|
+
print('🔒 个人文档, 已 gitignore, 不会 push/共享。run 时自动注入 {{ask:描述}}。')
|
|
821
|
+
return 0
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def cmd_init_data(args):
|
|
825
|
+
"""生成带注释的个人测试数据模板文档(若不存在)。"""
|
|
826
|
+
path, created = init_local_data_doc()
|
|
827
|
+
if created:
|
|
828
|
+
print('已生成模板: %s' % path)
|
|
829
|
+
print()
|
|
830
|
+
print('打开它, 填入你的测试账号密码(例子已给)。每人只填自己的 members/{自己}/。')
|
|
831
|
+
print('🔒 已 gitignore, 不会 push/共享给团队。')
|
|
832
|
+
else:
|
|
833
|
+
print('文档已存在(不覆盖): %s' % path)
|
|
834
|
+
print('直接编辑它, 或用 set-data 命令追加。')
|
|
835
|
+
return 0
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
# ---------------------------------------------------------------------------
|
|
839
|
+
# 子命令: login-state (登录态: 已登录就别重复登录, 尤其验证码慢)
|
|
840
|
+
# ---------------------------------------------------------------------------
|
|
841
|
+
|
|
842
|
+
def cmd_login_state(args):
|
|
843
|
+
"""查看/标记/清除 登录态。
|
|
844
|
+
|
|
845
|
+
QoderWork 保持同一个浏览器会话 → 登录一次后 cookie 不丢。
|
|
846
|
+
后续测新功能直接 goto 目标页, 不用重复登录(数学题验证码很慢)。
|
|
847
|
+
"""
|
|
848
|
+
action = args.action # show / mark / clear
|
|
849
|
+
env = getattr(args, 'env', None) or _default_env()
|
|
850
|
+
platform = getattr(args, 'platform', None) or 'web'
|
|
851
|
+
|
|
852
|
+
if action == 'mark':
|
|
853
|
+
base, src = resolve_base_url(env, platform)
|
|
854
|
+
if not base:
|
|
855
|
+
print('✗ %s/%s 未配域名, 无法标记登录。' % (env, platform))
|
|
856
|
+
return 1
|
|
857
|
+
mark_logged_in(env, platform, base)
|
|
858
|
+
print('已标记 %s/%s 为已登录 (浏览器会话保持中, cookie 有效)。' % (env, platform))
|
|
859
|
+
print('🔒 存个人文档 (gitignored), 不会 push。')
|
|
860
|
+
print()
|
|
861
|
+
print('后续 quick/browser 测新功能: 直接 goto 目标页, 不用再登录。')
|
|
862
|
+
return 0
|
|
863
|
+
|
|
864
|
+
if action == 'clear':
|
|
865
|
+
clear_login_state(env if env and env != 'all' else None,
|
|
866
|
+
platform if env != 'all' else None)
|
|
867
|
+
print('已清除登录态: %s/%s。' % (env, platform))
|
|
868
|
+
print('下次测试会重新走登录流程。')
|
|
869
|
+
return 0
|
|
870
|
+
|
|
871
|
+
# show (默认)
|
|
872
|
+
state = load_login_state()
|
|
873
|
+
if not state:
|
|
874
|
+
print('未记录任何登录态。')
|
|
875
|
+
print('登录用例通过后自动标记, 或手动: login-state mark --env %s --platform %s'
|
|
876
|
+
% (env, platform))
|
|
877
|
+
return 0
|
|
878
|
+
print('已记录登录态 (QoderWork 会话保持中, 后续测新功能可直接 goto 目标页):')
|
|
879
|
+
print('-' * 50)
|
|
880
|
+
for e, plats in state.items():
|
|
881
|
+
for pf, info in (plats or {}).items():
|
|
882
|
+
t = (info or {}).get('time', '?')[:19].replace('T', ' ')
|
|
883
|
+
url = (info or {}).get('url', '')
|
|
884
|
+
print(' %s/%s 已登录 (%s) %s' % (e, pf, t, url))
|
|
885
|
+
print('-' * 50)
|
|
886
|
+
print('🔒 个人文档, gitignored, 不 push。清除: login-state clear --env <环境>')
|
|
887
|
+
return 0
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
# ---------------------------------------------------------------------------
|
|
891
|
+
# 子命令: learn / recall (页面锚点沉淀 + 查询, A方案核心)
|
|
892
|
+
# ---------------------------------------------------------------------------
|
|
893
|
+
|
|
894
|
+
def cmd_learn(args):
|
|
895
|
+
"""把 read_page 返回的页面元素沉淀成稳定锚点 → data/index/test-pages.json。
|
|
896
|
+
|
|
897
|
+
AI 执行 observe(read_page) 后, 把元素 JSON 回传, 本命令提取稳定锚点存盘。
|
|
898
|
+
下次 recall 同页直接命中锚点, 跳过 LLM 匹配(Stagehand 式 action 缓存)。
|
|
899
|
+
"""
|
|
900
|
+
url_pattern = _normalize_url_pattern(args.url, getattr(args, '_base', None))
|
|
901
|
+
# 解析 page-json (read_page 返回的元素数组, 或 {elements:[...]})
|
|
902
|
+
try:
|
|
903
|
+
parsed = json.loads(args.page_json)
|
|
904
|
+
except json.JSONDecodeError as e:
|
|
905
|
+
print('✗ --page-json 不是合法 JSON: %s' % e)
|
|
906
|
+
return 1
|
|
907
|
+
if isinstance(parsed, dict):
|
|
908
|
+
elements = parsed.get('elements') or parsed.get('elements') or []
|
|
909
|
+
elif isinstance(parsed, list):
|
|
910
|
+
elements = parsed
|
|
911
|
+
else:
|
|
912
|
+
print('✗ --page-json 应为元素数组或含 elements 的对象')
|
|
913
|
+
return 1
|
|
914
|
+
# 可选 intent-map
|
|
915
|
+
intent_map = None
|
|
916
|
+
if args.intent_map:
|
|
917
|
+
try:
|
|
918
|
+
intent_map = json.loads(args.intent_map)
|
|
919
|
+
except json.JSONDecodeError as e:
|
|
920
|
+
print('⚠ --intent-map 不是合法 JSON, 忽略: %s' % e)
|
|
921
|
+
new_n, total_n = learn_page_anchors(url_pattern, elements, intent_map=intent_map)
|
|
922
|
+
print('=' * 56)
|
|
923
|
+
print('锚点沉淀 → data/index/test-pages.json')
|
|
924
|
+
print('=' * 56)
|
|
925
|
+
print('页面: %s' % url_pattern)
|
|
926
|
+
print('本次新增 %d 个锚点, 该页累计 %d 个' % (new_n, total_n))
|
|
927
|
+
print('🔒 团队共享(随 team_sync 分发), 只存 role/name/placeholder, 不含密码/ref。')
|
|
928
|
+
print()
|
|
929
|
+
print('下次 recall %s 即可直接用锚点生成用例, 跳过 LLM 匹配。' % url_pattern)
|
|
930
|
+
return 0
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def cmd_recall(args):
|
|
934
|
+
"""查某页已沉淀的锚点。AI 生成用例前查它, 有锚点就直接填进 step。"""
|
|
935
|
+
url_pattern = _normalize_url_pattern(args.url)
|
|
936
|
+
anchors = recall_page_anchors(url_pattern)
|
|
937
|
+
print('=' * 56)
|
|
938
|
+
print('已沉淀锚点: %s' % url_pattern)
|
|
939
|
+
print('=' * 56)
|
|
940
|
+
if not anchors:
|
|
941
|
+
print('该页暂无锚点。执行 observe(read_page) 后用 learn 沉淀:')
|
|
942
|
+
print(' python .qoder/scripts/autotest.py learn --url %s --page-json \'<read_page结果>\'' % url_pattern)
|
|
943
|
+
return 0
|
|
944
|
+
print('%-10s %-22s %s' % ('INTENT', 'ANCHOR', '描述'))
|
|
945
|
+
print('-' * 56)
|
|
946
|
+
for a in anchors:
|
|
947
|
+
print('%-10s %-22s %s' % (
|
|
948
|
+
a.get('intent', '?'),
|
|
949
|
+
_fmt_anchor(a.get('anchor')),
|
|
950
|
+
a.get('desc', '')[:30]))
|
|
951
|
+
print('-' * 56)
|
|
952
|
+
print('共 %d 个锚点。生成用例时可直接填入 step.anchor, 跳过 LLM 匹配。' % len(anchors))
|
|
953
|
+
return 0
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
# ---------------------------------------------------------------------------
|
|
957
|
+
# 子命令: page (页面按钮+接口画像, 开发→测试交接物)
|
|
958
|
+
# ---------------------------------------------------------------------------
|
|
959
|
+
|
|
960
|
+
def cmd_page(args):
|
|
961
|
+
"""查页面按钮+接口画像。薄入口, 实际逻辑在 page_probe.py。
|
|
962
|
+
数据来自知识图谱 traces 表, 无图谱则静默提示。
|
|
963
|
+
"""
|
|
964
|
+
try:
|
|
965
|
+
import page_probe
|
|
966
|
+
except ImportError:
|
|
967
|
+
# 直接当脚本跑时, 补一下 sys.path
|
|
968
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
969
|
+
import page_probe
|
|
970
|
+
|
|
971
|
+
profile = page_probe.build_page_profile(keyword=getattr(args, 'kw', None),
|
|
972
|
+
url=getattr(args, 'url', None))
|
|
973
|
+
label = args.kw or args.url
|
|
974
|
+
print('## 页面画像: %s' % label)
|
|
975
|
+
print()
|
|
976
|
+
print(page_probe.render_profile_md(profile))
|
|
977
|
+
if profile.get('ok'):
|
|
978
|
+
n_pages = len(profile['pages'])
|
|
979
|
+
n_btns = sum(len(pg['buttons']) for pg in profile['pages'])
|
|
980
|
+
print()
|
|
981
|
+
print('[page] %d 个页面, %d 个交互点' % (n_pages, n_btns))
|
|
982
|
+
return 0
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
# ---------------------------------------------------------------------------
|
|
986
|
+
# 子命令: assertion-gen (断言生成: PRD验收/GWT → 可执行 expect 断言)
|
|
987
|
+
# ---------------------------------------------------------------------------
|
|
988
|
+
|
|
989
|
+
def cmd_assertion_gen(args):
|
|
990
|
+
"""断言生成器: 把 PRD 验收标准 / GWT 文本翻译成可执行 expect 断言。
|
|
991
|
+
|
|
992
|
+
薄入口, 实际逻辑在 assertion_gen.py。
|
|
993
|
+
解决痛点: quick 模式瞎编 expect.equals → 现在断言从 PRD 结构化验收抽取,
|
|
994
|
+
图谱(page_probe/field_map)只补字段不编语义。详见 assertion_gen.py 文档头。
|
|
995
|
+
"""
|
|
996
|
+
try:
|
|
997
|
+
import assertion_gen
|
|
998
|
+
except ImportError:
|
|
999
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
1000
|
+
import assertion_gen # type: ignore
|
|
1001
|
+
|
|
1002
|
+
assertions, report = assertion_gen.generate(
|
|
1003
|
+
task=getattr(args, 'task', None),
|
|
1004
|
+
gwt=getattr(args, 'gwt', None),
|
|
1005
|
+
page_keyword=getattr(args, 'page', None),
|
|
1006
|
+
)
|
|
1007
|
+
print(assertion_gen.render_report(assertions, report, task=getattr(args, 'task', None)))
|
|
1008
|
+
|
|
1009
|
+
if assertions:
|
|
1010
|
+
# 落地: 任务模式写 assertions.json; gwt 模式只打印
|
|
1011
|
+
task_dir = None
|
|
1012
|
+
if getattr(args, 'task', None):
|
|
1013
|
+
task_dir = resolve_task_dir(args.task)
|
|
1014
|
+
if task_dir.is_dir():
|
|
1015
|
+
import json as _json
|
|
1016
|
+
out_path = os.path.join(str(task_dir), 'assertions.json')
|
|
1017
|
+
payload = {
|
|
1018
|
+
'_contract': {'type': 'assertions', 'version': 1,
|
|
1019
|
+
'source': args.task},
|
|
1020
|
+
'assertions': assertions,
|
|
1021
|
+
}
|
|
1022
|
+
with open(out_path, 'w', encoding='utf-8') as f:
|
|
1023
|
+
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
|
1024
|
+
print('断言已写入: %s' % out_path)
|
|
1025
|
+
print('下一步: 把 assertions 数组并进 quick 用例 steps (intent=assert), '
|
|
1026
|
+
'用 webaccess/quick 执行真跑验证。')
|
|
1027
|
+
return 0 if assertions else 2 # 抽不到=诚实退出非零
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
# ---------------------------------------------------------------------------
|
|
1031
|
+
# 子命令: report
|
|
1032
|
+
# ---------------------------------------------------------------------------
|
|
1033
|
+
|
|
1034
|
+
def cmd_report(args):
|
|
1035
|
+
task_dir = resolve_task_dir(args.task)
|
|
1036
|
+
task_name = Path(task_dir).name
|
|
1037
|
+
task = load_task_json(task_dir) or {}
|
|
1038
|
+
|
|
1039
|
+
if not os.path.isfile(HISTORY_PATH):
|
|
1040
|
+
print('暂无测试执行历史 (%s)' % HISTORY_PATH)
|
|
1041
|
+
print('先跑: python .qoder/scripts/autotest.py run %s --env test'
|
|
1042
|
+
% task_name)
|
|
1043
|
+
return 0
|
|
1044
|
+
|
|
1045
|
+
# 读该任务的全部历史 (倒序)
|
|
1046
|
+
records = []
|
|
1047
|
+
with open(HISTORY_PATH, encoding='utf-8') as f:
|
|
1048
|
+
for line in f:
|
|
1049
|
+
line = line.strip()
|
|
1050
|
+
if not line:
|
|
1051
|
+
continue
|
|
1052
|
+
try:
|
|
1053
|
+
r = json.loads(line)
|
|
1054
|
+
except json.JSONDecodeError:
|
|
1055
|
+
continue
|
|
1056
|
+
if r.get('task') == task_name:
|
|
1057
|
+
records.append(r)
|
|
1058
|
+
if not records:
|
|
1059
|
+
print('任务 %s 无执行记录。' % task_name)
|
|
1060
|
+
return 0
|
|
1061
|
+
|
|
1062
|
+
records.sort(key=lambda r: r.get('time', ''), reverse=True)
|
|
1063
|
+
print('=' * 60)
|
|
1064
|
+
print('测试历史 · %s' % task.get('title', task_name))
|
|
1065
|
+
print('=' * 60)
|
|
1066
|
+
print('%-20s %-6s %-7s %-5s %s' % (
|
|
1067
|
+
'时间', 'ENV', '状态', 'P/F', '目标'))
|
|
1068
|
+
print('-' * 60)
|
|
1069
|
+
for r in records[:20]:
|
|
1070
|
+
t = r.get('time', '')[:19].replace('T', ' ')
|
|
1071
|
+
print('%-20s %-6s %-7s %d/%d %s' % (
|
|
1072
|
+
t,
|
|
1073
|
+
r.get('env', '?'),
|
|
1074
|
+
r.get('status', '?').upper(),
|
|
1075
|
+
r.get('passed', 0),
|
|
1076
|
+
r.get('total', 0),
|
|
1077
|
+
r.get('base_url', '')))
|
|
1078
|
+
print('-' * 60)
|
|
1079
|
+
total_runs = len(records)
|
|
1080
|
+
pass_runs = sum(1 for r in records if r.get('status') == 'pass')
|
|
1081
|
+
print('历史 %d 次执行 · 通过率 %.0f%% (最近 %d 次)'
|
|
1082
|
+
% (total_runs, 100 * pass_runs / total_runs if total_runs else 0,
|
|
1083
|
+
min(total_runs, 20)))
|
|
1084
|
+
last = records[0]
|
|
1085
|
+
if last.get('failed_cases'):
|
|
1086
|
+
print('最近一次失败用例: %s' % ', '.join(last['failed_cases']))
|
|
1087
|
+
return 0
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
# ---------------------------------------------------------------------------
|
|
1091
|
+
# argparse
|
|
1092
|
+
# ---------------------------------------------------------------------------
|
|
1093
|
+
|
|
1094
|
+
def build_parser():
|
|
1095
|
+
p = argparse.ArgumentParser(
|
|
1096
|
+
prog='autotest.py',
|
|
1097
|
+
description='浏览器自动化测试: 用例生成 + 运行编排 (QoderWork 浏览器执行, 纯 Qoder 降级人工)',
|
|
1098
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1099
|
+
)
|
|
1100
|
+
sub = p.add_subparsers(dest='cmd')
|
|
1101
|
+
|
|
1102
|
+
def add_env_platform(sp):
|
|
1103
|
+
sp.add_argument('--env', default=_default_env(),
|
|
1104
|
+
choices=SUPPORTED_ENVS,
|
|
1105
|
+
help='环境 (test/pre/prod), 默认 %(default)s')
|
|
1106
|
+
sp.add_argument('--platform', default='web',
|
|
1107
|
+
choices=SUPPORTED_PLATFORMS,
|
|
1108
|
+
help='平台 (web=管理端 / app=移动端), 默认 %(default)s')
|
|
1109
|
+
|
|
1110
|
+
pg = sub.add_parser('generate', help='从任务生成测试用例骨架')
|
|
1111
|
+
pg.add_argument('task', help='任务名/路径 (如 06-14-login)')
|
|
1112
|
+
add_env_platform(pg)
|
|
1113
|
+
pg.set_defaults(func=cmd_generate)
|
|
1114
|
+
|
|
1115
|
+
pr = sub.add_parser('run', help='生成(若无)+输出浏览器执行指令/人工清单')
|
|
1116
|
+
pr.add_argument('task', help='任务名/路径')
|
|
1117
|
+
add_env_platform(pr)
|
|
1118
|
+
pr.add_argument('--url', default=None,
|
|
1119
|
+
help='临时覆盖目标域名 (优先于 --env 配置)')
|
|
1120
|
+
pr.add_argument('--case', nargs='*', default=None,
|
|
1121
|
+
help='只执行指定 case_id (可多个)')
|
|
1122
|
+
pr.add_argument('--data', default=None,
|
|
1123
|
+
help='测试数据 JSON, 注入 {{ask:描述}} 占位, '
|
|
1124
|
+
'如 \'{"登录账号":"test","登录密码":"123456"}\'')
|
|
1125
|
+
pr.add_argument('--record', default=None,
|
|
1126
|
+
help='回传执行结果 JSON, 如 \'{"AT-x-001":"pass"}\'')
|
|
1127
|
+
pr.set_defaults(func=cmd_run)
|
|
1128
|
+
|
|
1129
|
+
pq = sub.add_parser('quick', help='快速测试: 不需任务/PRD, AI 直接据描述生成并执行')
|
|
1130
|
+
pq.add_argument('--cases', default=None,
|
|
1131
|
+
help='JSON 用例 (单条对象或数组), AI 据用户描述生成后传入')
|
|
1132
|
+
pq.add_argument('--cases-file', dest='cases_file', default=None,
|
|
1133
|
+
help='用例 JSON 文件路径(推荐, 不截断)。AI 写文件后传路径')
|
|
1134
|
+
pq.add_argument('--desc', default=None,
|
|
1135
|
+
help='本次快速测试的描述/标题 (可选)')
|
|
1136
|
+
add_env_platform(pq)
|
|
1137
|
+
pq.add_argument('--url', default=None, help='临时覆盖目标域名')
|
|
1138
|
+
pq.add_argument('--data', default=None, help='测试数据 JSON, 注入 {{ask:描述}}')
|
|
1139
|
+
pq.add_argument('--record', default=None, help='回传执行结果 JSON')
|
|
1140
|
+
pq.add_argument('--page-json', nargs='*', default=None,
|
|
1141
|
+
help='read_page 返回的页面元素 JSON(多个), --record 时自动沉淀锚点')
|
|
1142
|
+
pq.set_defaults(func=cmd_quick)
|
|
1143
|
+
|
|
1144
|
+
pl = sub.add_parser('list', help='列出任务已有用例')
|
|
1145
|
+
pl.add_argument('task', help='任务名/路径')
|
|
1146
|
+
pl.set_defaults(func=cmd_list)
|
|
1147
|
+
|
|
1148
|
+
pr2 = sub.add_parser('report', help='查看任务测试执行历史')
|
|
1149
|
+
pr2.add_argument('task', help='任务名/路径')
|
|
1150
|
+
pr2.set_defaults(func=cmd_report)
|
|
1151
|
+
|
|
1152
|
+
psd = sub.add_parser('set-data', help='预置个人测试数据到个人文档(gitignored, 不共享)')
|
|
1153
|
+
psd.add_argument('key', help='数据名(对应用例 {{ask:数据名}} 占位), 如 登录账号')
|
|
1154
|
+
psd.add_argument('value', help='数据值, 如 test')
|
|
1155
|
+
psd.add_argument('--env', default=None, choices=SUPPORTED_ENVS,
|
|
1156
|
+
help='只对该环境生效(如 test/pre/prod)')
|
|
1157
|
+
psd.add_argument('--platform', default=None, choices=SUPPORTED_PLATFORMS,
|
|
1158
|
+
help='只对该平台生效(web/app)')
|
|
1159
|
+
psd.set_defaults(func=cmd_set_data)
|
|
1160
|
+
|
|
1161
|
+
pld = sub.add_parser('list-data', help='列出个人已预置的测试数据(可按 env/platform 过滤)')
|
|
1162
|
+
pld.add_argument('--env', default=None, choices=SUPPORTED_ENVS)
|
|
1163
|
+
pld.add_argument('--platform', default=None, choices=SUPPORTED_PLATFORMS)
|
|
1164
|
+
pld.set_defaults(func=cmd_list_data)
|
|
1165
|
+
|
|
1166
|
+
pid = sub.add_parser('init-data', help='生成个人测试数据模板文档(members/{dev}/autotest-data.yaml)')
|
|
1167
|
+
pid.set_defaults(func=cmd_init_data)
|
|
1168
|
+
|
|
1169
|
+
pls = sub.add_parser('login-state', help='登录态: 已登录就别重复登录(QoderWork 会话保持)')
|
|
1170
|
+
pls.add_argument('action', nargs='?', default='show',
|
|
1171
|
+
choices=['show', 'mark', 'clear'],
|
|
1172
|
+
help='show=查看 / mark=标记已登录 / clear=清除(强制重新登录)')
|
|
1173
|
+
add_env_platform(pls)
|
|
1174
|
+
pls.set_defaults(func=cmd_login_state)
|
|
1175
|
+
|
|
1176
|
+
ple = sub.add_parser('learn', help='沉淀页面锚点到 test-pages.json(observe后调, 团队共享)')
|
|
1177
|
+
ple.add_argument('--url', required=True, help='页面 URL 或路径(如 /auth/login 或完整URL)')
|
|
1178
|
+
ple.add_argument('--page-json', required=True,
|
|
1179
|
+
help="read_page 返回的元素 JSON (数组 或 {elements:[...]})")
|
|
1180
|
+
ple.add_argument('--intent-map', default=None,
|
|
1181
|
+
help='可选: {元素描述:{intent,anchor}} 标注意图')
|
|
1182
|
+
ple.set_defaults(func=cmd_learn)
|
|
1183
|
+
|
|
1184
|
+
prc = sub.add_parser('recall', help='查某页已沉淀的锚点(生成用例前查, 跳过LLM匹配)')
|
|
1185
|
+
prc.add_argument('--url', required=True, help='页面 URL 或路径(如 /auth/login)')
|
|
1186
|
+
prc.set_defaults(func=cmd_recall)
|
|
1187
|
+
|
|
1188
|
+
# page: 查页面按钮+接口画像 (开发→测试交接物, 数据来自知识图谱 traces 表)
|
|
1189
|
+
ppg = sub.add_parser('page', help='查页面按钮+接口画像(开发交接给测试,照着写用例)')
|
|
1190
|
+
ppg_grp = ppg.add_mutually_exclusive_group(required=True)
|
|
1191
|
+
ppg_grp.add_argument('--kw', help='业务关键词(如 保险 / 排班)')
|
|
1192
|
+
ppg_grp.add_argument('--url', help='页面 URL 或路径关键词(如 /veh/insurance)')
|
|
1193
|
+
ppg.set_defaults(func=cmd_page)
|
|
1194
|
+
|
|
1195
|
+
# assertion-gen: PRD验收/GWT → 可执行 expect 断言 (不编造, 不执行)
|
|
1196
|
+
pag = sub.add_parser('assertion-gen',
|
|
1197
|
+
help='断言生成: PRD验收/GWT → 可执行expect断言 (图谱补字段不编语义)')
|
|
1198
|
+
pag_src = pag.add_mutually_exclusive_group(required=False)
|
|
1199
|
+
pag_src.add_argument('--task', help='任务名 (读其 prd.md 验收标准章节)')
|
|
1200
|
+
pag_src.add_argument('--gwt', help='GWT/验收文本 (兜底输入)')
|
|
1201
|
+
pag.add_argument('--page', default=None,
|
|
1202
|
+
help='业务关键词(如 保险), 触发 page_probe 字段补全')
|
|
1203
|
+
pag.set_defaults(func=cmd_assertion_gen)
|
|
1204
|
+
|
|
1205
|
+
return p
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def _default_env():
|
|
1209
|
+
"""config.yaml autotest.default_env, 缺失回退 test。"""
|
|
1210
|
+
try:
|
|
1211
|
+
return load_config_section('autotest').get('default_env') or 'test'
|
|
1212
|
+
except Exception:
|
|
1213
|
+
return 'test'
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def main(argv=None):
|
|
1217
|
+
parser = build_parser()
|
|
1218
|
+
args = parser.parse_args(argv)
|
|
1219
|
+
if not getattr(args, 'cmd', None):
|
|
1220
|
+
parser.print_help()
|
|
1221
|
+
return 1
|
|
1222
|
+
try:
|
|
1223
|
+
return args.func(args)
|
|
1224
|
+
except RuntimeError as e:
|
|
1225
|
+
# 身份缺失等 (多人共用 workspace 时的隔离保障)
|
|
1226
|
+
print('✗ %s' % e)
|
|
1227
|
+
return 1
|
|
1228
|
+
except (ValueError, FileNotFoundError) as e:
|
|
1229
|
+
print('✗ %s' % e)
|
|
1230
|
+
return 1
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
if __name__ == '__main__':
|
|
1234
|
+
sys.exit(main())
|