tuner-testkit 4.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tuner_testkit/__init__.py +5 -0
- tuner_testkit/action_words/__init__.py +48 -0
- tuner_testkit/action_words/__main__.py +111 -0
- tuner_testkit/action_words/_internal/__init__.py +5 -0
- tuner_testkit/action_words/_internal/api.py +56 -0
- tuner_testkit/action_words/_internal/db.py +48 -0
- tuner_testkit/action_words/_internal/generators.py +37 -0
- tuner_testkit/action_words/_internal/ids.py +78 -0
- tuner_testkit/action_words/_internal/wait.py +27 -0
- tuner_testkit/action_words/base.py +131 -0
- tuner_testkit/action_words/context.py +105 -0
- tuner_testkit/action_words/plane.py +83 -0
- tuner_testkit/action_words/registry.py +96 -0
- tuner_testkit/api_mock/__init__.py +68 -0
- tuner_testkit/api_mock/app.py +419 -0
- tuner_testkit/api_mock/errors.py +17 -0
- tuner_testkit/api_mock/router.py +100 -0
- tuner_testkit/api_mock/spec.py +180 -0
- tuner_testkit/api_mock/store.py +214 -0
- tuner_testkit/api_objects/__init__.py +1 -0
- tuner_testkit/api_objects/auth.py +227 -0
- tuner_testkit/api_objects/recording/__init__.py +35 -0
- tuner_testkit/api_objects/recording/capture.py +271 -0
- tuner_testkit/api_objects/recording/codegen.py +381 -0
- tuner_testkit/api_objects/recording/freeze.py +312 -0
- tuner_testkit/api_objects/recording/mocks.py +149 -0
- tuner_testkit/api_objects/recording/normalize.py +256 -0
- tuner_testkit/api_objects/recording/pipeline.py +149 -0
- tuner_testkit/api_objects/recording/sanitize.py +99 -0
- tuner_testkit/api_test/__init__.py +18 -0
- tuner_testkit/api_test/client.py +274 -0
- tuner_testkit/api_test/errors.py +26 -0
- tuner_testkit/api_test/jsonpath.py +88 -0
- tuner_testkit/api_test/model.py +350 -0
- tuner_testkit/apps/__init__.py +1 -0
- tuner_testkit/apps/_shared/__init__.py +1 -0
- tuner_testkit/apps/_shared/changelog.py +77 -0
- tuner_testkit/apps/_shared/plane.py +25 -0
- tuner_testkit/apps/_shared/plane_app.py +296 -0
- tuner_testkit/apps/_shared/plane_asset.py +147 -0
- tuner_testkit/apps/_shared/tests/test_plane_asset.py +124 -0
- tuner_testkit/apps/_shared/tests/test_plane_collect.py +45 -0
- tuner_testkit/apps/api_recorder/__init__.py +11 -0
- tuner_testkit/apps/api_recorder/__main__.py +6 -0
- tuner_testkit/apps/api_recorder/addon.py +67 -0
- tuner_testkit/apps/api_recorder/cli.py +201 -0
- tuner_testkit/apps/api_recorder/playwright_tap.py +113 -0
- tuner_testkit/apps/api_recorder/tests/__init__.py +0 -0
- tuner_testkit/apps/api_recorder/tests/test_addon.py +117 -0
- tuner_testkit/apps/dna/__init__.py +1 -0
- tuner_testkit/apps/dna/__main__.py +4 -0
- tuner_testkit/apps/dna/build.py +80 -0
- tuner_testkit/apps/dna/cli.py +147 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/agents/bdd-asset-pipeline.md +102 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/hooks/sync_registry.py +45 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/hooks.json +11 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/rules/apps-authoring.mdc +49 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/rules/apps-handover.mdc +29 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/rules/assets-knowledge.mdc +44 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/rules/bdd-asset-layering.mdc +65 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/rules/bdd-pipeline-gates.mdc +53 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/rules/bdd-stage-layout.mdc +53 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/rules/commit-convention.mdc +21 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/rules/index-hygiene.mdc +33 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/rules/packages-db.mdc +131 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/rules/packages-fake.mdc +59 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/rules/packages-logging.mdc +60 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/rules/packages-page-test.mdc +92 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/skills/create-action-word/SKILL.md +84 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/skills/create-app/SKILL.md +64 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/skills/dump-ddl/SKILL.md +110 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/skills/feature-authoring/SKILL.md +52 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/skills/feature-review-lint/SKILL.md +37 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/skills/freeze-api-objects/SKILL.md +75 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/skills/maintain-behave-steps/SKILL.md +57 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/skills/maintain-index/SKILL.md +47 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/skills/maintain-page-objects/SKILL.md +93 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/skills/release-template/SKILL.md +51 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/skills/reuse-analysis/SKILL.md +51 -0
- tuner_testkit/apps/dna/dna_payload/.cursor/skills/run-feature-playwright-mcp/SKILL.md +64 -0
- tuner_testkit/apps/dna/dna_payload/AGENTS.md +79 -0
- tuner_testkit/apps/dna/dna_payload/MANIFEST.txt +13 -0
- tuner_testkit/apps/dna/dna_payload/docs/spec/action-words-syntax.md +172 -0
- tuner_testkit/apps/dna/dna_payload/docs/spec/api-objects-syntax.md +240 -0
- tuner_testkit/apps/dna/dna_payload/docs/spec/apps-authoring-syntax.md +75 -0
- tuner_testkit/apps/dna/dna_payload/docs/spec/assets-knowledge-syntax.md +70 -0
- tuner_testkit/apps/dna/dna_payload/docs/spec/bdd-run-gherkin-in-playwrightMCP.md +159 -0
- tuner_testkit/apps/dna/dna_payload/docs/spec/behave-gerkin-syntax.md +282 -0
- tuner_testkit/apps/dna/dna_payload/docs/spec/behave-step-definitions.md +199 -0
- tuner_testkit/apps/dna/dna_payload/docs/spec/commit-convention.md +83 -0
- tuner_testkit/apps/dna/dna_payload/docs/spec/page-objects-syntax.md +282 -0
- tuner_testkit/apps/dna/dna_payload/tools/git-hooks/README.md +28 -0
- tuner_testkit/apps/dna/dna_payload/tools/git-hooks/commit-msg +20 -0
- tuner_testkit/apps/dna/dna_payload/tools/git-hooks/pre-push +22 -0
- tuner_testkit/apps/dna/dna_payload/tools/git-hooks/validate_commit_msg.py +178 -0
- tuner_testkit/apps/dna/paths.py +15 -0
- tuner_testkit/apps/dna/source.py +84 -0
- tuner_testkit/apps/dna/tests/test_cli.py +21 -0
- tuner_testkit/apps/dump_ddl.py +1333 -0
- tuner_testkit/apps/index_ai/__init__.py +8 -0
- tuner_testkit/apps/index_ai/__main__.py +42 -0
- tuner_testkit/apps/index_ai/registry.py +237 -0
- tuner_testkit/apps/index_platform/__init__.py +9 -0
- tuner_testkit/apps/index_platform/__main__.py +29 -0
- tuner_testkit/apps/index_platform/build.py +63 -0
- tuner_testkit/apps/index_platform/catalog.py +228 -0
- tuner_testkit/apps/index_platform/plane.py +29 -0
- tuner_testkit/apps/index_platform/tests/test_catalog.py +29 -0
- tuner_testkit/apps/init_repo/__init__.py +11 -0
- tuner_testkit/apps/init_repo/__main__.py +87 -0
- tuner_testkit/apps/init_repo/manifest.py +133 -0
- tuner_testkit/apps/init_repo/scaffold.py +149 -0
- tuner_testkit/apps/mock_server/__init__.py +1 -0
- tuner_testkit/apps/mock_server/__main__.py +6 -0
- tuner_testkit/apps/mock_server/cli.py +213 -0
- tuner_testkit/apps/mock_server/plane.py +52 -0
- tuner_testkit/apps/mock_server/seed.py +215 -0
- tuner_testkit/apps/page_recorder/__init__.py +7 -0
- tuner_testkit/apps/page_recorder/__main__.py +6 -0
- tuner_testkit/apps/page_recorder/capture.py +215 -0
- tuner_testkit/apps/page_recorder/cli.py +134 -0
- tuner_testkit/apps/page_recorder/codegen.py +239 -0
- tuner_testkit/apps/page_recorder/freeze.py +390 -0
- tuner_testkit/apps/page_recorder/inject.py +235 -0
- tuner_testkit/apps/page_recorder/session.py +253 -0
- tuner_testkit/apps/page_recorder/tests/__init__.py +0 -0
- tuner_testkit/apps/page_recorder/tests/test_capture.py +171 -0
- tuner_testkit/apps/page_recorder/tests/test_cli.py +53 -0
- tuner_testkit/apps/page_recorder/tests/test_freeze.py +192 -0
- tuner_testkit/apps/recorder/__init__.py +11 -0
- tuner_testkit/apps/recorder/__main__.py +6 -0
- tuner_testkit/apps/recorder/cli.py +233 -0
- tuner_testkit/apps/recorder/tests/__init__.py +0 -0
- tuner_testkit/apps/recorder/tests/test_cli.py +169 -0
- tuner_testkit/config.py +333 -0
- tuner_testkit/db/__init__.py +34 -0
- tuner_testkit/db/client.py +247 -0
- tuner_testkit/db/connection.py +140 -0
- tuner_testkit/excel/__init__.py +37 -0
- tuner_testkit/excel/workbook.py +225 -0
- tuner_testkit/fake/__init__.py +220 -0
- tuner_testkit/fake/__main__.py +4 -0
- tuner_testkit/fake/catalog.py +195 -0
- tuner_testkit/fake/china.py +139 -0
- tuner_testkit/fake/cli.py +162 -0
- tuner_testkit/fake/core.py +340 -0
- tuner_testkit/fake/medical.py +237 -0
- tuner_testkit/fake/wrappers.py +95 -0
- tuner_testkit/logging/__init__.py +50 -0
- tuner_testkit/logging/context.py +130 -0
- tuner_testkit/logging/core.py +202 -0
- tuner_testkit/logging/helpers.py +152 -0
- tuner_testkit/logging/hooks.py +196 -0
- tuner_testkit/logging/scenario_file.py +98 -0
- tuner_testkit/page_test/__init__.py +182 -0
- tuner_testkit/page_test/__main__.py +216 -0
- tuner_testkit/page_test/base.py +208 -0
- tuner_testkit/page_test/driver.py +724 -0
- tuner_testkit/page_test/errors.py +51 -0
- tuner_testkit/page_test/harvest.py +526 -0
- tuner_testkit/page_test/health.py +369 -0
- tuner_testkit/page_test/locator.py +575 -0
- tuner_testkit/page_test/model.py +522 -0
- tuner_testkit/page_test/registry.py +237 -0
- tuner_testkit/page_test/session.py +192 -0
- tuner_testkit/page_test/steps.py +517 -0
- tuner_testkit/page_test/testing.py +375 -0
- tuner_testkit/project.py +180 -0
- tuner_testkit-4.0.0.dist-info/METADATA +176 -0
- tuner_testkit-4.0.0.dist-info/RECORD +174 -0
- tuner_testkit-4.0.0.dist-info/WHEEL +5 -0
- tuner_testkit-4.0.0.dist-info/entry_points.txt +14 -0
- tuner_testkit-4.0.0.dist-info/licenses/LICENSE +21 -0
- tuner_testkit-4.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Action Words — 统一管理自动化测试资产的最小业务动作层。
|
|
2
|
+
|
|
3
|
+
设计目标(见 docs/spec/action-words-syntax.md):
|
|
4
|
+
|
|
5
|
+
- 每个 action word 是一个独立可运行的业务动作(DB 造数 / DB 断言 /
|
|
6
|
+
接口请求 / 接口断言 / 页面操作 / 页面断言),入参经 pydantic 强类型
|
|
7
|
+
声明并带样例,可 CLI 一键执行,也可被 behave / pytest 步骤直接调用;
|
|
8
|
+
- 接口类 word 只做冻结 ``packages/api_objects`` 资产的引用与编排,
|
|
9
|
+
不重复定义请求契约;
|
|
10
|
+
- DB 类 word 统一走 ``tuner_testkit.db.DbClient``(经类元数据 ``datasource``
|
|
11
|
+
声明目标库),造数结果按表登记 ``cleanup``,供测试夹具精确清理。
|
|
12
|
+
|
|
13
|
+
CLI::
|
|
14
|
+
|
|
15
|
+
uv run python -m tuner_testkit.action_words list
|
|
16
|
+
uv run python -m tuner_testkit.action_words describe <word_id>
|
|
17
|
+
uv run python -m tuner_testkit.action_words run <word_id> --params '{...}'
|
|
18
|
+
uv run python -m tuner_testkit.action_words catalog --out report/action_words_catalog.json
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from tuner_testkit.action_words.base import (
|
|
23
|
+
ActionCategory,
|
|
24
|
+
ActionResult,
|
|
25
|
+
ActionWord,
|
|
26
|
+
TableRows,
|
|
27
|
+
)
|
|
28
|
+
from tuner_testkit.action_words.context import ActionContext
|
|
29
|
+
from tuner_testkit.action_words.registry import (
|
|
30
|
+
discover,
|
|
31
|
+
export_catalog,
|
|
32
|
+
get,
|
|
33
|
+
list_all,
|
|
34
|
+
register,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"ActionCategory",
|
|
39
|
+
"ActionContext",
|
|
40
|
+
"ActionResult",
|
|
41
|
+
"ActionWord",
|
|
42
|
+
"TableRows",
|
|
43
|
+
"discover",
|
|
44
|
+
"export_catalog",
|
|
45
|
+
"get",
|
|
46
|
+
"list_all",
|
|
47
|
+
"register",
|
|
48
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Action Words CLI — 列出 / 查看 / 一键运行 / 导出目录。
|
|
2
|
+
|
|
3
|
+
用法::
|
|
4
|
+
|
|
5
|
+
uv run python -m tuner_testkit.action_words list
|
|
6
|
+
uv run python -m tuner_testkit.action_words describe db_seed.create_example
|
|
7
|
+
uv run python -m tuner_testkit.action_words run db_seed.create_example --params "{...}"
|
|
8
|
+
uv run python -m tuner_testkit.action_words run db_seed.create_example --params-file p.json
|
|
9
|
+
uv run python -m tuner_testkit.action_words run db_seed.create_example --example
|
|
10
|
+
uv run python -m tuner_testkit.action_words catalog --out report/action_words_catalog.json
|
|
11
|
+
|
|
12
|
+
``run`` 未提供 ``--params`` 时使用 ``{}``(即全部默认值);``--example``
|
|
13
|
+
使用 word 自带的 ``example_params`` 样例运行。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from tuner_testkit.action_words import ActionContext, export_catalog, get, list_all
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _print_json(data: object) -> None:
|
|
26
|
+
print(json.dumps(data, ensure_ascii=False, indent=2, default=str))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cmd_list(_args: argparse.Namespace) -> int:
|
|
30
|
+
rows = [
|
|
31
|
+
{"word_id": cls.word_id, "name": cls.name, "category": str(cls.category)}
|
|
32
|
+
for cls in list_all()
|
|
33
|
+
]
|
|
34
|
+
_print_json(rows)
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _cmd_describe(args: argparse.Namespace) -> int:
|
|
39
|
+
_print_json(get(args.word_id).describe())
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
44
|
+
cls = get(args.word_id)
|
|
45
|
+
if args.example:
|
|
46
|
+
raw = dict(cls.example_params)
|
|
47
|
+
elif args.params_file:
|
|
48
|
+
raw = json.loads(Path(args.params_file).read_text(encoding="utf-8"))
|
|
49
|
+
else:
|
|
50
|
+
raw = json.loads(args.params or "{}")
|
|
51
|
+
|
|
52
|
+
with ActionContext(username=args.username, password=args.password) as ctx:
|
|
53
|
+
try:
|
|
54
|
+
result = cls(ctx).run_from_dict(raw)
|
|
55
|
+
except AssertionError as exc:
|
|
56
|
+
_print_json({"ok": False, "error": str(exc)})
|
|
57
|
+
return 1
|
|
58
|
+
_print_json(result.model_dump(mode="json"))
|
|
59
|
+
return 0 if result.ok else 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _cmd_catalog(args: argparse.Namespace) -> int:
|
|
63
|
+
catalog = export_catalog()
|
|
64
|
+
if args.out:
|
|
65
|
+
out = Path(args.out)
|
|
66
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
out.write_text(
|
|
68
|
+
json.dumps(catalog, ensure_ascii=False, indent=2, default=str),
|
|
69
|
+
encoding="utf-8",
|
|
70
|
+
)
|
|
71
|
+
print(f"catalog written: {out} ({len(catalog)} words)")
|
|
72
|
+
else:
|
|
73
|
+
_print_json(catalog)
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main(argv: list[str] | None = None) -> int:
|
|
78
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
79
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
80
|
+
|
|
81
|
+
parser = argparse.ArgumentParser(prog="python -m tuner_testkit.action_words")
|
|
82
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
83
|
+
|
|
84
|
+
sub.add_parser("list", help="列出全部已注册 action words")
|
|
85
|
+
|
|
86
|
+
p_desc = sub.add_parser("describe", help="查看某个 word 的元数据 / 入参 schema / 样例")
|
|
87
|
+
p_desc.add_argument("word_id")
|
|
88
|
+
|
|
89
|
+
p_run = sub.add_parser("run", help="一键运行某个 word")
|
|
90
|
+
p_run.add_argument("word_id")
|
|
91
|
+
p_run.add_argument("--params", help="JSON 入参字符串(默认 {})")
|
|
92
|
+
p_run.add_argument("--params-file", help="JSON 入参文件路径")
|
|
93
|
+
p_run.add_argument("--example", action="store_true", help="使用 example_params 样例运行")
|
|
94
|
+
p_run.add_argument("--username", help="接口登录用户名(覆盖环境变量/配置)")
|
|
95
|
+
p_run.add_argument("--password", help="接口登录密码(覆盖环境变量/配置)")
|
|
96
|
+
|
|
97
|
+
p_cat = sub.add_parser("catalog", help="导出全量目录 JSON(平台化数据源)")
|
|
98
|
+
p_cat.add_argument("--out", help="输出文件路径;缺省打印到 stdout")
|
|
99
|
+
|
|
100
|
+
args = parser.parse_args(argv)
|
|
101
|
+
handlers = {
|
|
102
|
+
"list": _cmd_list,
|
|
103
|
+
"describe": _cmd_describe,
|
|
104
|
+
"run": _cmd_run,
|
|
105
|
+
"catalog": _cmd_catalog,
|
|
106
|
+
}
|
|
107
|
+
return handlers[args.command](args)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""API 辅助 — 冻结 api_objects 的动态加载与登录换 token。
|
|
2
|
+
|
|
3
|
+
登录资产模块由环境变量 ``TEST_LOGIN_API_MODULE`` /
|
|
4
|
+
``TEST_LOGIN_API_ATTR`` 配置(默认未设置时明确报错),避免模板硬编码
|
|
5
|
+
业务路由目录。含连字符的包路径(如 ``packages.api_objects.prod-api.login``)
|
|
6
|
+
同样经 :func:`load_api` 按名加载。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_api(module_path: str, attr: str) -> Any:
|
|
16
|
+
"""按模块路径与属性名加载一个冻结的 APIModel 资产。"""
|
|
17
|
+
return getattr(importlib.import_module(module_path), attr)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def login_token(username: str, password: str) -> str:
|
|
21
|
+
"""调用冻结的 login 接口资产换取 bearer token。
|
|
22
|
+
|
|
23
|
+
配置(环境变量):
|
|
24
|
+
- ``TEST_LOGIN_API_MODULE`` — 如 ``tuner_testkit.api_objects.auth_api.login``
|
|
25
|
+
- ``TEST_LOGIN_API_ATTR`` — 如 ``login_post_v1``(默认 ``login_post_v1``)
|
|
26
|
+
"""
|
|
27
|
+
module = (os.getenv("TEST_LOGIN_API_MODULE") or "").strip()
|
|
28
|
+
attr = (os.getenv("TEST_LOGIN_API_ATTR") or "login_post_v1").strip()
|
|
29
|
+
if not module:
|
|
30
|
+
raise RuntimeError(
|
|
31
|
+
"未配置登录 API Object:请设置环境变量 TEST_LOGIN_API_MODULE "
|
|
32
|
+
"(及可选 TEST_LOGIN_API_ATTR),指向冻结的 login 资产;"
|
|
33
|
+
"或向 ActionContext 显式传入 bearer_token。"
|
|
34
|
+
)
|
|
35
|
+
login_model = load_api(module, attr)
|
|
36
|
+
resp = login_model.set_json({"username": username, "password": password}).execute()
|
|
37
|
+
token = _extract_token(resp)
|
|
38
|
+
if not token:
|
|
39
|
+
keys = sorted(resp.json.keys()) if isinstance(resp.json, dict) else type(resp.json)
|
|
40
|
+
raise RuntimeError(f"登录响应中未找到 token(响应顶层键: {keys})")
|
|
41
|
+
return token
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _extract_token(resp: Any) -> str | None:
|
|
45
|
+
payload = resp.json if isinstance(resp.json, dict) else {}
|
|
46
|
+
for candidate in (payload.get("token"), payload.get("access_token")):
|
|
47
|
+
if candidate:
|
|
48
|
+
return str(candidate)
|
|
49
|
+
data = payload.get("data")
|
|
50
|
+
if isinstance(data, dict):
|
|
51
|
+
for key in ("token", "access_token", "accessToken"):
|
|
52
|
+
if data.get(key):
|
|
53
|
+
return str(data[key])
|
|
54
|
+
if isinstance(data, str) and data:
|
|
55
|
+
return data
|
|
56
|
+
return None
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""DB 造数共用原语 — 参数化批量 INSERT(多方言:SQL Server / MySQL / PostgreSQL)。
|
|
2
|
+
|
|
3
|
+
注入安全说明:表名与列名是各 action word 模块内代码定义的常量(固定白名单),
|
|
4
|
+
从不接受外部输入;只有**值**经 ``%s`` 占位符传递,因此拼接列名/占位符
|
|
5
|
+
不构成注入面。
|
|
6
|
+
|
|
7
|
+
方言说明:pymssql / pymysql / psycopg 参数风格同为 ``%s``,各库仅标识符引用
|
|
8
|
+
不同(``[name]`` / `` `name` `` / ``"name"``);``bulk_insert`` 从
|
|
9
|
+
``client.dialect`` 自动选择,调用方无感。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Mapping, Sequence
|
|
14
|
+
|
|
15
|
+
from tuner_testkit.db import DbClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def quote_ident(name: str, dialect: str = "mysql") -> str:
|
|
19
|
+
"""Quote an identifier for the given SQL dialect."""
|
|
20
|
+
if dialect == "mysql":
|
|
21
|
+
return f"`{name}`"
|
|
22
|
+
if dialect == "postgresql":
|
|
23
|
+
return '"' + name.replace('"', '""') + '"'
|
|
24
|
+
return f"[{name}]"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def insert_sql(table: str, columns: Sequence[str], dialect: str = "mysql") -> str:
|
|
28
|
+
cols = ", ".join(quote_ident(c, dialect) for c in columns)
|
|
29
|
+
placeholders = ", ".join(["%s"] * len(columns))
|
|
30
|
+
return f"INSERT INTO {quote_ident(table, dialect)} ({cols}) VALUES ({placeholders})"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def bulk_insert(
|
|
34
|
+
client: DbClient,
|
|
35
|
+
table: str,
|
|
36
|
+
columns: Sequence[str],
|
|
37
|
+
rows: Sequence[Mapping[str, Any]],
|
|
38
|
+
) -> int:
|
|
39
|
+
"""将 ``rows`` 以参数化语句批量插入 ``table``。
|
|
40
|
+
|
|
41
|
+
每个 row dict 必须恰好包含 ``columns`` 中的键;值按列序取出后经
|
|
42
|
+
``%s`` 占位符传递。标识符引用按 ``client.dialect`` 自动选择方言。
|
|
43
|
+
"""
|
|
44
|
+
if not rows:
|
|
45
|
+
return 0
|
|
46
|
+
sql = insert_sql(table, columns, client.dialect)
|
|
47
|
+
params = [tuple(row[c] for c in columns) for row in rows]
|
|
48
|
+
return client.execute_many(sql, params)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Random / derived value generators for action words.
|
|
2
|
+
|
|
3
|
+
Implementation lives in ``tuner_testkit.fake``. This module re-exports the
|
|
4
|
+
historical names so ``from tuner_testkit.action_words._internal import generators``
|
|
5
|
+
keeps working.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from tuner_testkit.fake import (
|
|
10
|
+
batch_number,
|
|
11
|
+
date_str,
|
|
12
|
+
digits,
|
|
13
|
+
doc_no,
|
|
14
|
+
money,
|
|
15
|
+
now,
|
|
16
|
+
now_str,
|
|
17
|
+
qty,
|
|
18
|
+
random_future_date,
|
|
19
|
+
random_past_date,
|
|
20
|
+
task_id,
|
|
21
|
+
today_str,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"batch_number",
|
|
26
|
+
"date_str",
|
|
27
|
+
"digits",
|
|
28
|
+
"doc_no",
|
|
29
|
+
"money",
|
|
30
|
+
"now",
|
|
31
|
+
"now_str",
|
|
32
|
+
"qty",
|
|
33
|
+
"random_future_date",
|
|
34
|
+
"random_past_date",
|
|
35
|
+
"task_id",
|
|
36
|
+
"today_str",
|
|
37
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Client-side monotonic bigint id generation for data factories.
|
|
2
|
+
|
|
3
|
+
Capacity-test injection needs to control parent/child ids *before* hitting the
|
|
4
|
+
database so that a whole batch (parent rows + child rows) can be inserted with
|
|
5
|
+
``executemany`` while keeping the foreign-key linkage correct. We therefore do
|
|
6
|
+
not rely on MySQL ``auto_increment`` / ``lastrowid`` for linkage; instead we
|
|
7
|
+
mint ids here with a small snowflake-style scheme::
|
|
8
|
+
|
|
9
|
+
id = (elapsed_ms << 20) | sequence
|
|
10
|
+
|
|
11
|
+
- ``elapsed_ms`` is milliseconds since a fixed epoch (2025-01-01 UTC), so the
|
|
12
|
+
high bits keep increasing over time and stay comfortably inside MySQL
|
|
13
|
+
``bigint`` range.
|
|
14
|
+
- ``sequence`` is a 20-bit per-millisecond counter (up to 1,048,576 ids/ms);
|
|
15
|
+
when it overflows we spin to the next millisecond, so ids are strictly
|
|
16
|
+
monotonically increasing and unique within a process.
|
|
17
|
+
|
|
18
|
+
The generated ids visually resemble the sample data (e.g.
|
|
19
|
+
``2070119332155711489``) while remaining unique across a run.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
# Fixed epoch: 2025-01-01T00:00:00Z in milliseconds.
|
|
27
|
+
_EPOCH_MS = 1_735_689_600_000
|
|
28
|
+
_SEQ_BITS = 20
|
|
29
|
+
_SEQ_MASK = (1 << _SEQ_BITS) - 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class IdGenerator:
|
|
33
|
+
"""Thread-safe, strictly increasing bigint id generator."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, *, epoch_ms: int = _EPOCH_MS) -> None:
|
|
36
|
+
self._epoch_ms = epoch_ms
|
|
37
|
+
self._lock = threading.Lock()
|
|
38
|
+
self._last_ms = -1
|
|
39
|
+
self._seq = 0
|
|
40
|
+
|
|
41
|
+
def _now_ms(self) -> int:
|
|
42
|
+
return int(time.time() * 1000) - self._epoch_ms
|
|
43
|
+
|
|
44
|
+
def next(self) -> int:
|
|
45
|
+
"""Return the next unique, monotonically increasing bigint id."""
|
|
46
|
+
with self._lock:
|
|
47
|
+
now = self._now_ms()
|
|
48
|
+
if now == self._last_ms:
|
|
49
|
+
self._seq = (self._seq + 1) & _SEQ_MASK
|
|
50
|
+
if self._seq == 0:
|
|
51
|
+
# Sequence exhausted within this ms: wait for the next ms.
|
|
52
|
+
while now <= self._last_ms:
|
|
53
|
+
now = self._now_ms()
|
|
54
|
+
else:
|
|
55
|
+
self._seq = 0
|
|
56
|
+
self._last_ms = now
|
|
57
|
+
return (now << _SEQ_BITS) | self._seq
|
|
58
|
+
|
|
59
|
+
def next_batch(self, n: int) -> list[int]:
|
|
60
|
+
"""Return ``n`` unique ids in increasing order."""
|
|
61
|
+
if n < 0:
|
|
62
|
+
raise ValueError("n must be >= 0")
|
|
63
|
+
return [self.next() for _ in range(n)]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Process-wide default shared by all factories so a single run never collides
|
|
67
|
+
# parent and child ids across different factory instances.
|
|
68
|
+
default_id_generator = IdGenerator()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def next_id() -> int:
|
|
72
|
+
"""Convenience wrapper around the process-wide default generator."""
|
|
73
|
+
return default_id_generator.next()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def next_ids(n: int) -> list[int]:
|
|
77
|
+
"""Convenience wrapper returning ``n`` ids from the default generator."""
|
|
78
|
+
return default_id_generator.next_batch(n)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""轮询等待 — 异步结果断言前的同步点。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def wait_until(
|
|
9
|
+
check: Callable[[], tuple[bool, Any]],
|
|
10
|
+
*,
|
|
11
|
+
timeout: float = 90.0,
|
|
12
|
+
interval: float = 3.0,
|
|
13
|
+
desc: str = "",
|
|
14
|
+
) -> Any:
|
|
15
|
+
"""轮询 ``check`` 直到其返回 ``(True, value)``,超时抛 AssertionError。
|
|
16
|
+
|
|
17
|
+
``check`` 返回 ``(ok, detail)``:ok 为 False 时 detail 用于超时报错定位。
|
|
18
|
+
"""
|
|
19
|
+
deadline = time.monotonic() + timeout
|
|
20
|
+
detail: Any = None
|
|
21
|
+
while True:
|
|
22
|
+
ok, detail = check()
|
|
23
|
+
if ok:
|
|
24
|
+
return detail
|
|
25
|
+
if time.monotonic() >= deadline:
|
|
26
|
+
raise AssertionError(f"等待超时({timeout}s): {desc};最后状态: {detail!r}")
|
|
27
|
+
time.sleep(interval)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Action Word 基类与统一数据契约。
|
|
2
|
+
|
|
3
|
+
Action Word 是本仓库测试资产的最小可复用业务动作单元,按类别覆盖
|
|
4
|
+
DB 造数 / DB 断言 / 接口请求 / 接口断言 / 页面操作 / 页面断言六类工作。
|
|
5
|
+
每个 action word 独立可运行(CLI 一键执行),也可被 behave 步骤或 pytest
|
|
6
|
+
直接调用。
|
|
7
|
+
|
|
8
|
+
模板规范(平台化契约,违反会被 registry 注册与模板一致性测试拦截):
|
|
9
|
+
|
|
10
|
+
- 子类必须有业务 docstring(描述业务含义、前置条件、副作用);
|
|
11
|
+
- 类元数据 ``word_id`` / ``name`` / ``category`` 必填;
|
|
12
|
+
- 内嵌 ``Params``(pydantic BaseModel):每个字段必须有类型声明与
|
|
13
|
+
``Field(description=...)``,供平台以可视化表单展示;
|
|
14
|
+
- ``example_params``:一份能通过 ``Params`` 校验、可直接运行的入参样例;
|
|
15
|
+
- ``run(params)`` 为唯一执行入口;断言类 word 失败时抛 ``AssertionError``,
|
|
16
|
+
成功返回 ``Result``(含 ``detail`` 细节与造数类的 ``cleanup`` 清理登记)。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from enum import StrEnum
|
|
22
|
+
from typing import Any, ClassVar, Mapping
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
25
|
+
|
|
26
|
+
from tuner_testkit.action_words.context import ActionContext
|
|
27
|
+
from tuner_testkit.db import DEFAULT_ALIAS, DbClient
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ActionCategory(StrEnum):
|
|
31
|
+
"""Action word 的工作类别(平台侧用于分组展示与运行策略)。"""
|
|
32
|
+
|
|
33
|
+
DB_SEED = "db_seed" # DB 造数
|
|
34
|
+
DB_ASSERT = "db_assert" # DB 断言
|
|
35
|
+
API_REQUEST = "api_request" # 接口请求 / 编排
|
|
36
|
+
API_ASSERT = "api_assert" # 接口断言
|
|
37
|
+
UI_ACTION = "ui_action" # 页面操作(预留)
|
|
38
|
+
UI_ASSERT = "ui_assert" # 页面断言(预留)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TableRows(BaseModel):
|
|
42
|
+
"""一张表上本次动作产生的行 id,供调用方注册清理夹具后精确删除。"""
|
|
43
|
+
|
|
44
|
+
table: str = Field(description="表名")
|
|
45
|
+
ids: list[int] = Field(default_factory=list, description="行主键 id 列表")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ActionResult(BaseModel):
|
|
49
|
+
"""所有 action word 的统一返回基类。
|
|
50
|
+
|
|
51
|
+
子类可以增加强类型的业务字段(如 ``invoice_nos``);``detail`` 存放
|
|
52
|
+
人类可读的执行摘要,``cleanup`` 仅造数类 word 填写。
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
ok: bool = Field(True, description="动作是否成功")
|
|
56
|
+
detail: dict[str, Any] = Field(
|
|
57
|
+
default_factory=dict, description="人类可读的执行细节 / 摘要"
|
|
58
|
+
)
|
|
59
|
+
cleanup: list[TableRows] = Field(
|
|
60
|
+
default_factory=list,
|
|
61
|
+
description="造数产生的行按表登记,供测试夹具在场景结束后精确删除",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ActionWord(ABC):
|
|
66
|
+
"""所有 action word 的抽象基类。见模块 docstring 的模板规范。"""
|
|
67
|
+
|
|
68
|
+
# ---- 类元数据(平台目录 / registry 索引) ----
|
|
69
|
+
word_id: ClassVar[str]
|
|
70
|
+
name: ClassVar[str]
|
|
71
|
+
category: ClassVar[ActionCategory]
|
|
72
|
+
tags: ClassVar[tuple[str, ...]] = ()
|
|
73
|
+
#: 依赖的环境资源:"db" / "api";ActionContext 按需惰性初始化
|
|
74
|
+
requires: ClassVar[frozenset[str]] = frozenset()
|
|
75
|
+
#: 目标数据源别名(config/env.py 的 ``DATABASES`` 键)。框架支持
|
|
76
|
+
#: MySQL / SQL Server / PostgreSQL 共存,DB 类 word 在此显式声明去影响哪个库
|
|
77
|
+
#: (如主业务在 "main"、第二库在 "sqlserver" / "postgres"),
|
|
78
|
+
#: 数据库具体类型只写在配置里。
|
|
79
|
+
datasource: ClassVar[str] = DEFAULT_ALIAS
|
|
80
|
+
#: 可直接运行的入参样例(必须能通过 Params 校验)
|
|
81
|
+
example_params: ClassVar[dict[str, Any]] = {}
|
|
82
|
+
|
|
83
|
+
class Params(BaseModel):
|
|
84
|
+
"""入参契约;子类内嵌覆盖。未知键一律拒绝,避免拼写错误静默生效。"""
|
|
85
|
+
|
|
86
|
+
model_config = ConfigDict(extra="forbid")
|
|
87
|
+
|
|
88
|
+
class Result(ActionResult):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
def __init__(self, ctx: ActionContext | None = None) -> None:
|
|
92
|
+
self.ctx = ctx if ctx is not None else ActionContext()
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def db(self) -> DbClient:
|
|
96
|
+
"""本 word 声明的目标数据源连接(``ctx.get_db(cls.datasource)``)。"""
|
|
97
|
+
return self.ctx.get_db(type(self).datasource)
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
def run(self, params: Params) -> ActionResult:
|
|
101
|
+
"""执行动作。断言类失败抛 ``AssertionError``,其余异常按原样上抛。"""
|
|
102
|
+
|
|
103
|
+
def run_from_dict(self, raw: Mapping[str, Any] | None = None) -> ActionResult:
|
|
104
|
+
"""统一接入点:dict 入参 → pydantic 校验 → :meth:`run`。
|
|
105
|
+
|
|
106
|
+
behave 步骤、pytest、CLI、未来平台的在线执行都走这一入口。
|
|
107
|
+
"""
|
|
108
|
+
params = self.Params.model_validate(dict(raw or {}))
|
|
109
|
+
return self.run(params)
|
|
110
|
+
|
|
111
|
+
# ---- 平台化导出 ----
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def params_schema(cls) -> dict[str, Any]:
|
|
115
|
+
"""入参 JSON Schema(供平台渲染表单)。"""
|
|
116
|
+
return cls.Params.model_json_schema()
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def describe(cls) -> dict[str, Any]:
|
|
120
|
+
"""目录条目:元数据 + docstring + 入参 schema + 样例。"""
|
|
121
|
+
return {
|
|
122
|
+
"word_id": cls.word_id,
|
|
123
|
+
"name": cls.name,
|
|
124
|
+
"category": str(cls.category),
|
|
125
|
+
"tags": list(cls.tags),
|
|
126
|
+
"requires": sorted(cls.requires),
|
|
127
|
+
"datasource": cls.datasource,
|
|
128
|
+
"doc": (cls.__doc__ or "").strip(),
|
|
129
|
+
"params_schema": cls.params_schema(),
|
|
130
|
+
"example_params": cls.example_params,
|
|
131
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""ActionContext — action word 运行所需环境资源的惰性容器。
|
|
2
|
+
|
|
3
|
+
持有 DB 连接与 API 登录 token,按 word 的 ``requires`` 声明按需初始化:
|
|
4
|
+
|
|
5
|
+
- ``get_db(alias)``: 按 ``config.env.DATABASES`` 中的命名数据源别名取
|
|
6
|
+
:class:`tuner_testkit.db.DbClient`,实例内按别名缓存(框架支持 MySQL /
|
|
7
|
+
SQL Server 共存,word 通过类元数据 ``datasource`` 声明目标库);
|
|
8
|
+
- ``db``: 默认别名(``main``)的便捷入口,等价 ``get_db(DEFAULT_ALIAS)``;
|
|
9
|
+
- ``api_token``: 首次访问时通过可配置的登录 API Object 换取;凭据解析
|
|
10
|
+
顺序为 显式入参 > 环境变量 ``TEST_USERNAME`` / ``TEST_PASSWORD`` >
|
|
11
|
+
``config.env.TEST_ACCOUNT``;behave 的 ``-D username/password`` 由
|
|
12
|
+
environment 钩子解析后显式传入。
|
|
13
|
+
|
|
14
|
+
一个 behave scenario / 一次 CLI 运行对应一个 ActionContext 实例,
|
|
15
|
+
token 与各别名的 DB 连接在实例内缓存。
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from tuner_testkit.db import DEFAULT_ALIAS, DbClient
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ActionContext:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
db: DbClient | None = None,
|
|
30
|
+
username: str | None = None,
|
|
31
|
+
password: str | None = None,
|
|
32
|
+
bearer_token: str | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
# 别名 -> DbClient;外部注入的 db 挂在默认别名下,由注入方管理生命周期
|
|
35
|
+
self._dbs: dict[str, DbClient] = {}
|
|
36
|
+
self._injected_aliases: set[str] = set()
|
|
37
|
+
if db is not None:
|
|
38
|
+
self._dbs[DEFAULT_ALIAS] = db
|
|
39
|
+
self._injected_aliases.add(DEFAULT_ALIAS)
|
|
40
|
+
self._username = username
|
|
41
|
+
self._password = password
|
|
42
|
+
self._token = bearer_token
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
# DB
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def get_db(self, alias: str = DEFAULT_ALIAS) -> DbClient:
|
|
49
|
+
"""按命名数据源别名取 DbClient;未注入时首次访问惰性创建并缓存。"""
|
|
50
|
+
client = self._dbs.get(alias)
|
|
51
|
+
if client is None:
|
|
52
|
+
client = DbClient.for_datasource(alias)
|
|
53
|
+
self._dbs[alias] = client
|
|
54
|
+
return client
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def db(self) -> DbClient:
|
|
58
|
+
return self.get_db(DEFAULT_ALIAS)
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
# API 登录 token
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def api_token(self) -> str:
|
|
66
|
+
if self._token:
|
|
67
|
+
return self._token
|
|
68
|
+
username, password = self._resolve_credentials()
|
|
69
|
+
from tuner_testkit.action_words._internal.api import login_token
|
|
70
|
+
|
|
71
|
+
self._token = login_token(username, password)
|
|
72
|
+
return self._token
|
|
73
|
+
|
|
74
|
+
def _resolve_credentials(self) -> tuple[str, str]:
|
|
75
|
+
from tuner_testkit.config import load_project_env
|
|
76
|
+
|
|
77
|
+
env_config = load_project_env()
|
|
78
|
+
|
|
79
|
+
account: dict[str, Any] = getattr(env_config, "TEST_ACCOUNT", None) or {}
|
|
80
|
+
username = self._username or os.getenv("TEST_USERNAME") or account.get("username") or ""
|
|
81
|
+
password = self._password or os.getenv("TEST_PASSWORD") or account.get("password") or ""
|
|
82
|
+
if not username or not password:
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
"缺少登录凭据:请在 config/env.py 的 TEST_ACCOUNT 填入,"
|
|
85
|
+
"或通过 behave -D username=... -D password=...,"
|
|
86
|
+
"或环境变量 TEST_USERNAME / TEST_PASSWORD 提供"
|
|
87
|
+
)
|
|
88
|
+
return str(username), str(password)
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# 生命周期
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def close(self) -> None:
|
|
95
|
+
"""关闭自建的 DB 连接;外部注入的连接由注入方管理。"""
|
|
96
|
+
for alias, client in list(self._dbs.items()):
|
|
97
|
+
if alias not in self._injected_aliases:
|
|
98
|
+
client.close()
|
|
99
|
+
del self._dbs[alias]
|
|
100
|
+
|
|
101
|
+
def __enter__(self) -> "ActionContext":
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
105
|
+
self.close()
|