@pzy560117/opentest 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/assets/manifest.json +1 -1
- package/assets/skills/opentest/scripts/opentest-detect.sh +6 -0
- package/assets/skills/opentest/scripts/opentest-state.sh +6 -1
- package/assets/skills/opentest/templates/matrix-template.md +4 -4
- package/assets/skills/opentest-author/SKILL.md +8 -7
- package/assets/skills/opentest-plan/SKILL.md +5 -3
- package/assets/skills/opentest-run/SKILL.md +7 -4
- package/assets/skills/opentest-verify/SKILL.md +2 -0
- package/assets/skills-zh/opentest/templates/matrix-template.md +4 -4
- package/assets/skills-zh/opentest-author/SKILL.md +8 -7
- package/assets/skills-zh/opentest-plan/SKILL.md +5 -3
- package/assets/skills-zh/opentest-run/SKILL.md +7 -4
- package/assets/skills-zh/opentest-verify/SKILL.md +2 -0
- package/bin/opentest.js +191 -10
- package/package.json +1 -1
- package/scripts/smoke-test.js +65 -0
package/README.md
CHANGED
|
@@ -12,6 +12,8 @@ plan -> author -> run -> accept -> verify -> archive
|
|
|
12
12
|
|
|
13
13
|
`heal` is a recovery phase for stale test assets and must not hide product behavior failures.
|
|
14
14
|
|
|
15
|
+
When OpenTest needs code-level tests and the project does not declare an existing test framework, it defaults to `pytest`. Coverage completeness is enforced through the acceptance-to-test matrix: every applicable behavior, boundary, and risk surface must have evidence or an explicit gap/blocker with a recovery path.
|
|
16
|
+
|
|
15
17
|
Recommended placement in a development loop:
|
|
16
18
|
|
|
17
19
|
```text
|
|
@@ -26,10 +28,19 @@ requirement / design
|
|
|
26
28
|
|
|
27
29
|
## Install
|
|
28
30
|
|
|
29
|
-
Install
|
|
31
|
+
Install the package, then run the initializer from any project:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install -g @pzy560117/opentest
|
|
35
|
+
opentest init
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`opentest init` lets you choose the target tool, language, project/global scope, and project path. This is the recommended path for Claude Code, Codex, Cursor, OpenCode, Gemini CLI, Qwen Code, and Qoder users.
|
|
39
|
+
|
|
40
|
+
You can still run fully scripted installs. For example, install Chinese OpenTest skills for Claude Code in the current project:
|
|
30
41
|
|
|
31
42
|
```bash
|
|
32
|
-
|
|
43
|
+
opentest install --scope project --platform claude --language zh
|
|
33
44
|
```
|
|
34
45
|
|
|
35
46
|
Install Chinese OpenTest skills for Qoder in a specific project:
|
|
@@ -98,6 +109,7 @@ Restart the target AI programming tool or open a new session after installation
|
|
|
98
109
|
|
|
99
110
|
## Package Contents
|
|
100
111
|
|
|
101
|
-
- `assets/skills/`:
|
|
112
|
+
- `assets/skills/`: English skill files
|
|
113
|
+
- `assets/skills-zh/`: Chinese skill files
|
|
102
114
|
- `assets/manifest.json`: published asset manifest
|
|
103
115
|
- `bin/opentest.js`: installer CLI
|
package/assets/manifest.json
CHANGED
|
@@ -14,6 +14,7 @@ package_script_present() {
|
|
|
14
14
|
|
|
15
15
|
summary() {
|
|
16
16
|
printf '=== OpenTest Project Facts ===\n'
|
|
17
|
+
printf 'default_test_framework: pytest\n'
|
|
17
18
|
|
|
18
19
|
if [ -f package.json ]; then
|
|
19
20
|
printf 'package_json: present\n'
|
|
@@ -29,6 +30,11 @@ summary() {
|
|
|
29
30
|
else
|
|
30
31
|
printf 'python_tests: missing\n'
|
|
31
32
|
fi
|
|
33
|
+
if command -v python >/dev/null 2>&1 && python -m pytest --version >/dev/null 2>&1; then
|
|
34
|
+
printf 'pytest_command: present\n'
|
|
35
|
+
else
|
|
36
|
+
printf 'pytest_command: missing\n'
|
|
37
|
+
fi
|
|
32
38
|
|
|
33
39
|
if [ -f pom.xml ]; then printf 'maven_project: present\n'; else printf 'maven_project: missing\n'; fi
|
|
34
40
|
if [ -f Cargo.toml ]; then printf 'cargo_project: present\n'; else printf 'cargo_project: missing\n'; fi
|
|
@@ -69,7 +69,7 @@ require_state_file() {
|
|
|
69
69
|
|
|
70
70
|
validate_known_field() {
|
|
71
71
|
case "$1" in
|
|
72
|
-
workflow|phase|plan|matrix|acceptance|run_mode|run_report|verification_result|verification_report|archived|created_at|updated_at)
|
|
72
|
+
workflow|phase|plan|matrix|acceptance|run_mode|run_report|test_framework|coverage_report|verification_result|verification_report|archived|created_at|updated_at)
|
|
73
73
|
return 0
|
|
74
74
|
;;
|
|
75
75
|
*)
|
|
@@ -113,6 +113,8 @@ matrix: null
|
|
|
113
113
|
acceptance: null
|
|
114
114
|
run_mode: targeted
|
|
115
115
|
run_report: null
|
|
116
|
+
test_framework: pytest
|
|
117
|
+
coverage_report: null
|
|
116
118
|
verification_result: pending
|
|
117
119
|
verification_report: null
|
|
118
120
|
archived: false
|
|
@@ -147,6 +149,9 @@ cmd_set() {
|
|
|
147
149
|
verification_result)
|
|
148
150
|
validate_enum "$value" pending pass fail risk-accepted
|
|
149
151
|
;;
|
|
152
|
+
test_framework)
|
|
153
|
+
validate_enum "$value" pytest project-existing none
|
|
154
|
+
;;
|
|
150
155
|
archived)
|
|
151
156
|
validate_enum "$value" true false
|
|
152
157
|
;;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Acceptance-to-Test Matrix
|
|
2
2
|
|
|
3
|
-
| ID | Intent | Trigger/Input | Expected behavior | Risk | Evidence layer | Required evidence | Status |
|
|
4
|
-
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
5
|
-
| ACC-001 | Happy path succeeds | | | low | smoke | targeted review | pending |
|
|
6
|
-
| ACC-002 | Failure or boundary path | | | medium | acceptance | UI/API acceptance | pending |
|
|
3
|
+
| ID | Intent | Coverage dimension | Trigger/Input | Expected behavior | Risk | Evidence layer | Framework/command | Required evidence | Gap/blocker | Status |
|
|
4
|
+
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
5
|
+
| ACC-001 | Happy path succeeds | main path | | | low | smoke | project command or `python -m pytest` | targeted review | none | pending |
|
|
6
|
+
| ACC-002 | Failure or boundary path | failure/boundary | | | medium | acceptance | natural-language acceptance or `python -m pytest` | UI/API acceptance | none | pending |
|
|
@@ -15,13 +15,14 @@ This phase turns the matrix into executable evidence. Write tests for behavior t
|
|
|
15
15
|
|
|
16
16
|
1. Read `matrix` from `.opentest.yaml`.
|
|
17
17
|
2. For unit/component/integration/contract evidence, create or update test files using the project's existing test framework.
|
|
18
|
-
3.
|
|
19
|
-
4. For
|
|
20
|
-
5.
|
|
21
|
-
6.
|
|
22
|
-
7.
|
|
23
|
-
8.
|
|
18
|
+
3. Default to pytest when code-level tests are required and the project has no explicit framework rule or existing framework. Prefer `tests/` and a command that can run with `python -m pytest`.
|
|
19
|
+
4. For E2E, smoke, browser acceptance, real APIs, or cross-page flows, write natural language acceptance cases under `docs/opentest/acceptance/`.
|
|
20
|
+
5. For frontend feedback cases, specify the feedback location and shape, such as field-level errors, form-level errors, toast, modal, inline status, or page error state.
|
|
21
|
+
6. Record reasons and risk for evidence that is not applicable or cannot currently be created.
|
|
22
|
+
7. Write the `.opentest.yaml` `acceptance` field.
|
|
23
|
+
8. If the project uses Loop Handoff, update `docs/loop-handoff/latest.md` with test assets, natural language acceptance cases, gaps/risks, and next step.
|
|
24
|
+
9. Run `bash "$OPENTEST_GUARD" author --apply`.
|
|
24
25
|
|
|
25
26
|
## Existing Skill Routing
|
|
26
27
|
|
|
27
|
-
When the matrix requires code-level test evidence, load the existing TDD guidance first. If project rules require a specific test framework, follow those rules. If the current task is better proven by real acceptance than by adding test framework code, record the reason and hand it to `opentest-accept`.
|
|
28
|
+
When the matrix requires code-level test evidence, load the existing TDD guidance first. If project rules require a specific test framework, follow those rules. If no project framework is present, use pytest as the default instead of inventing a custom harness. If the current task is better proven by real acceptance than by adding test framework code, record the reason and hand it to `opentest-accept`.
|
|
@@ -15,10 +15,10 @@ This phase is the entry point for OpenTest-driven development. Before implementa
|
|
|
15
15
|
|
|
16
16
|
1. Read project rules, requirements, design, diff, existing test commands, and `opentest/references/codex-harness-coverage-heuristics.md`.
|
|
17
17
|
2. If the change involves frontend UI, forms, navigation, CRUD, status feedback, or motion, first read the project's `docs/frontend/DESIGN.md` and any available `harness-frontend-design` / `product-ui-defaults` rules.
|
|
18
|
-
3. Classify the change type, risk level, and
|
|
18
|
+
3. Classify the change type, risk level, applicable coverage dimensions, and whether code-level evidence should use an existing project framework or the default `pytest` framework.
|
|
19
19
|
4. Mine implicit scenarios: empty input, invalid input, network failure, missing permissions, duplicate submission, long content, empty data, error mapping, mobile behavior, accessibility, cross-page return paths, and state restoration.
|
|
20
|
-
5. Produce a narrow matrix with at least `ID`, `intent`, `trigger/input`, `expected behavior`, `risk`, `evidence layer`, `required evidence`, and `status`.
|
|
21
|
-
6. Mark applicable but uncovered evidence as `gap
|
|
20
|
+
5. Produce a narrow matrix with at least `ID`, `intent`, `coverage dimension`, `trigger/input`, `expected behavior`, `risk`, `evidence layer`, `framework/command`, `required evidence`, `gap/blocker`, and `status`.
|
|
21
|
+
6. Mark applicable but uncovered evidence as `gap`, and record a concrete blocker or recovery path instead of leaving coverage unknown.
|
|
22
22
|
7. Write the `.opentest.yaml` `plan` and `matrix` fields.
|
|
23
23
|
8. If the project uses Loop Handoff, update `docs/loop-handoff/latest.md` with the plan path, matrix path, current phase, and next step.
|
|
24
24
|
9. Run `bash "$OPENTEST_GUARD" plan --apply`.
|
|
@@ -28,3 +28,5 @@ This phase is the entry point for OpenTest-driven development. Before implementa
|
|
|
28
28
|
Do not expand the full Codex Harness checklist into fixed required items. Keep a lightweight coverage summary for low-risk changes, and expand detailed acceptance dimensions only for high-risk loops.
|
|
29
29
|
|
|
30
30
|
Do not force all evidence into unit tests. The matrix must state which behavior is proven by unit, component, integration, contract, E2E, smoke, browser acceptance, or security review evidence.
|
|
31
|
+
|
|
32
|
+
Coverage completeness means every applicable product behavior, failure path, boundary, and risk surface has either required evidence or an explicit gap/blocker. It does not mean every task must run every possible test type.
|
|
@@ -18,11 +18,14 @@ Run existing project verification commands and write a run report.
|
|
|
18
18
|
|
|
19
19
|
Evidence layers are decided by the matrix. Do not run only unit tests just because `npm test` exists. If the matrix requires integration, contract, E2E, smoke, or security checks, run the matching project command or record missing command / blocked.
|
|
20
20
|
|
|
21
|
+
If `.opentest.yaml` has `test_framework: pytest`, or code-level test evidence is required and no project framework is declared, use `python -m pytest` as the default test command. When the matrix requires coverage evidence, prefer `python -m pytest --cov=. --cov-report=term-missing` and write the coverage output path to `.opentest.yaml` `coverage_report`. If pytest or pytest-cov is unavailable, record `missing command` with the install/remediation step instead of passing the gate.
|
|
22
|
+
|
|
21
23
|
## Steps
|
|
22
24
|
|
|
23
25
|
1. Read `run_mode` and the matrix from `.opentest.yaml`.
|
|
24
26
|
2. Prefer explicit project commands, and select targeted, fast, full, or ci-like mode based on matrix evidence layers.
|
|
25
|
-
3.
|
|
26
|
-
4.
|
|
27
|
-
5.
|
|
28
|
-
6.
|
|
27
|
+
3. If no explicit command exists for code-level tests, run or document the default pytest command.
|
|
28
|
+
4. Record command, exit code, summary, coverage output, and log path under `docs/opentest/runs/`.
|
|
29
|
+
5. Write the `.opentest.yaml` `run_report` field, and write `coverage_report` when coverage evidence was required or produced.
|
|
30
|
+
6. If the project uses Loop Handoff, update `docs/loop-handoff/latest.md` with verification already run, verification not run, failures/blockers, run report path, and next step.
|
|
31
|
+
7. Run `bash "$OPENTEST_GUARD" run --apply`.
|
|
@@ -19,6 +19,8 @@ If the project uses Loop Handoff, also write the OpenTest verification report pa
|
|
|
19
19
|
The verification report must also review the matrix and confirm:
|
|
20
20
|
|
|
21
21
|
- Every required evidence item has a pass, fail, blocked, or risk-accepted conclusion.
|
|
22
|
+
- Coverage completeness is checked: every applicable coverage dimension in the matrix has required evidence, a run/acceptance result, or an explicit gap/blocker with recovery path.
|
|
22
23
|
- High-risk behavior does not pass merely because tooling, a test framework, or seed data is missing.
|
|
24
|
+
- If code-level coverage was required and `test_framework: pytest` is active, the report must include the `python -m pytest` command result and the `coverage_report` path, or fail/block with a reason.
|
|
23
25
|
- Blocked evidence includes a blocker reason and recovery path.
|
|
24
26
|
- If product behavior fails, do not enter `heal` to patch test assets; return to implementation or requirement correction.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 验收到测试矩阵
|
|
2
2
|
|
|
3
|
-
| ID | 意图 | 触发/输入 | 期望行为 | 风险 | 证据层级 | 必需证据 | 状态 |
|
|
4
|
-
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
5
|
-
| ACC-001 | 主路径成功 | | | low | smoke | targeted review | pending |
|
|
6
|
-
| ACC-002 | 失败/边界路径 | | | medium | acceptance | UI/API 验收 | pending |
|
|
3
|
+
| ID | 意图 | 覆盖维度 | 触发/输入 | 期望行为 | 风险 | 证据层级 | 框架/命令 | 必需证据 | 缺口/阻塞 | 状态 |
|
|
4
|
+
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
5
|
+
| ACC-001 | 主路径成功 | 主路径 | | | low | smoke | 项目命令或 `python -m pytest` | targeted review | 无 | pending |
|
|
6
|
+
| ACC-002 | 失败/边界路径 | 失败/边界 | | | medium | acceptance | 自然语言验收或 `python -m pytest` | UI/API 验收 | 无 | pending |
|
|
@@ -15,13 +15,14 @@ description: "OpenTest 阶段 2:根据矩阵补齐测试资产和自然语言
|
|
|
15
15
|
|
|
16
16
|
1. 读取 `.opentest.yaml` 中的 `matrix`。
|
|
17
17
|
2. 对 unit/component/integration/contract 证据,按项目已有测试框架创建或更新测试文件。
|
|
18
|
-
3.
|
|
19
|
-
4.
|
|
20
|
-
5.
|
|
21
|
-
6.
|
|
22
|
-
7.
|
|
23
|
-
8.
|
|
18
|
+
3. 当矩阵要求代码级测试、且项目没有明确框架规则或既有框架时,默认使用 pytest;优先放在 `tests/`,并确保可用 `python -m pytest` 运行。
|
|
19
|
+
4. 对 E2E、smoke、browser acceptance、真实 API 或跨页面流程,写入 `docs/opentest/acceptance/` 自然语言验收用例。
|
|
20
|
+
5. 对前端反馈类用例,写清反馈位置和形态,例如字段下方错误、表单顶部错误、轻提示、模态框、行内状态或页面错误态。
|
|
21
|
+
6. 对不适用或当前无法补齐的证据,记录原因和风险。
|
|
22
|
+
7. 写入 `.opentest.yaml` 的 `acceptance` 字段。
|
|
23
|
+
8. 更新 `docs/loop-handoff/latest.md`(如果项目使用 Loop Handoff),记录测试资产、自然语言验收用例、gap/risk 和下一步。
|
|
24
|
+
9. 运行 `bash "$OPENTEST_GUARD" author --apply`。
|
|
24
25
|
|
|
25
26
|
## 现有技能路由
|
|
26
27
|
|
|
27
|
-
当矩阵要求代码级测试证据时,优先加载现有 TDD guidance
|
|
28
|
+
当矩阵要求代码级测试证据时,优先加载现有 TDD guidance。若项目规则要求特定测试框架,遵循项目规则。若项目没有测试框架,默认使用 pytest,不临时发明自定义测试壳。若当前任务更适合真实验收而不是新增测试框架代码,记录原因并交给 `opentest-accept`。
|
|
@@ -15,10 +15,10 @@ description: "OpenTest 阶段 1:分析变更、风险和项目事实,生成
|
|
|
15
15
|
|
|
16
16
|
1. 读取项目规则、需求、设计、diff、现有测试命令和 `opentest/references/codex-harness-coverage-heuristics.md`。
|
|
17
17
|
2. 如果存在前端、表单、导航、CRUD、状态反馈或动效,优先读取项目内 `docs/frontend/DESIGN.md` 和可用的 `harness-frontend-design` / `product-ui-defaults` 规则。
|
|
18
|
-
3.
|
|
18
|
+
3. 判断变更类型、风险等级、适用覆盖维度,以及代码级证据应使用项目已有框架还是默认 `pytest` 框架。
|
|
19
19
|
4. 做隐性场景挖掘:空输入、非法输入、网络失败、权限不足、重复提交、长内容、空数据、错误映射、移动端、可访问性、跨页面返回和状态恢复。
|
|
20
|
-
5. 生成窄矩阵,至少包含 `ID
|
|
21
|
-
6. 对适用但未覆盖的证据面标记 `gap
|
|
20
|
+
5. 生成窄矩阵,至少包含 `ID`、`意图`、`覆盖维度`、`触发/输入`、`期望行为`、`风险`、`证据层级`、`框架/命令`、`必需证据`、`缺口/阻塞`、`状态`。
|
|
21
|
+
6. 对适用但未覆盖的证据面标记 `gap`,并写清具体阻塞或恢复路径,不把覆盖状态留成 unknown。
|
|
22
22
|
7. 写入 `.opentest.yaml` 的 `plan` 和 `matrix` 字段。
|
|
23
23
|
8. 更新 `docs/loop-handoff/latest.md`(如果项目使用 Loop Handoff),记录 plan、matrix 路径、当前阶段和下一步。
|
|
24
24
|
9. 运行 `bash "$OPENTEST_GUARD" plan --apply`。
|
|
@@ -28,3 +28,5 @@ description: "OpenTest 阶段 1:分析变更、风险和项目事实,生成
|
|
|
28
28
|
不要把 Codex Harness 全量 checklist 展开成固定必需项。低风险变更保留轻量覆盖摘要,高风险闭环再展开详细验收维度。
|
|
29
29
|
|
|
30
30
|
不要把所有证据都写成 unit test。矩阵要明确哪些用 unit/component/integration/contract/E2E/smoke/browser acceptance/security review 证明。
|
|
31
|
+
|
|
32
|
+
覆盖完整性指每个适用的产品行为、失败路径、边界和风险面都有必需证据,或有明确的 gap/blocker;不是要求每次任务都跑所有测试类型。
|
|
@@ -18,11 +18,14 @@ description: "OpenTest 阶段 3:按 targeted、fast、full 或 ci-like 模式
|
|
|
18
18
|
|
|
19
19
|
证据层级由矩阵决定。不要因为存在 `npm test` 就只跑 unit test;如果矩阵要求 integration、contract、E2E、smoke 或安全检查,必须运行对应项目命令,或记录 missing command / blocked。
|
|
20
20
|
|
|
21
|
+
如果 `.opentest.yaml` 中 `test_framework: pytest`,或矩阵要求代码级测试但项目没有声明测试框架,默认测试命令使用 `python -m pytest`。当矩阵要求覆盖率证据时,优先使用 `python -m pytest --cov=. --cov-report=term-missing`,并把覆盖率输出路径写入 `.opentest.yaml` 的 `coverage_report`。如果缺少 pytest 或 pytest-cov,记录 `missing command` 和安装/恢复步骤,不允许直接通过质量门。
|
|
22
|
+
|
|
21
23
|
## 步骤
|
|
22
24
|
|
|
23
25
|
1. 读取 `.opentest.yaml` 的 `run_mode` 和矩阵。
|
|
24
26
|
2. 优先使用项目显式命令,按矩阵中的证据层级选择 targeted、fast、full 或 ci-like。
|
|
25
|
-
3.
|
|
26
|
-
4.
|
|
27
|
-
5.
|
|
28
|
-
6.
|
|
27
|
+
3. 如果代码级测试没有显式命令,运行或记录默认 pytest 命令。
|
|
28
|
+
4. 记录命令、退出码、摘要、覆盖率输出和日志路径到 `docs/opentest/runs/`。
|
|
29
|
+
5. 写入 `.opentest.yaml` 的 `run_report` 字段;当要求或产生覆盖率证据时,也写入 `coverage_report`。
|
|
30
|
+
6. 更新 `docs/loop-handoff/latest.md`(如果项目使用 Loop Handoff),记录已运行验证、未运行验证、失败/阻塞、run report 路径和下一步。
|
|
31
|
+
7. 运行 `bash "$OPENTEST_GUARD" run --apply`。
|
|
@@ -19,6 +19,8 @@ description: "OpenTest 阶段 5:应用质量门并生成验证报告。"
|
|
|
19
19
|
验证报告还必须回看矩阵,确认:
|
|
20
20
|
|
|
21
21
|
- 每个 required evidence 都有 pass、fail、blocked 或 risk-accepted 结论。
|
|
22
|
+
- 覆盖完整性已检查:矩阵中的每个适用覆盖维度都有必需证据、运行/验收结果,或带恢复路径的明确 gap/blocker。
|
|
22
23
|
- 高风险行为不能因为没有工具、没有测试框架或没有 seed data 就直接通过。
|
|
24
|
+
- 如果要求代码级覆盖率且 `test_framework: pytest` 生效,报告必须包含 `python -m pytest` 命令结果和 `coverage_report` 路径;否则必须 fail/block 并写明原因。
|
|
23
25
|
- blocked evidence 必须包含阻塞原因和后续恢复路径。
|
|
24
26
|
- 若产品行为失败,不进入 `heal` 修测试资产;应返回实现或需求修正。
|
package/bin/opentest.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { readFileSync } from 'fs';
|
|
4
4
|
import { copyFile, mkdir, readdir, rm, stat } from 'fs/promises';
|
|
5
5
|
import path from 'path';
|
|
6
|
+
import { createInterface } from 'readline/promises';
|
|
7
|
+
import { stdin as input, stdout as output } from 'process';
|
|
6
8
|
import { fileURLToPath } from 'url';
|
|
7
9
|
|
|
8
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -42,13 +44,14 @@ function usage(exitCode = 0) {
|
|
|
42
44
|
const text = `OpenTest skill installer
|
|
43
45
|
|
|
44
46
|
Usage:
|
|
47
|
+
opentest init [--scope <project|global>] [--platform <id>] [--language <id>] [--project <path>] [--force]
|
|
45
48
|
opentest install [--scope <project|global>] [--platform <id>] [--language <id>] [--project <path>] [--global] [--force]
|
|
46
49
|
opentest list [--language <id>]
|
|
47
50
|
|
|
48
51
|
Options:
|
|
49
52
|
--scope SCOPE Install scope: project or global (default: project)
|
|
50
|
-
--platform ID Target platform: ${availableIds(manifest.platforms)} (
|
|
51
|
-
--language ID Skill language: ${availableIds(manifest.languages)} (default:
|
|
53
|
+
--platform ID Target platform: ${availableIds(manifest.platforms)} (auto-detected when possible)
|
|
54
|
+
--language ID Skill language: ${availableIds(manifest.languages)} (default: locale-aware)
|
|
52
55
|
--project PATH Project root for project scope (default: current directory)
|
|
53
56
|
--global Alias for --scope global --platform codex
|
|
54
57
|
--force Overwrite existing opentest skill directories
|
|
@@ -61,11 +64,12 @@ function parseArgs(argv) {
|
|
|
61
64
|
const [command, ...rest] = argv;
|
|
62
65
|
const options = {
|
|
63
66
|
command,
|
|
64
|
-
scope:
|
|
65
|
-
platform:
|
|
66
|
-
language:
|
|
67
|
+
scope: undefined,
|
|
68
|
+
platform: undefined,
|
|
69
|
+
language: undefined,
|
|
67
70
|
force: false,
|
|
68
71
|
project: process.cwd(),
|
|
72
|
+
explicitTarget: false,
|
|
69
73
|
};
|
|
70
74
|
|
|
71
75
|
for (let i = 0; i < rest.length; i += 1) {
|
|
@@ -73,28 +77,34 @@ function parseArgs(argv) {
|
|
|
73
77
|
if (arg === '--global') {
|
|
74
78
|
options.scope = 'global';
|
|
75
79
|
options.platform = 'codex';
|
|
80
|
+
options.explicitTarget = true;
|
|
76
81
|
} else if (arg === '--force') {
|
|
77
82
|
options.force = true;
|
|
83
|
+
options.explicitTarget = true;
|
|
78
84
|
} else if (arg === '--scope') {
|
|
79
85
|
const value = rest[i + 1];
|
|
80
86
|
if (!value) usage(1);
|
|
81
87
|
options.scope = value;
|
|
88
|
+
options.explicitTarget = true;
|
|
82
89
|
i += 1;
|
|
83
90
|
} else if (arg === '--platform') {
|
|
84
91
|
const value = rest[i + 1];
|
|
85
92
|
if (!value) usage(1);
|
|
86
93
|
options.platform = value;
|
|
94
|
+
options.explicitTarget = true;
|
|
87
95
|
i += 1;
|
|
88
96
|
} else if (arg === '--language') {
|
|
89
97
|
const value = rest[i + 1];
|
|
90
98
|
if (!value) usage(1);
|
|
91
99
|
options.language = value;
|
|
100
|
+
options.explicitTarget = true;
|
|
92
101
|
i += 1;
|
|
93
102
|
} else if (arg === '--project') {
|
|
94
103
|
const value = rest[i + 1];
|
|
95
104
|
if (!value) usage(1);
|
|
96
105
|
options.scope = 'project';
|
|
97
106
|
options.project = path.resolve(value);
|
|
107
|
+
options.explicitTarget = true;
|
|
98
108
|
i += 1;
|
|
99
109
|
} else {
|
|
100
110
|
usage(1);
|
|
@@ -116,6 +126,61 @@ function homeRelativePath(manifestPath) {
|
|
|
116
126
|
return manifestPath.replace(/^~[\\/]/, '');
|
|
117
127
|
}
|
|
118
128
|
|
|
129
|
+
function platformById(platformId) {
|
|
130
|
+
return manifest.platforms.find((entry) => entry.id === platformId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function languageById(languageId) {
|
|
134
|
+
return manifest.languages.find((entry) => entry.id === languageId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function detectPlatform(projectRoot) {
|
|
138
|
+
const envHints = [
|
|
139
|
+
['OPENTEST_PLATFORM', process.env.OPENTEST_PLATFORM],
|
|
140
|
+
['CLAUDECODE', process.env.CLAUDECODE ? 'claude' : undefined],
|
|
141
|
+
['CLAUDE_CODE', process.env.CLAUDE_CODE ? 'claude' : undefined],
|
|
142
|
+
['CODEX', process.env.CODEX ? 'codex' : undefined],
|
|
143
|
+
];
|
|
144
|
+
for (const [, value] of envHints) {
|
|
145
|
+
if (value && platformById(value)) {
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const platform of manifest.platforms) {
|
|
151
|
+
if (await exists(path.join(projectRoot, platform.projectSkillsDir))) {
|
|
152
|
+
return platform.id;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function detectLanguage() {
|
|
160
|
+
const explicit = process.env.OPENTEST_LANGUAGE;
|
|
161
|
+
if (explicit && languageById(explicit)) {
|
|
162
|
+
return explicit;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const localeText = [
|
|
166
|
+
process.env.LC_ALL,
|
|
167
|
+
process.env.LC_MESSAGES,
|
|
168
|
+
process.env.LANG,
|
|
169
|
+
Intl.DateTimeFormat().resolvedOptions().locale,
|
|
170
|
+
].filter(Boolean).join(' ').toLowerCase();
|
|
171
|
+
|
|
172
|
+
return localeText.includes('zh') ? 'zh' : 'en';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function withInstallDefaults(options) {
|
|
176
|
+
return {
|
|
177
|
+
...options,
|
|
178
|
+
scope: options.scope ?? 'project',
|
|
179
|
+
platform: options.platform ?? await detectPlatform(options.project) ?? 'codex',
|
|
180
|
+
language: options.language ?? detectLanguage(),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
119
184
|
function targetSkillsDir(options, platform) {
|
|
120
185
|
if (options.scope === 'global') {
|
|
121
186
|
return path.join(homeDir(), homeRelativePath(platform.globalSkillsDir));
|
|
@@ -150,6 +215,119 @@ async function exists(filePath) {
|
|
|
150
215
|
return stat(filePath).then(() => true, () => false);
|
|
151
216
|
}
|
|
152
217
|
|
|
218
|
+
function formatChoiceList(entries) {
|
|
219
|
+
return entries.map((entry, index) => `${index + 1}) ${entry.name} [${entry.id}]`).join('\n');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function matchChoice(entries, answer) {
|
|
223
|
+
const trimmed = answer.trim().toLowerCase();
|
|
224
|
+
if (!trimmed) return undefined;
|
|
225
|
+
|
|
226
|
+
const numeric = Number.parseInt(trimmed, 10);
|
|
227
|
+
if (Number.isInteger(numeric) && numeric >= 1 && numeric <= entries.length) {
|
|
228
|
+
return entries[numeric - 1].id;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return entries.find((entry) => entry.id.toLowerCase() === trimmed)?.id;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function askChoice(rl, label, entries, defaultId) {
|
|
235
|
+
const defaultEntry = entries.find((entry) => entry.id === defaultId) ?? entries[0];
|
|
236
|
+
for (;;) {
|
|
237
|
+
const answer = await rl.question(`${label}\n${formatChoiceList(entries)}\nChoose [${defaultEntry.id}]: `);
|
|
238
|
+
const selected = matchChoice(entries, answer) ?? (answer.trim() ? undefined : defaultEntry.id);
|
|
239
|
+
if (selected) {
|
|
240
|
+
return selected;
|
|
241
|
+
}
|
|
242
|
+
console.log(`Please enter a number or one of: ${availableIds(entries)}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function askText(rl, label, defaultValue) {
|
|
247
|
+
const answer = await rl.question(`${label} [${defaultValue}]: `);
|
|
248
|
+
return answer.trim() || defaultValue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function askYesNo(rl, label, defaultValue = false) {
|
|
252
|
+
const suffix = defaultValue ? 'Y/n' : 'y/N';
|
|
253
|
+
for (;;) {
|
|
254
|
+
const answer = (await rl.question(`${label} [${suffix}]: `)).trim().toLowerCase();
|
|
255
|
+
if (!answer) return defaultValue;
|
|
256
|
+
if (['y', 'yes'].includes(answer)) return true;
|
|
257
|
+
if (['n', 'no'].includes(answer)) return false;
|
|
258
|
+
console.log('Please answer y or n.');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function readPipedInput() {
|
|
263
|
+
return new Promise((resolve, reject) => {
|
|
264
|
+
let text = '';
|
|
265
|
+
input.setEncoding('utf8');
|
|
266
|
+
input.on('data', (chunk) => {
|
|
267
|
+
text += chunk;
|
|
268
|
+
});
|
|
269
|
+
input.on('end', () => resolve(text));
|
|
270
|
+
input.on('error', reject);
|
|
271
|
+
input.resume();
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function createPrompter() {
|
|
276
|
+
if (input.isTTY) {
|
|
277
|
+
return createInterface({ input, output });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const lines = (await readPipedInput()).split(/\r?\n/);
|
|
281
|
+
let index = 0;
|
|
282
|
+
return {
|
|
283
|
+
async question(prompt) {
|
|
284
|
+
output.write(prompt);
|
|
285
|
+
const answer = lines[index] ?? '';
|
|
286
|
+
index += 1;
|
|
287
|
+
return answer;
|
|
288
|
+
},
|
|
289
|
+
close() {},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function hasManagedInstall(options, currentManifest) {
|
|
294
|
+
const platform = resolvePlatform(options.platform);
|
|
295
|
+
const target = targetSkillsDir(options, platform);
|
|
296
|
+
for (const skillDir of managedSkillDirs(currentManifest)) {
|
|
297
|
+
if (await exists(path.join(target, skillDir))) {
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function promptInstallOptions(options, currentManifest) {
|
|
305
|
+
const defaults = await withInstallDefaults(options);
|
|
306
|
+
const rl = await createPrompter();
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
console.log('OpenTest init\n');
|
|
310
|
+
defaults.platform = await askChoice(rl, 'Target AI coding tool:', manifest.platforms, defaults.platform);
|
|
311
|
+
defaults.language = await askChoice(rl, 'Skill language:', manifest.languages, defaults.language);
|
|
312
|
+
defaults.scope = await askChoice(rl, 'Install scope:', [
|
|
313
|
+
{ id: 'project', name: 'Current project' },
|
|
314
|
+
{ id: 'global', name: 'User global' },
|
|
315
|
+
], defaults.scope);
|
|
316
|
+
|
|
317
|
+
if (defaults.scope === 'project') {
|
|
318
|
+
defaults.project = path.resolve(await askText(rl, 'Project root', defaults.project));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (!defaults.force && await hasManagedInstall(defaults, currentManifest)) {
|
|
322
|
+
defaults.force = await askYesNo(rl, 'OpenTest skills already exist. Overwrite managed OpenTest directories?', false);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return defaults;
|
|
326
|
+
} finally {
|
|
327
|
+
rl.close();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
153
331
|
async function copyManifestFile(sourceRoot, targetRoot, relativePath) {
|
|
154
332
|
const src = path.join(sourceRoot, relativePath);
|
|
155
333
|
const dest = path.join(targetRoot, relativePath);
|
|
@@ -207,17 +385,20 @@ async function main() {
|
|
|
207
385
|
usage(0);
|
|
208
386
|
}
|
|
209
387
|
|
|
210
|
-
const language = resolveLanguage(options.language);
|
|
211
|
-
const platform = resolvePlatform(options.platform);
|
|
212
|
-
resolveScope(options.scope);
|
|
213
|
-
|
|
214
388
|
if (options.command === 'list') {
|
|
389
|
+
const language = resolveLanguage(options.language ?? detectLanguage());
|
|
215
390
|
await listSkills(language);
|
|
216
391
|
return;
|
|
217
392
|
}
|
|
218
393
|
|
|
394
|
+
if (options.command === 'init' || (options.command === 'install' && !options.explicitTarget && process.stdin.isTTY)) {
|
|
395
|
+
const promptedOptions = await promptInstallOptions(options, manifest);
|
|
396
|
+
await install(promptedOptions, manifest);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
219
400
|
if (options.command === 'install') {
|
|
220
|
-
await install(options, manifest);
|
|
401
|
+
await install(await withInstallDefaults(options), manifest);
|
|
221
402
|
return;
|
|
222
403
|
}
|
|
223
404
|
|
package/package.json
CHANGED
package/scripts/smoke-test.js
CHANGED
|
@@ -156,6 +156,33 @@ function assertPrepublishGateCoversManifestAssets() {
|
|
|
156
156
|
assert(prepublishCheck.includes("assets/skills-zh"), '[PREPUBLISH] must validate assets/skills-zh exists');
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
function assertDefaultPytestContracts() {
|
|
160
|
+
const stateScript = readFileSync('assets/skills/opentest/scripts/opentest-state.sh', 'utf8');
|
|
161
|
+
const detectScript = readFileSync('assets/skills/opentest/scripts/opentest-detect.sh', 'utf8');
|
|
162
|
+
const englishAuthor = readFileSync('assets/skills/opentest-author/SKILL.md', 'utf8');
|
|
163
|
+
const chineseAuthor = readFileSync('assets/skills-zh/opentest-author/SKILL.md', 'utf8');
|
|
164
|
+
const englishRun = readFileSync('assets/skills/opentest-run/SKILL.md', 'utf8');
|
|
165
|
+
const chineseRun = readFileSync('assets/skills-zh/opentest-run/SKILL.md', 'utf8');
|
|
166
|
+
const englishVerify = readFileSync('assets/skills/opentest-verify/SKILL.md', 'utf8');
|
|
167
|
+
const chineseVerify = readFileSync('assets/skills-zh/opentest-verify/SKILL.md', 'utf8');
|
|
168
|
+
const englishMatrix = readFileSync('assets/skills/opentest/templates/matrix-template.md', 'utf8');
|
|
169
|
+
const chineseMatrix = readFileSync('assets/skills-zh/opentest/templates/matrix-template.md', 'utf8');
|
|
170
|
+
|
|
171
|
+
assert(stateScript.includes('test_framework: pytest'), '[PYTEST] state init must default test_framework to pytest');
|
|
172
|
+
assert(stateScript.includes('coverage_report: null'), '[PYTEST] state init must track coverage_report');
|
|
173
|
+
assert(stateScript.includes('test_framework|'), '[PYTEST] state script must allow test_framework field updates');
|
|
174
|
+
assert(detectScript.includes('default_test_framework: pytest'), '[PYTEST] detect summary must report pytest as default framework');
|
|
175
|
+
assert(detectScript.includes('pytest_command:'), '[PYTEST] detect summary must report pytest command status');
|
|
176
|
+
assert(englishAuthor.includes('Default to pytest'), '[PYTEST] English author skill must default missing framework to pytest');
|
|
177
|
+
assert(chineseAuthor.includes('默认使用 pytest'), '[PYTEST] Chinese author skill must default missing framework to pytest');
|
|
178
|
+
assert(englishRun.includes('python -m pytest'), '[PYTEST] English run skill must document pytest command');
|
|
179
|
+
assert(chineseRun.includes('python -m pytest'), '[PYTEST] Chinese run skill must document pytest command');
|
|
180
|
+
assert(englishVerify.includes('Coverage completeness'), '[COVERAGE] English verify skill must gate coverage completeness');
|
|
181
|
+
assert(chineseVerify.includes('覆盖完整性'), '[COVERAGE] Chinese verify skill must gate coverage completeness');
|
|
182
|
+
assert(englishMatrix.includes('Coverage dimension') && englishMatrix.includes('Framework/command') && englishMatrix.includes('Gap/blocker'), '[COVERAGE] English matrix template must include coverage, command, and gap columns');
|
|
183
|
+
assert(chineseMatrix.includes('覆盖维度') && chineseMatrix.includes('框架/命令') && chineseMatrix.includes('缺口/阻塞'), '[COVERAGE] Chinese matrix template must include coverage, command, and gap columns');
|
|
184
|
+
}
|
|
185
|
+
|
|
159
186
|
async function assertPrepublishRejectsPublishSecretTextFiles() {
|
|
160
187
|
const fixtureDir = path.join('assets', 'opentest-smoke-secret-fixtures');
|
|
161
188
|
const fixtures = [
|
|
@@ -418,11 +445,48 @@ async function assertInstallBehavior() {
|
|
|
418
445
|
noForceResult.stderr.includes('already exists. Re-run with --force to overwrite.'),
|
|
419
446
|
`[INSTALL] expected overwrite guidance for existing skill\n${noForceResult.stderr}`,
|
|
420
447
|
);
|
|
448
|
+
|
|
449
|
+
const detectedProject = await makeTempDir('opentest-detected-');
|
|
450
|
+
const detectedResult = assertCliSucceeds(['install', '--project', detectedProject], {
|
|
451
|
+
env: {
|
|
452
|
+
OPENTEST_PLATFORM: 'claude',
|
|
453
|
+
OPENTEST_LANGUAGE: 'zh',
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
await assertExists(
|
|
457
|
+
path.join(detectedProject, '.claude', 'skills', 'opentest', 'SKILL.md'),
|
|
458
|
+
'[INSTALL] OPENTEST_PLATFORM=claude must install project skills into .claude/skills',
|
|
459
|
+
);
|
|
460
|
+
assert(detectedResult.stdout.includes('Platform: Claude Code'), '[INSTALL] detected output must include Platform: Claude Code');
|
|
461
|
+
assert(detectedResult.stdout.includes('Language: 中文'), '[INSTALL] detected output must include Language: 中文');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function assertInitBehavior() {
|
|
465
|
+
const project = await makeTempDir('opentest-init-');
|
|
466
|
+
const initResult = assertCliSucceeds(['init'], {
|
|
467
|
+
input: `2\n2\n1\n${project}\n`,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
await assertExists(
|
|
471
|
+
path.join(project, '.claude', 'skills', 'opentest', 'SKILL.md'),
|
|
472
|
+
'[INIT] interactive init must install Claude project skills when Claude Code is selected',
|
|
473
|
+
);
|
|
474
|
+
await assertExists(
|
|
475
|
+
path.join(project, '.claude', 'skills', 'opentest', 'scripts', 'opentest-state.sh'),
|
|
476
|
+
'[INIT] interactive init must install shared OpenTest scripts',
|
|
477
|
+
);
|
|
478
|
+
assert(initResult.stdout.includes('OpenTest init'), '[INIT] output must show init wizard heading');
|
|
479
|
+
assert(initResult.stdout.includes('Target AI coding tool:'), '[INIT] output must prompt for platform');
|
|
480
|
+
assert(initResult.stdout.includes('Skill language:'), '[INIT] output must prompt for language');
|
|
481
|
+
assert(initResult.stdout.includes('Install scope:'), '[INIT] output must prompt for scope');
|
|
482
|
+
assert(initResult.stdout.includes('Platform: Claude Code'), '[INIT] output must include selected platform');
|
|
483
|
+
assert(initResult.stdout.includes('Language: 中文'), '[INIT] output must include selected language');
|
|
421
484
|
}
|
|
422
485
|
|
|
423
486
|
runManifestPathRelativitySelfCheck();
|
|
424
487
|
assertManifestStructure();
|
|
425
488
|
assertPrepublishGateCoversManifestAssets();
|
|
489
|
+
assertDefaultPytestContracts();
|
|
426
490
|
assertOpenTestSearchRootsCoverPlatforms();
|
|
427
491
|
assertLanguageAssetContracts();
|
|
428
492
|
await assertPrepublishRejectsPublishSecretTextFiles();
|
|
@@ -431,6 +495,7 @@ assertCliRejects(['install', '--language', 'jp'], 'Available languages: en, zh')
|
|
|
431
495
|
assertCliRejects(['install', '--platform', 'invalid'], 'Available platforms: codex, claude, cursor, opencode, gemini, qwen, qoder');
|
|
432
496
|
assertCliRejects(['install', '--scope', 'team'], 'Available scopes: project, global');
|
|
433
497
|
await assertInstallBehavior();
|
|
498
|
+
await assertInitBehavior();
|
|
434
499
|
|
|
435
500
|
for (const assetPath of [...localizedFiles(), ...sharedAssetFiles()]) {
|
|
436
501
|
const fullPath = path.join('assets', assetPath);
|