diff --git a/.agents/docs/2026-11-08-mtest-design.md b/.agents/docs/2026-11-08-mtest-design.md
index 8bc6ccc..3d684ba 100644
--- a/.agents/docs/2026-11-08-mtest-design.md
+++ b/.agents/docs/2026-11-08-mtest-design.md
@@ -311,3 +311,17 @@ mtest 不止是开发者写测试的接口:围绕测试结果的数据、分
- **M3** 已决定:首批项目 PR CI 墙钟中位数减半,发布 CI 关键路径缩短 30%,以迁移前测量的正式基线为准。
- **M4** 已决定:其他语言以工具支持与薄的 FFI 接口(C ABI 报告库)接入,不提供其他语言的完整测试库。
- **M5** 已决定:本地存储为按运行分文件的 NDJSON 加索引,可直接作为 CI 产物。
+
+## 14. 用例的运行场所与机器能力(0.8.0)
+
+**问题。** 0.7.0 的 `[capabilities]` 只由引擎在选择时求值。实现协议的其他运行器(`mcpp test`)与单独运行的程序不求值,使用方因而在用例上同时写标签 `requires:<词>`、在用例体内再次判断,并另维护一份探测表;用例必须通过的平台另写在 `[ci.coverage] hosts` 的表中,与用例分离。
+
+**决定。**
+
+1. **场所。** `only{...}` 与 `except{...}` 以 mcpplibs.os 的平台与架构陈述用例在何处运行:`only` 的平台之间、架构之间为"或",二者之间为"与";套件与用例的 `only` 取交集,`except` 累加。场所之外,用例跳过,原因为 `only on <场所> (this host: <系统> <架构>)` 或 `not on <场所> (...)`。库、Bash 与 Python 适配器由程序自行判定;引擎依据清单中的 `only`、`except` 同样判定,以覆盖只列出场所的实现(C ABI)。
+2. **编译期选择。** 不新增宏或常量:mtest 重新导出 mcpplibs.os 的 `platform`、`architecture`、`can_run_on`、`host`、`on
`。`on
([&](auto){...})` 的函数体在不能运行于 `P` 的构建中被丢弃,承担 `if constexpr` 的作用;openkal 镜像在加载时得知 `host()`,与第 3.1 节一致。
+3. **能力。** `capability` 以 `[capabilities]` 的同一组检验(系统、架构、PATH 上的程序、glob、命令、环境变量、`all`/`none`)加一个函数在代码中声明一次,由程序在每个进程中求值一次,并作为属性使用(`"x"_test[gcc]`,同时带标签 `requires:gcc`)。缺失时用例跳过,原因与引擎相同:`missing capability: <名> (<未成立的检验>)`。在模块接口中声明的能力先于导入它的单元初始化,因此命名空间作用域的套件可以使用。
+4. **协议。** `case` 事件新增可选字段 `only`、`except`、`capabilities`(名到布尔)与 `reason`(`expect` 为 `skip` 时的原因)。引擎对程序已回答的能力词不再要求 `[capabilities]` 声明,并把答案记入运行记录;`[capabilities]` 继续服务于脚本程序。
+5. **覆盖。** `mtest ci coverage` 要求陈述了 `only` 的用例在所收集运行覆盖到的每个场所上通过,且至少有一个运行位于其场所;场所由用例自身陈述,不再需要表格。
+
+**兼容。** 新字段均为可选;未陈述场所与能力的程序、清单与配置的含义不变。
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 44b236c..2da2b64 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,34 @@
# Changelog
+## [0.8.0] - 2026-11-14
+
+- **Where a case runs is stated by the case** (`only{}`, `except{}`). A case or a suite names the
+ platforms and architectures of `mcpplibs.os` it runs on (`only{platform::windows}`,
+ `only{platform::linux, architecture::x86_64}`) or not (`except{platform::macos}`); a suite's and
+ its case's narrow together. Elsewhere it is skipped with one reason, `only on windows (this host:
+ linux x86_64)`. The Bash adapter (`--only`, `--except`), the Python adapter (`only=`,
+ `excepted=`) and the C ABI (`mtest_case_only`, `mtest_case_except`) state the same; the engine
+ skips a listed case outside its places without starting it.
+- **Code for one platform is selected when it is compiled.** mtest re-exports mcpplibs.os's
+ `platform`, `architecture`, `can_run_on`, `host` and `on
`: `on([&](auto) {
+ ... })` is a body that a build for another platform discards, and an ok image decides when it is
+ loaded.
+- **What a machine can do is declared in code and answered by the program** (`capability`). The
+ tests of `[capabilities]` -- a system, an architecture, programs on PATH, a file a glob matches, a
+ command, environment values, other capabilities that must or must not hold -- and a function for
+ what they cannot state, answered once per process. A capability is an attribute
+ (`"links"_test[gcc]`, with the tag `requires:gcc`); a case whose machine lacks it is skipped,
+ `missing capability: gcc (no g++ on PATH)`, under any runner (`mtest`, `mcpp test`, the program
+ alone). The engine takes the program's answers: a word the program answers needs no
+ `[capabilities]` entry, and the run records it.
+- **The listing says why a case is skipped, and where it runs** (P3.3): a `case` event may carry
+ `reason` (with `expect: skip`), `only`, `except` and `capabilities`; the schema states them. The
+ conformance suite checks them of every level-3 implementation (`placed`).
+- **`mtest ci coverage` checks each case on its places**: a case that states `only` must pass on
+ each of its places that a collected run covered, and some run must have been on one -- what
+ `[ci.coverage] hosts` states of a program, stated by the case.
+- Additive: a program, a listing and a configuration that state none of these mean what they did.
+
## [0.7.2] - 2026-11-13
- **Each call measured by itself** (`bench::each`, `mcpplibs.mtest.bench`). `bench::measure` times
diff --git a/README.md b/README.md
index 3ed4083..0dd14b5 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@ int main() {
```toml
[dev-dependencies]
-mcpplibs.mtest = "0.7.2"
+mcpplibs.mtest = "0.8.0"
```
A failure names its place and both sides (`expected 2, actual 3`). Under a runner the same program
@@ -62,6 +62,12 @@ E2E cli{"cli", resource{"serial:home"}, timeout{std::chrono::minutes{2}}, [] {
}};
```
+Where a case runs and what it needs of the machine are attributes too: `only{platform::windows}`,
+`except{architecture::aarch64}`, and a `capability` declared once in code
+(`const capability gcc{"gcc", {.program = {"g++"}}};`, then `"links"_test[gcc]`). The program
+skips a case elsewhere, saying why, under any runner; `on([&](auto) { ... })`
+is code a build for another platform discards.
+
## Tests, with the engine
```bash
diff --git a/README.zh-CN.md b/README.zh-CN.md
index f8c6c31..3330b3b 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -37,7 +37,7 @@ int main() {
```toml
[dev-dependencies]
-mcpplibs.mtest = "0.7.2"
+mcpplibs.mtest = "0.8.0"
```
失败时给出位置与两侧的值(`expected 2, actual 3`)。在运行器之下,同一程序按协议报告;单独运行时每个用例输出一行。
@@ -57,6 +57,8 @@ E2E cli{"cli", resource{"serial:home"}, timeout{std::chrono::minutes{2}}, [] {
}};
```
+用例在何处运行、需要机器具备什么,同样是属性:`only{platform::windows}`、`except{architecture::aarch64}`,以及在代码中声明一次的 `capability`(`const capability gcc{"gcc", {.program = {"g++"}}};`,再写 `"links"_test[gcc]`)。无论由哪个运行器启动,程序都在场所之外跳过用例并说明原因;`on([&](auto) { ... })` 中的代码在其他平台的构建中被丢弃。
+
## 用引擎运行测试
```bash
diff --git a/adapters/gtest/mcpp.toml b/adapters/gtest/mcpp.toml
index ac4ef33..7835dca 100644
--- a/adapters/gtest/mcpp.toml
+++ b/adapters/gtest/mcpp.toml
@@ -13,7 +13,7 @@
[package]
namespace = "mcpplibs.mtest"
name = "gtest"
-version = "0.7.2"
+version = "0.8.0"
description = "GoogleTest speaking the mtest protocol: a test main that lists, selects and reports GoogleTest's cases"
license = "Apache-2.0"
diff --git a/adapters/gtest/src/protocol.cpp b/adapters/gtest/src/protocol.cpp
index 33b25f7..19d541c 100644
--- a/adapters/gtest/src/protocol.cpp
+++ b/adapters/gtest/src/protocol.cpp
@@ -128,7 +128,7 @@ int mcpplibs_mtest_gtest_main(int argc, char** argv) {
{"protocol", 1},
{"level", 1},
{"framework", "googletest"},
- {"version", "0.7.2"},
+ {"version", "0.8.0"},
{"isolation", {"process", "case"}},
{"guide", "https://github.com/mcpp-community/mtest/blob/main/adapters/gtest/docs/"
"writing-tests.md"}});
diff --git a/adapters/script/mtest.sh b/adapters/script/mtest.sh
index 6bf0a1a..597bf36 100644
--- a/adapters/script/mtest.sh
+++ b/adapters/script/mtest.sh
@@ -54,12 +54,48 @@ _mtest_selected() {
# mtest_case ID FUNCTION [--tag T] [--kind K] [--clause C] [--resource R] [--artifact A] [--timeout SECONDS]
# [--isolate] [--retries N] [--diagnose-env NAME=VALUE] [--diagnose-timeout SECONDS]
# [--at-most METRIC=VALUE] [--at-least METRIC=VALUE]
-# [--expect-fail REASON] [--skip REASON]
+# [--expect-fail REASON] [--skip REASON] [--only PLACE] [--except PLACE]
+# A PLACE is a platform (linux, macos, windows) or an architecture (x86_64, aarch64, riscv64): with
+# --only the case runs only on one of the platforms named, when any is, and on one of the
+# architectures, when any is; with --except it does not run on any named. Elsewhere it is skipped.
# A case that states no kind is of the program's (its directory's, docs/layout.md).
+# This host's platform and architecture, in mcpplibs.os's names.
+_mtest_host_platform() {
+ case "$(uname -s)" in
+ Linux*) echo linux ;; Darwin*) echo macos ;; MINGW*|MSYS*|CYGWIN*|Windows*) echo windows ;;
+ *) uname -s | tr '[:upper:]' '[:lower:]' ;;
+ esac
+}
+_mtest_host_arch() {
+ case "$(uname -m)" in
+ x86_64|amd64|AMD64) echo x86_64 ;; aarch64|arm64|ARM64) echo aarch64 ;; *) uname -m ;;
+ esac
+}
+_mtest_is_arch() { case "$1" in x86_64|aarch64|riscv64|wasm32) return 0 ;; esac; return 1; }
+
+# _mtest_place_skip N ONLY... EXCEPT...: why the case does not run here, or nothing.
+_mtest_place_skip() {
+ local n="$1"; shift
+ local system arch here words="" w platforms=0 archs=0 platform_ok=0 arch_ok=0 i=0
+ system="$(_mtest_host_platform)"; arch="$(_mtest_host_arch)"; here="$system $arch"
+ while [ $i -lt "$n" ]; do
+ w="$1"; shift; i=$((i + 1))
+ words="${words:+$words, }$w"
+ if _mtest_is_arch "$w"; then archs=1; [ "$w" = "$arch" ] && arch_ok=1
+ else platforms=1; [ "$w" = "$system" ] && platform_ok=1; fi
+ done
+ if { [ $platforms -eq 1 ] && [ $platform_ok -eq 0 ]; } || { [ $archs -eq 1 ] && [ $arch_ok -eq 0 ]; }; then
+ echo "only on $words (this host: $here)"; return 0
+ fi
+ for w in "$@"; do
+ if [ "$w" = "$system" ] || [ "$w" = "$arch" ]; then echo "not on $w (this host: $here)"; return 0; fi
+ done
+}
+
mtest_case() {
local id="$1" fn="$2"; shift 2
local tags=() clauses=() resources=() artifacts=() kind="" limit="" xfail="" skip=""
- local isolate="" retries="" diagnose=() diagnose_limit="" bounds=()
+ local isolate="" retries="" diagnose=() diagnose_limit="" bounds=() only=() except=()
while [ $# -gt 0 ]; do
case "$1" in
--tag) tags+=("$2"); shift 2 ;;
@@ -76,10 +112,13 @@ mtest_case() {
--at-least) bounds+=("min:$2"); shift 2 ;;
--expect-fail) xfail="$2"; shift 2 ;;
--skip) skip="$2"; shift 2 ;;
+ --only) only+=("$2"); shift 2 ;;
+ --except) except+=("$2"); shift 2 ;;
*) echo "mtest_case: unknown option $1" >&2; shift ;;
esac
done
_mtest_seen+=("$id")
+ [ -z "$skip" ] && skip="$(_mtest_place_skip "${#only[@]}" ${only[@]+"${only[@]}"} ${except[@]+"${except[@]}"})"
if [ "$_mtest_mode" = list ]; then
local ev
ev="\"e\":\"case\",\"id\":$(_mtest_json "$id"),\"name\":$(_mtest_json "$id"),\"file\":$(_mtest_json "${BASH_SOURCE[1]:-$0}"),\"line\":${BASH_LINENO[0]:-0},\"tags\":$(_mtest_list "${tags[@]}"),\"clauses\":$(_mtest_list "${clauses[@]}"),\"resources\":$(_mtest_list "${resources[@]}"),\"artifacts\":$(_mtest_list "${artifacts[@]}")"
@@ -103,7 +142,9 @@ mtest_case() {
done
ev+=",\"bounds\":[$list]"
fi
- [ -n "$skip" ] && ev+=',"expect":"skip"'
+ [ ${#only[@]} -gt 0 ] && ev+=",\"only\":$(_mtest_list "${only[@]}")"
+ [ ${#except[@]} -gt 0 ] && ev+=",\"except\":$(_mtest_list "${except[@]}")"
+ [ -n "$skip" ] && ev+=",\"expect\":\"skip\",\"reason\":$(_mtest_json "$skip")"
[ -n "$xfail" ] && [ -z "$skip" ] && ev+=',"expect":"fail"'
_mtest_event "$ev"
return 0
diff --git a/adapters/script/mtest_report.py b/adapters/script/mtest_report.py
index 1b24342..70284d4 100644
--- a/adapters/script/mtest_report.py
+++ b/adapters/script/mtest_report.py
@@ -17,6 +17,7 @@ def reads():
import inspect
import json
import os
+import platform
import sys
import time
@@ -128,13 +129,42 @@ def _check_bounds(at_most, at_least):
_failure(f"metric {name} = {reported[name]:g}, under its bound {limit:g} (at_least)", depth=3)
+_ARCHITECTURES = ("x86_64", "aarch64", "riscv64", "wasm32")
+
+
+def _host():
+ """This host's platform and architecture, in mcpplibs.os's names."""
+ system = {"Linux": "linux", "Darwin": "macos", "Windows": "windows"}.get(platform.system(), platform.system().lower())
+ machine = platform.machine()
+ arch = {"amd64": "x86_64", "AMD64": "x86_64", "arm64": "aarch64", "ARM64": "aarch64"}.get(machine, machine)
+ return system, arch
+
+
+def _place_skip(only, excepted):
+ """Why a case with these places does not run here, or None."""
+ system, arch = _host()
+ here = f"{system} {arch}"
+ platforms = [w for w in only if w not in _ARCHITECTURES]
+ archs = [w for w in only if w in _ARCHITECTURES]
+ if (platforms and system not in platforms) or (archs and arch not in archs):
+ return f"only on {', '.join(only)} (this host: {here})"
+ for w in excepted:
+ if w in (system, arch):
+ return f"not on {w} (this host: {here})"
+ return None
+
+
def case(id, tags=(), kind=None, clauses=(), resources=(), artifacts=(), expect_fail=None, skip_reason=None,
- timeout=None, isolate=False, retries=None, diagnose=None, at_most=None, at_least=None):
+ timeout=None, isolate=False, retries=None, diagnose=None, at_most=None, at_least=None, only=(), excepted=()):
"""Declares a case: lists it, runs it, or passes over it, as the runner asks. A case that states no
kind is of its program's (its directory's, docs/layout.md). `timeout` (seconds), `isolate`,
`retries` and `diagnose` ({"env": {...}, "timeout": seconds}) are how a runner runs it (P3.3);
- `at_most` and `at_least` ({metric: value}) bound its metrics (P6.5)."""
+ `at_most` and `at_least` ({metric: value}) bound its metrics (P6.5). `only` and `excepted` name
+ platforms (linux, macos, windows) and architectures (x86_64, aarch64, riscv64): the case runs only
+ on one of the platforms and one of the architectures `only` names, when it names any, and on none
+ `excepted` names; elsewhere it is skipped."""
at_most, at_least = dict(at_most or {}), dict(at_least or {})
+ skip_reason = skip_reason or _place_skip(list(only), list(excepted))
def declare(fn):
global _failed, _current
_seen.append(id)
@@ -154,8 +184,13 @@ def declare(fn):
fields["diagnose"] = diagnose
if at_most or at_least:
fields["bounds"] = _bounds_json(at_most, at_least)
+ if only:
+ fields["only"] = list(only)
+ if excepted:
+ fields["except"] = list(excepted)
if skip_reason:
fields["expect"] = "skip"
+ fields["reason"] = skip_reason
elif expect_fail:
fields["expect"] = "fail"
_event(**fields)
diff --git a/adapters/script/tests/fixture.py b/adapters/script/tests/fixture.py
index bf39df3..f21ec53 100755
--- a/adapters/script/tests/fixture.py
+++ b/adapters/script/tests/fixture.py
@@ -48,6 +48,11 @@ def holds():
pass
+@mt.case("placed", only=["wasm32"])
+def placed():
+ pass
+
+
@mt.case("bounded", at_most={"elapsed": 0.1})
def bounded():
mt.metric("elapsed", 0.5, "s")
diff --git a/adapters/script/tests/fixture.sh b/adapters/script/tests/fixture.sh
index 71de070..3d97d22 100755
--- a/adapters/script/tests/fixture.sh
+++ b/adapters/script/tests/fixture.sh
@@ -16,6 +16,7 @@ mtest_case "group/inner" nothing
mtest_case "measures" measures --kind bench
mtest_case "covers" nothing --kind cts --clause P3.1
mtest_case "holds" nothing --resource serial:db --artifact file:data/x.txt
+mtest_case "placed" nothing --only wasm32
slow() { mtest_metric elapsed 0.5 s; }
mtest_case "bounded" slow --at-most elapsed=0.1
mtest_report
diff --git a/capi/fixture/main.c b/capi/fixture/main.c
index 6d97938..2cce2ad 100644
--- a/capi/fixture/main.c
+++ b/capi/fixture/main.c
@@ -37,6 +37,8 @@ int main(void) {
mtest_case("holds", __FILE__, __LINE__, "unit");
mtest_case_resource("serial:db");
mtest_case_artifact("file:data/x.txt");
+ mtest_case("placed", __FILE__, __LINE__, "unit");
+ mtest_case_only("wasm32");
mtest_case("bounded", __FILE__, __LINE__, NULL);
mtest_bound("bounded", "elapsed", NAN, 0.1);
return mtest_done();
@@ -49,6 +51,8 @@ int main(void) {
run("measures", "pass", NULL, 0);
run("covers", "pass", NULL, 0);
run("holds", "pass", NULL, 0);
+ /* The C ABI lists places and leaves the decision to its program: this one runs nowhere else. */
+ run("placed", "skip", "only on wasm32", 0);
run("bounded", "pass", NULL, 0); /* its bound fails it */
return mtest_done();
}
diff --git a/capi/include/mtest.h b/capi/include/mtest.h
index beaec93..ada8b0c 100644
--- a/capi/include/mtest.h
+++ b/capi/include/mtest.h
@@ -33,6 +33,12 @@ void mtest_case_tag(const char* tag);
void mtest_case_clause(const char* clause);
void mtest_case_resource(const char* resource);
void mtest_case_artifact(const char* artifact);
+/* Where the case last listed runs (0.8.0): a platform (linux, macos, windows) or an architecture
+ (x86_64, aarch64, riscv64). With `only` it runs only on one of the platforms named, when any is,
+ and on one of the architectures, when any is; with `except` on none named. The engine skips it
+ elsewhere, saying where; a program that runs alone decides for itself. */
+void mtest_case_only(const char* place);
+void mtest_case_except(const char* place);
/* How long the case last listed may run, in seconds (P3.3). */
void mtest_case_timeout(double seconds);
/* The case last listed runs in a process of its own (P3.3). */
diff --git a/capi/mcpp.toml b/capi/mcpp.toml
index 38c5cd3..dfab92f 100644
--- a/capi/mcpp.toml
+++ b/capi/mcpp.toml
@@ -4,7 +4,7 @@
[package]
namespace = "mcpplibs.mtest"
name = "capi"
-version = "0.7.2"
+version = "0.8.0"
description = "mtest's C ABI: the protocol's events for any language with a C FFI"
license = "Apache-2.0"
diff --git a/capi/src/mtest.cpp b/capi/src/mtest.cpp
index 0f9589d..ba8afb3 100644
--- a/capi/src/mtest.cpp
+++ b/capi/src/mtest.cpp
@@ -23,7 +23,7 @@ std::string text(const char* value) {
struct pending_case {
event body;
- std::vector tags, clauses, resources, artifacts;
+ std::vector tags, clauses, resources, artifacts, only, except;
bool expect_fail = false;
double timeout = 0;
bool isolated = false;
@@ -78,6 +78,10 @@ struct state {
p.body["clauses"] = p.clauses;
p.body["resources"] = p.resources;
p.body["artifacts"] = p.artifacts;
+ if (!p.only.empty())
+ p.body["only"] = p.only;
+ if (!p.except.empty())
+ p.body["except"] = p.except;
if (p.expect_fail)
p.body["expect"] = "fail";
if (p.timeout > 0)
@@ -116,7 +120,7 @@ void mtest_init(const char* framework, int level) {
{"protocol", 1},
{"level", std::clamp(level, 1, 3)},
{"framework", text(framework)},
- {"version", "0.7.2"},
+ {"version", "0.8.0"},
{"isolation", {"process", "case"}}});
}
@@ -161,6 +165,12 @@ void mtest_case_resource(const char* value) {
void mtest_case_artifact(const char* value) {
attribute(&pending_case::artifacts, value);
}
+void mtest_case_only(const char* value) {
+ attribute(&pending_case::only, value);
+}
+void mtest_case_except(const char* value) {
+ attribute(&pending_case::except, value);
+}
void mtest_case_timeout(double seconds) {
auto& s = the();
diff --git a/conformance/mcpp.toml b/conformance/mcpp.toml
index de3206e..efa275e 100644
--- a/conformance/mcpp.toml
+++ b/conformance/mcpp.toml
@@ -8,7 +8,7 @@
[package]
namespace = "mcpplibs.mtest"
name = "conformance"
-version = "0.7.2"
+version = "0.8.0"
description = "The conformance suite of the mtest protocol, for every implementation of its program side"
license = "Apache-2.0"
diff --git a/conformance/src/fixture.cpp b/conformance/src/fixture.cpp
index 3a27ec2..3f6d1b0 100644
--- a/conformance/src/fixture.cpp
+++ b/conformance/src/fixture.cpp
@@ -17,6 +17,7 @@ int main() {
};
"covers"_test[kind::cts][clause{"P3.1"}] = [] {};
"holds"_test[resource{"serial:db"}][artifact{"file:data/x.txt"}] = [] {};
+ "placed"_test[only{architecture::wasm32}] = [] {};
"bounded"_test[at_most{"elapsed", 0.1}] = [] { metric("elapsed", 0.5, "s"); };
return report();
}
diff --git a/conformance/tests/protocol.cpp b/conformance/tests/protocol.cpp
index 27a623e..f0a7514 100644
--- a/conformance/tests/protocol.cpp
+++ b/conformance/tests/protocol.cpp
@@ -115,8 +115,10 @@ std::vector fixture_ids(int level) {
"measures", "covers", "holds"})
if (level >= 3 || s != "expected to fail")
out.push_back(id(s));
- if (level >= 3)
+ if (level >= 3) {
out.push_back(id("bounded"));
+ out.push_back(id("placed"));
+ }
return out;
}
@@ -205,6 +207,23 @@ int main() {
auto xf = find(r, "case", id("expected to fail"));
expect(fatal(xf.has_value()));
expect(eq(xf->value("expect", ""), std::string("fail")));
+ // Where it runs (0.8.0): the places it runs only on, and a case it lists skipped says why.
+ auto placed = find(r, "case", id("placed"));
+ expect(fatal(placed.has_value()));
+ expect(placed->value("only", json::array()) == json::array({"wasm32"})) << placed->dump();
+ if (placed->value("expect", "") == "skip")
+ expect(contains(placed->value("reason", ""), "only on wasm32")) << placed->dump();
+ };
+ "P3.3 a case outside its places is not run"_test[kind::cts][clause{"P3.3"}] = [] {
+ const auto r = run("run");
+ if (level(r) < 3)
+ skip("the implementation is below level 3");
+ if (os::ARCH == os::architecture::wasm32)
+ skip("this host is the fixture's place");
+ auto end = find(r, "end", id("placed"));
+ expect(fatal(end.has_value()));
+ expect(eq(end->value("status", ""), std::string("skip")));
+ expect(contains(end->value("reason", ""), "only on wasm32")) << end->dump();
};
"P4.1 a selection runs exactly the selected cases"_test[kind::cts][clause{"P4.1"}] = [] {
const auto r = run("run", std::vector{id("passes"), id("group/inner")});
diff --git a/docs/adapters.md b/docs/adapters.md
index 99a4840..9003a84 100644
--- a/docs/adapters.md
+++ b/docs/adapters.md
@@ -25,5 +25,13 @@ Every level-3 implementation states the same attributes of a case, in its own wo
| diagnosis | `diagnose{}` | `--diagnose-env K=V`, `--diagnose-timeout S` | `diagnose={"env": {...}}` | `mtest_case_diagnose(k, v, s)` |
| bounds (P6.5) | `at_most{}`, `at_least{}` | `--at-most M=V`, `--at-least M=V` | `at_most={M: V}`, `at_least=` | `mtest_bound(id, metric, min, max)` |
| expected verdict | `expect_failure{}`, `skip_if{}` | `--expect-fail`, `--skip` | `expect_fail=`, `skip_reason=` | `mtest_case_expect_fail()` |
+| places (0.8.0) | `only{}`, `except{}` | `--only P`, `--except P` | `only=[...]`, `excepted=[...]` | `mtest_case_only(p)`, `mtest_case_except(p)` |
+| capabilities (0.8.0) | a `capability` | `# requires:` and `[capabilities]` | `tags=["requires:..."]` and `[capabilities]` | `mtest_case_tag("requires:...")` and `[capabilities]` |
+
+A place is a platform (`linux`, `macos`, `windows`) or an architecture (`x86_64`, `aarch64`,
+`riscv64`). The library and the script adapters skip a case outside its places themselves, so a
+program run alone agrees with a runner; the engine skips it too, for a program that only lists them
+(the C ABI). A capability in C++ is answered by the program; the other implementations name the
+word, and the engine answers it from `[capabilities]`.
A case that states no kind is of its program's (its directory's, docs/layout.md).
diff --git a/docs/commands.md b/docs/commands.md
index 2f3d75a..1d3d23c 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -75,7 +75,7 @@ A run is named by its identifier, `last`, or `last-N`.
| `ci annotate`, `ci collect --from DIR` | a run's failures as GitHub annotations and its Markdown report as the job's summary; a CI store taken in |
| `ci report [--group G]` | every collected run of a group (default: `GITHUB_RUN_ID`) as one Markdown report, in the job's summary and `--out` |
| `ci timeline --repo OWNER/REPO --run-id N` | a workflow run's jobs as a schedule with the critical path (each job waited for the job that ended last before it began), as Markdown with a gantt; `--summary` writes it to the job's summary |
-| `ci coverage [--group G]` | every program of mtest.toml judged against the collected runs of a group (default `GITHUB_RUN_ID`; none: every stored run): `ran` on some host, `unaffected` (a narrowed run left it out), `excused` or `owed` (`[ci.coverage]`), else `uncovered`, which fails, as does a listed entry that ran or names no program, and a program that did not pass on a host `[ci.coverage] hosts` requires; the capability words no run's machine provided are listed |
+| `ci coverage [--group G]` | every program of mtest.toml judged against the collected runs of a group (default `GITHUB_RUN_ID`; none: every stored run): `ran` on some host, `unaffected` (a narrowed run left it out), `excused` or `owed` (`[ci.coverage]`), else `uncovered`, which fails, as does a listed entry that ran or names no program, a program that did not pass on a host `[ci.coverage] hosts` requires, and a case that states its places (`only`) and did not pass on one of them that a run covered, or on none; the capability words no run's machine provided are listed |
| `ci plan [--hosts H]... [--format github]` | the selection divided among the hosts (`[ci.plan]`) by each host's history: the cases its capability answers admit, their measured time there, and the shards that fit `[ci.plan] shard`; `github` writes `=[1,...,N]` and `_work=true\|false` to `GITHUB_OUTPUT`, for a job matrix |
| `capabilities` | how this machine answers each word of `[capabilities]`, and why |
| `measure --out FILE -- ARGV` | runs ARGV and writes what it used (its children's account) as JSON: how the engine measures a program that reports no use of its own (P10.4) |
diff --git a/docs/config.md b/docs/config.md
index 53d363e..72460d4 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -105,7 +105,9 @@ max_age = "7d" # an older map is not read: every case i
# case's tag `requires:gcc`). Every test an entry states must hold; an entry that states none holds
# everywhere. A case that requires a word this machine lacks is skipped, with the reason
# `missing capability: ()`; a word no entry declares is an error, for no
-# machine could answer it. `mtest capabilities` prints the answers; each run records them.
+# machine could answer it. `mtest capabilities` prints the answers; each run records them. A word a
+# program answers itself (the C++ library's `capability`, declared in code with these tests) needs
+# no entry: the program lists its answer and the reason of the skip, and the run records it too.
[capabilities]
tag = "requires:" # the tag prefix whose words are capabilities (the default)
gcc = { program = "g++" } # found on PATH (a list: each)
@@ -123,6 +125,8 @@ no-msvc = { os = ["windows"], none = ["msvc"] }
exceptions = "tests/coverage-exceptions.tsv" # \t
debt = "tests/coverage-debt.tsv" # \t: skips everywhere
hosts = "tests/host-requirements.tsv" # \t\t: must pass on each host
+# A case that states its places (`only{platform::windows}`, `--only windows`) must also pass on each
+# of them that a collected run was on, and some run must have been on one: no table names it.
# `mtest ci plan`: a selection divided among CI's hosts by each host's history.
[ci.plan]
diff --git a/engine/mcpp.toml b/engine/mcpp.toml
index 66c0ffe..c080e32 100644
--- a/engine/mcpp.toml
+++ b/engine/mcpp.toml
@@ -10,7 +10,7 @@
[package]
namespace = "mtest"
name = "mtest"
-version = "0.7.2"
+version = "0.8.0"
description = "The mtest engine: run, schedule, store, explain and analyse tests of any language"
license = "Apache-2.0"
authors = ["mcpp-community"]
diff --git a/engine/src/cicheck.cpp b/engine/src/cicheck.cpp
index c864103..83e4f42 100644
--- a/engine/src/cicheck.cpp
+++ b/engine/src/cicheck.cpp
@@ -76,6 +76,65 @@ std::vector words_of(const config& c, const std::vector& only, std::string_view system, std::string_view arch) {
+ bool any_platform = false, any_arch = false, platform_ok = false, arch_ok = false;
+ for (const auto& w : only) {
+ if (is_architecture(w)) {
+ any_arch = true;
+ arch_ok = arch_ok || w == arch;
+ } else {
+ any_platform = true;
+ platform_ok = platform_ok || w == system;
+ }
+ }
+ return (!any_platform || platform_ok) && (!any_arch || arch_ok);
+}
+
+// A case stated to run only on some places must pass on each of them that a run covered, and on
+// at least one: what [ci.coverage] hosts states of a program, stated by the case itself.
+std::vector placed_problems(const std::vector& runs,
+ const std::function& narrowed) {
+ std::map> places; // program::case -> its `only`
+ for (const auto& r : runs)
+ for (const auto& cr : r.cases)
+ if (!cr.only.empty())
+ places.try_emplace(cr.program + "::" + cr.id, cr.only);
+ std::vector out;
+ for (const auto& [key, only] : places) {
+ std::map passed; // " " covered -> passed there
+ for (const auto& r : runs) {
+ if (!covers(only, r.system, r.arch))
+ continue;
+ const auto at = std::format("{} {}", r.system, r.arch);
+ const auto result = std::ranges::find_if(r.cases, [&](const case_result& cr) {
+ return cr.program + "::" + cr.id == key;
+ });
+ if (result == r.cases.end() && narrowed(r))
+ continue; // absent by the run's selection
+ const bool ok =
+ result != r.cases.end() && (result->status == "pass" || result->status == "xfail" ||
+ result->status == "flaky");
+ passed[at] = passed[at] || ok;
+ }
+ std::string words;
+ for (const auto& w : only)
+ words += (words.empty() ? "" : ", ") + w;
+ if (passed.empty())
+ out.push_back(std::format("{} runs only on {} and no run was there", key, words));
+ for (const auto& [at, ok] : passed)
+ if (!ok)
+ out.push_back(
+ std::format("{} runs only on {} and did not pass on {}", key, words, at));
+ }
+ return out;
+}
+
} // namespace
ci_answer ci_coverage(const config& c, const std::vector& programs,
@@ -176,6 +235,8 @@ ci_answer ci_coverage(const config& c, const std::vector& programs
host));
}
}
+ for (auto& pr : placed_problems(runs, [&](const run_record& r) { return narrowed(c, r); }))
+ problems.push_back(std::move(pr));
std::vector unprovided;
for (const auto& [w, _] : c.capabilities.words)
if (!word_somewhere[w])
diff --git a/engine/src/commands.cpp b/engine/src/commands.cpp
index e235403..ea59fcc 100644
--- a/engine/src/commands.cpp
+++ b/engine/src/commands.cpp
@@ -1958,7 +1958,7 @@ json catalog() {
plain({{"out", {{"type", "string"}}},
{"argv", {{"type", "array"}, {"items", {{"type", "string"}}}}}}),
true);
- return {{"schema", "mtest.catalog/1"}, {"version", "0.7.2"}, {"tools", tools}};
+ return {{"schema", "mtest.catalog/1"}, {"version", "0.8.0"}, {"tools", tools}};
}
} // namespace mcpplibs::mtest::engine
diff --git a/engine/src/main.cpp b/engine/src/main.cpp
index 5a029ba..fa222fe 100644
--- a/engine/src/main.cpp
+++ b/engine/src/main.cpp
@@ -98,7 +98,7 @@ const json* tool_named(const json& tools, std::string_view name) {
cmdline::App application(const json& tools) {
auto app = cmdline::App("mtest")
- .version("0.7.2")
+ .version("0.8.0")
.description("run tests of any language, and ask about their runs")
.option(cmdline::Option("json").global().help("print the answer's data"));
for (const auto& t : tools)
@@ -253,7 +253,7 @@ int main(int argc, char** argv) {
const std::string name(parsed->subcommand_name());
const auto& sub = parsed->subcommand()->get();
if (name == "version") {
- std::println("mtest 0.7.2");
+ std::println("mtest 0.8.0");
return 0;
}
const auto cwd = std::filesystem::current_path();
diff --git a/engine/src/model.cpp b/engine/src/model.cpp
index 236bd0e..1ad9ccc 100644
--- a/engine/src/model.cpp
+++ b/engine/src/model.cpp
@@ -58,7 +58,9 @@ json to_json(const case_info& c) {
{"line", c.line}, {"kind", c.kind}, {"tags", c.tags},
{"clauses", c.clauses}, {"resources", c.resources}, {"artifacts", c.artifacts},
{"expect", c.expect}, {"timeout", c.timeout}, {"isolated", c.isolated},
- {"retries", c.retries}, {"uses", c.uses}, {"bounds", to_json(c.bounds)}};
+ {"retries", c.retries}, {"uses", c.uses}, {"bounds", to_json(c.bounds)},
+ {"only", c.only}, {"except", c.except}, {"capabilities", c.capabilities},
+ {"reason", c.reason}};
}
json to_json(const failure_info& f) {
json j = {{"file", f.file}, {"line", f.line}, {"message", f.message}};
@@ -102,6 +104,10 @@ json to_json(const case_result& c) {
j["artifacts"] = c.artifacts;
if (!c.tags.empty())
j["tags"] = c.tags;
+ if (!c.only.empty())
+ j["only"] = c.only;
+ if (!c.except.empty())
+ j["except"] = c.except;
if (!c.clauses.empty())
j["clauses"] = c.clauses;
if (c.quarantined)
@@ -210,6 +216,13 @@ case_info case_info_from(const json& j) {
c.resources = strings(j, "resources");
c.artifacts = strings(j, "artifacts");
c.uses = strings(j, "uses");
+ c.only = strings(j, "only");
+ c.except = strings(j, "except");
+ if (auto a = j.find("capabilities"); a != j.end() && a->is_object())
+ for (auto it = a->begin(); it != a->end(); ++it)
+ if (it.value().is_boolean())
+ c.capabilities[it.key()] = it.value().get();
+ c.reason = str(j, "reason");
return c;
}
case_result case_result_from(const json& j) {
@@ -241,6 +254,8 @@ case_result case_result_from(const json& j) {
c.artifacts = strings(j, "artifacts");
c.tags = strings(j, "tags");
c.clauses = strings(j, "clauses");
+ c.only = strings(j, "only");
+ c.except = strings(j, "except");
c.quarantined = j.value("quarantined", false);
c.attempts = j.value("attempts", 1);
if (auto it = j.find("fixtures"); it != j.end() && it->is_array())
diff --git a/engine/src/model.cppm b/engine/src/model.cppm
index 503e82a..a030309 100644
--- a/engine/src/model.cppm
+++ b/engine/src/model.cppm
@@ -72,6 +72,11 @@ struct case_info {
std::vector
stated_bounds; // mtest.toml's, which the engine judges (the program judges its own)
std::vector uses; // run fixtures (P11)
+ // Where it runs (0.8.0): the platforms and architectures it runs only on, and those it does
+ // not run on; the capabilities its program answered, and why the program lists it skipped.
+ std::vector only, except;
+ std::map capabilities;
+ std::string reason;
};
// A shared object a case caused to be made (P5.2 `fixture`), and how long making it took.
@@ -102,6 +107,7 @@ struct case_result {
std::vector failures;
std::vector metrics;
std::vector artifacts, tags, clauses;
+ std::vector only, except; // where it runs, as listed (0.8.0)
std::vector fixtures;
std::vector bounds; // as listed, for `analyze trend`
bool quarantined = false;
diff --git a/engine/src/schedule.cpp b/engine/src/schedule.cpp
index 72d3b33..678d39d 100644
--- a/engine/src/schedule.cpp
+++ b/engine/src/schedule.cpp
@@ -415,6 +415,11 @@ run_record execute(const config& c, const store& s, const plan& p, const execute
rec.host = host_name();
for (const auto& [word, a] : machine_capabilities(c))
rec.capabilities[word] = a.present;
+ // And each capability a program declares in code and answered when it was listed (0.8.0).
+ for (const auto& u : p.units)
+ for (const auto& uc : u.cases)
+ for (const auto& [word, present] : uc.info.capabilities)
+ rec.capabilities.try_emplace(word, present);
const int hw = int(std::max(1u, std::thread::hardware_concurrency()));
rec.jobs = o.jobs > 0 ? o.jobs : c.jobs > 0 ? c.jobs : hw;
const int retries = o.retries >= 0 ? o.retries : c.retries;
@@ -437,6 +442,9 @@ run_record execute(const config& c, const store& s, const plan& p, const execute
cr.kind = uc.info.kind;
cr.status = "skip";
cr.reason = uc.info.skip;
+ cr.tags = uc.info.tags;
+ cr.only = uc.info.only;
+ cr.except = uc.info.except;
skipped.push_back(std::move(cr));
return true;
};
@@ -691,6 +699,8 @@ run_record execute(const config& c, const store& s, const plan& p, const execute
cr.kind = ci->info.kind;
cr.tags = ci->info.tags;
cr.clauses = ci->info.clauses;
+ cr.only = ci->info.only;
+ cr.except = ci->info.except;
cr.bounds = ci->info.bounds;
judge_bounds(cr, ci->info.stated_bounds);
expected_to_fail(cr, ci->info.expect_fail);
diff --git a/engine/src/select.cpp b/engine/src/select.cpp
index 32cd236..fd311cc 100644
--- a/engine/src/select.cpp
+++ b/engine/src/select.cpp
@@ -327,6 +327,36 @@ void apply_attributes(const config& c, const program_spec& program, case_info& c
ci.kind = program.kind.empty() ? "unit" : program.kind;
}
+// Why a case whose places are `only` and `except` does not run on this machine (0.8.0), in the
+// library's words; empty when it runs here.
+std::string place_skip(const std::vector& only,
+ const std::vector& except) {
+ const std::string system(os::name(os::host()));
+ const std::string arch(os::name(os::ARCH));
+ const auto here = std::format("{} {}", system, arch);
+ const auto architecture = [](std::string_view w) {
+ return w == "x86_64" || w == "aarch64" || w == "riscv64" || w == "wasm32";
+ };
+ bool platforms = false, architectures = false, platform_ok = false, arch_ok = false;
+ std::string words;
+ for (const auto& w : only) {
+ words += (words.empty() ? "" : ", ") + w;
+ if (architecture(w)) {
+ architectures = true;
+ arch_ok = arch_ok || w == arch;
+ } else {
+ platforms = true;
+ platform_ok = platform_ok || w == system;
+ }
+ }
+ if ((platforms && !platform_ok) || (architectures && !arch_ok))
+ return std::format("only on {} (this host: {})", words, here);
+ for (const auto& w : except)
+ if (w == system || w == arch)
+ return std::format("not on {} (this host: {})", w, here);
+ return {};
+}
+
std::string layout_kind(std::string_view path) {
std::string_view previous;
for (const auto part : std::views::split(path, '/')) {
@@ -578,14 +608,22 @@ std::expected make_plan(const config& c, const store& s,
u.expected_peak = pk->second;
for (auto ci : *listed) {
apply_attributes(c, u.program, ci);
+ // A case its program lists skipped, saying why (0.8.0: a place it does not run on, a
+ // capability it answered absent), is recorded so and not started.
+ if (ci.expect == "skip" && ci.skip.empty() && !ci.reason.empty())
+ ci.skip = ci.reason;
+ // A place it does not run on, whether or not its program decided so itself.
+ if (ci.skip.empty())
+ ci.skip = place_skip(ci.only, ci.except);
// A required capability this machine lacks makes the case a skip that says which; one
- // `[capabilities]` does not declare is an error, for no machine could answer it.
+ // that neither the program answers nor `[capabilities]` declares is an error, for no
+ // machine could answer it.
if (!c.capabilities.words.empty()) {
auto tags = ci.tags;
tags.insert(tags.end(), p.tags.begin(), p.tags.end());
for (const auto& t : tags) {
auto word = capability_of_tag(c, t);
- if (!word)
+ if (!word || ci.capabilities.contains(*word))
continue;
if (!c.capabilities.words.contains(*word))
return std::unexpected(
diff --git a/engine/src/service.cpp b/engine/src/service.cpp
index 8e3030d..95f997f 100644
--- a/engine/src/service.cpp
+++ b/engine/src/service.cpp
@@ -56,7 +56,7 @@ int serve(const std::filesystem::path& cwd) {
{"result",
{{"protocolVersion", params.value("protocolVersion", std::string("2025-06-18"))},
{"capabilities", {{"tools", {{"listChanged", false}}}}},
- {"serverInfo", {{"name", "mtest"}, {"version", "0.7.2"}}},
+ {"serverInfo", {{"name", "mtest"}, {"version", "0.8.0"}}},
{"instructions",
"Run tests and ask about runs: run, plan, explain, history, analyze, graph, "
"gate, ci. A test session (test/plan, test/run, test/add, test/cancel, "
diff --git a/engine/tests/engine.cpp b/engine/tests/engine.cpp
index 2055a0e..335219c 100644
--- a/engine/tests/engine.cpp
+++ b/engine/tests/engine.cpp
@@ -807,6 +807,89 @@ printf ')" + header +
expect(eq(caps["capabilities"]["c"]["present"].get(), true));
expect(eq(caps["capabilities"]["d"]["present"].get(), false));
};
+ // --- 0.8.0: where a case runs, stated by the case; capabilities its program answers
+ "a case runs only where its places hold, and the engine says where"_test[kind::e2e] = [] {
+ const std::string head =
+ "#!/usr/bin/env bash\n# mtest-protocol=1\n. '" + adapter() + "'\nok() { :; }\n";
+ const std::string here(mcpplibs::os::name(mcpplibs::os::host()));
+ auto root =
+ project({{"t.sh", head + "mtest_case here ok --only " + here +
+ "\nmtest_case elsewhere ok --except " + here +
+ "\nmtest_case 'on another architecture' ok --only riscv64\n"
+ "mtest_report\n"}});
+ auto j = json_of(mtest(root, {"run", "--json", "--report-json", "r.json", "bash t.sh"}));
+ expect(eq(j["status"].get(), std::string("pass"))) << j.dump();
+ auto r = json::parse(*mcpplibs::os::fs::read(root / "r.json"));
+ std::map by_id;
+ for (const auto& c : r["cases"])
+ by_id[c["id"].get()] = c;
+ expect(eq(by_id["here"]["status"].get(), std::string("pass"))) << r.dump();
+ expect(eq(by_id["here"].value("only", json::array()), json::array({here})))
+ << "the record keeps its places";
+ expect(eq(by_id["elsewhere"]["status"].get(), std::string("skip")));
+ expect(contains(by_id["elsewhere"].value("reason", ""), "not on " + here));
+ if (mcpplibs::os::ARCH != mcpplibs::os::architecture::riscv64)
+ expect(
+ contains(by_id["on another architecture"].value("reason", ""), "only on riscv64"));
+ };
+ "a capability its program answers needs no entry in [capabilities]"_test[kind::e2e] = [&] {
+ // A program that answers `requires:` words itself (the C++ library's `capability`) lists
+ // them under `capabilities`, and the reason of a skip; the engine takes both.
+ const std::string listing =
+ R"js({"e":"program","framework":"fake","level":3}
+{"e":"case","id":"has","name":"has","tags":["requires:mine"],"capabilities":{"mine":true}}
+{"e":"case","id":"lacks","name":"lacks","tags":["requires:theirs"],"capabilities":{"theirs":false},"expect":"skip","reason":"missing capability: theirs (no theirs on PATH)"}
+{"e":"done"}
+)js";
+ const std::string ran =
+ R"({"e":"program","framework":"fake","level":3}
+{"e":"begin","id":"has","t":0}
+{"e":"end","id":"has","status":"pass","ms":1}
+{"e":"done"}
+)";
+ auto root =
+ project({{"listing.ndjson", listing},
+ {"ran.ndjson", ran},
+ {"1_fake.sh", "# mtest-protocol=1\nif [ \"$MTEST_MODE\" = list ]; then cat "
+ "listing.ndjson > \"$MTEST_REPORT\"; "
+ "else cat ran.ndjson > \"$MTEST_REPORT\"; fi\n"},
+ {"mtest.toml",
+ "[discover.s]\nglobs = [\"[0-9]*.sh\"]\ncommand = [\"bash\", \"{path}\"]\n"
+ "[capabilities]\nother = {}\n"}});
+ auto j = json_of(mtest(root, {"run", "--json", "--report-json", "r.json"}));
+ expect(eq(j["status"].get(), std::string("pass"))) << j.dump();
+ auto r = json::parse(*mcpplibs::os::fs::read(root / "r.json"));
+ std::map by_id;
+ for (const auto& c : r["cases"])
+ by_id[c["id"].get()] = c;
+ expect(eq(by_id["has"]["status"].get(), std::string("pass"))) << r.dump();
+ expect(eq(by_id["lacks"]["status"].get(), std::string("skip")));
+ expect(contains(by_id["lacks"].value("reason", ""), "missing capability: theirs"));
+ expect(eq(r["capabilities"].value("mine", false), true))
+ << "the run records the program's answers";
+ expect(eq(r["capabilities"].value("theirs", true), false));
+ };
+ "a case stated for a place must pass there"_test[kind::e2e] = [] {
+ const std::string head = "#!/usr/bin/env bash\n# mtest-protocol=1\n. '" + adapter() + "'\n";
+ const std::string here(mcpplibs::os::name(mcpplibs::os::host()));
+ auto root = project(
+ {{"t.sh", head + "bad() { mtest_eq 1 2; }\nok() { :; }\nmtest_case fails bad --only " +
+ here + "\nmtest_case passes ok --only " + here +
+ "\nmtest_case nowhere ok --only riscv64 --only " + here +
+ "\nmtest_report\n"},
+ {"mtest.toml",
+ "[discover.s]\nglobs = [\"t.sh\"]\ncommand = [\"bash\", \"{path}\"]\n"}});
+ (void)mtest(root, {"run", "--json", "--group", "g8"});
+ auto j = json_of(mtest(root, {"ci", "coverage", "--group", "g8", "--json"}));
+ const auto problems = j["problems"].dump();
+ expect(contains(problems, "t.sh::fails runs only on " + here + " and did not pass on"))
+ << j.dump();
+ expect(!contains(problems, "t.sh::passes")) << j.dump();
+ if (mcpplibs::os::ARCH != mcpplibs::os::architecture::riscv64)
+ expect(contains(problems, "t.sh::nowhere runs only on riscv64, " + here +
+ " and no run was there"))
+ << j.dump();
+ };
"every program ran somewhere or is accounted for"_test[kind::e2e] = [&] {
auto root = project({{"1_ok.sh", "exit 0\n"},
{"2_skips.sh", "echo 'SKIP: nothing here'; exit 77\n"},
diff --git a/lib/docs/writing-tests.md b/lib/docs/writing-tests.md
index 1a5fd80..95df479 100644
--- a/lib/docs/writing-tests.md
+++ b/lib/docs/writing-tests.md
@@ -53,7 +53,7 @@ E2E cli{"cli", resource{"serial:home"}, timeout{std::chrono::minutes{2}}, [] { /
The entry enters the child before any case; the copy runs no case and reports nothing. The files of
one directory are then one program (mcpp `[test] programs = "directory"`) however many of them start
copies.
-- The manifest names the library once: `[dev-dependencies] mcpplibs.mtest = "0.7.2"`.
+- The manifest names the library once: `[dev-dependencies] mcpplibs.mtest = "0.8.0"`.
- Identifiers are stable: a selection, a history and a shard name a case by them.
## Expectations
@@ -73,7 +73,8 @@ skipped, `fail("why")` as failed.
## Attributes
Written after an object's name, before its body, or in brackets after a case's name. A case has
-the attributes of every scope that declares it: tags, clauses, resources and artifacts add up,
+the attributes of every scope that declares it: tags, clauses, resources, artifacts, the places it
+does not run on and the capabilities it requires add up, the places it runs only on narrow,
isolation holds for everything inside; the others are the innermost one stated.
| Attribute | States | The runner |
@@ -90,9 +91,46 @@ isolation holds for everything inside; the others are the innermost one stated.
| `at_most{"ready", 15}`, `at_least{"recall", 0.9}` | the limits of a metric it reports | the case fails when its metric passes a limit or is not reported; `mtest analyze trend` shows each metric beside its limits |
| `skip_if{cond, "why"}`, `expect_failure{"why"}`, `known_gap{"why", "YYYY-MM-DD"}` | its expected verdict | reports skip, xfail, xpass |
| `monitor{monitor_level::detailed}` | how closely its use of the machine is measured | measures at least that closely: its processor time (`basic`), its memory and I/O (`detailed`), both over time (`trace`), as metrics with their precision (protocol P10) |
+| `only{platform::windows}`, `only{platform::linux, architecture::x86_64}`, `except{platform::macos}` | where it runs: on one of the platforms and one of the architectures `only` names, when it names any, and on none `except` names | skips it elsewhere, saying where (`only on windows (this host: linux x86_64)`); `mtest ci coverage` requires it to pass on each named place a run covered |
+| a `capability`, `[gcc]` | what it requires of the machine, and the tag `requires:gcc` | skips it on a machine that lacks it, saying why (`missing capability: gcc (no g++ on PATH)`) |
| `uses{"index"}` | a run fixture it uses | serves the fixture once for the run before the first case that uses it, gives its endpoint (`fixture_endpoint("index")`), ends it after the last |
`Bench` holds `machine`: a measurement means something only on a machine nothing else uses.
+
+### Places and capabilities
+
+The program decides where a case runs, whichever runner starts it (`mtest`, `mcpp test`, the
+program alone), and lists the decision with its reason. Places are the platforms and architectures
+of `mcpplibs.os`. A capability is declared once, in code, by the tests of mtest.toml's
+`[capabilities]` (a system, an architecture, programs on PATH, a file a glob matches, a command
+that succeeds, environment values, other capabilities that must or must not hold) or a function
+for what those cannot state; it is answered once per process. Declared in a module interface, it
+is made before the units that import it, so a suite at namespace scope may state it:
+
+```cpp
+// tests/support/machine.cppm
+export module app.test.machine;
+import mcpplibs.mtest;
+using namespace mcpplibs::mtest;
+export const capability gcc{"gcc", {.os = {platform::linux}, .program = {"g++"}}};
+export const capability pack{"pack", {.all = {gcc}, .program = {"patchelf"}}};
+export const capability msvc{"msvc", {.os = {platform::windows}, .test = has_visual_studio}};
+
+// tests/e2e/linking.cpp
+import app.test.machine;
+
+E2E linking{"linking", [] {
+ "links statically"_test[gcc] = [] { ... };
+ "reads the registry"_test[only{platform::windows}] = [] {
+ on([&](auto) { expect(eq(registry_value(), 1)); });
+ };
+}};
+```
+
+What may be decided when the program is compiled is: `can_run_on(platform)` is a constant, and
+`host()` is one on a native build (an ok image knows it when it is loaded). Code that names what
+exists for one platform only is written in `on([&](auto) { ... })`, whose body a build for
+another platform discards.
How the tests of a project are laid out, from the directory to the line, is in
[docs/layout.md](../../docs/layout.md).
diff --git a/lib/mcpp.toml b/lib/mcpp.toml
index c65c099..82b5659 100644
--- a/lib/mcpp.toml
+++ b/lib/mcpp.toml
@@ -17,7 +17,7 @@
[package]
namespace = "mcpplibs"
name = "mtest"
-version = "0.7.2"
+version = "0.8.0"
description = "The C++ test library of mtest: cases, expectations, fixtures, bench, conformance and end-to-end helpers, with no macro and no header, speaking the mtest protocol"
license = "Apache-2.0"
authors = ["mcpp-community"]
diff --git a/lib/src/mtest.cpp b/lib/src/mtest.cpp
index bc05b0f..fc21cbb 100644
--- a/lib/src/mtest.cpp
+++ b/lib/src/mtest.cpp
@@ -324,7 +324,7 @@ void write_program() {
{"protocol", 1},
{"level", 3},
{"framework", "mcpplibs.mtest"},
- {"version", "0.7.2"},
+ {"version", "0.8.0"},
{"isolation", {"process", "case"}},
{"guide", "https://github.com/mcpp-community/mtest/blob/main/lib/docs/writing-tests.md"}});
}
@@ -673,7 +673,8 @@ void test::operator=(body b) {
std::unique_lock g(s.lock);
write_program();
const std::string id = full_id(name_);
- const attributes attrs = within(inherited(), attrs_);
+ attributes attrs = within(inherited(), attrs_);
+ attrs.skip = skip_reason(attrs);
s.seen.insert(id);
s.clauses.insert(attrs.clauses.begin(), attrs.clauses.end());
const std::string file = b.where.file_name();
@@ -690,6 +691,20 @@ void test::operator=(body b) {
ev["clauses"] = attrs.clauses;
ev["resources"] = attrs.resources;
ev["artifacts"] = attrs.artifacts;
+ if (attrs.only_platforms || attrs.only_architectures) {
+ auto words = attrs.only_platforms.value_or(std::vector{});
+ for (const auto& w : attrs.only_architectures.value_or(std::vector{}))
+ words.push_back(w);
+ ev["only"] = words;
+ }
+ if (!attrs.excepted.empty())
+ ev["except"] = attrs.excepted;
+ if (!attrs.capabilities.empty()) {
+ event answered = event::object();
+ for (const auto& c : attrs.capabilities)
+ answered[c.name()] = c.answer().present;
+ ev["capabilities"] = answered;
+ }
if (attrs.limit)
ev["timeout"] = std::chrono::duration(*attrs.limit).count();
if (!attrs.bounds.empty())
@@ -708,9 +723,10 @@ void test::operator=(body b) {
d["timeout"] = std::chrono::duration(attrs.diagnosis->limit).count();
ev["diagnose"] = d;
}
- if (attrs.skip)
+ if (attrs.skip) {
ev["expect"] = "skip";
- else if (attrs.expected_failure)
+ ev["reason"] = *attrs.skip;
+ } else if (attrs.expected_failure)
ev["expect"] = "fail";
write_event(ev);
return;
@@ -1128,6 +1144,220 @@ void apply(attributes& a, known_gap g) {
a.gap_due = g.due;
}
+// ---- where a case runs, and what the machine can do (0.8.0) ------------------
+
+namespace {
+
+bool holds_word(const std::vector& words, std::string_view w) {
+ return std::ranges::find(words, w) != words.end();
+}
+
+std::string joined(const std::vector& words) {
+ std::string out;
+ for (const auto& w : words)
+ out += (out.empty() ? "" : ", ") + w;
+ return out;
+}
+
+std::string this_host() {
+ return std::format("{} {}", os::name(os::host()), os::name(os::ARCH));
+}
+
+// `${NAME}` replaced by the environment's value (empty when unset).
+std::string expand(std::string_view text) {
+ std::string out;
+ for (std::size_t i = 0; i < text.size(); ++i) {
+ if (text[i] == '$' && i + 1 < text.size() && text[i + 1] == '{') {
+ const auto end = text.find('}', i + 2);
+ if (end != std::string_view::npos) {
+ out += os::env::get(text.substr(i + 2, end - i - 2)).value_or("");
+ i = end;
+ continue;
+ }
+ }
+ out += text[i];
+ }
+ return out;
+}
+
+// Whether `name` matches `pattern`, where `*` is any run of characters and `?` any one.
+bool glob_match(std::string_view pattern, std::string_view name) {
+ if (pattern.empty())
+ return name.empty();
+ if (pattern.front() == '*')
+ for (std::size_t skip = 0; skip <= name.size(); ++skip)
+ if (glob_match(pattern.substr(1), name.substr(skip)))
+ return true;
+ if (pattern.front() == '*')
+ return false;
+ return !name.empty() && (pattern.front() == '?' || pattern.front() == name.front()) &&
+ glob_match(pattern.substr(1), name.substr(1));
+}
+
+// Whether some file matches `pattern`: a path whose components may hold `*` and `?`.
+bool some_file(const std::string& pattern) {
+ const auto p = os::fs::from_utf8(pattern);
+ std::vector at{p.root_path()};
+ for (const auto& part : p.relative_path()) {
+ const auto word = os::fs::utf8(part);
+ std::vector next;
+ for (const auto& dir : at) {
+ std::error_code ec;
+ if (!word.contains('*') && !word.contains('?')) {
+ if (std::filesystem::exists(dir / part, ec))
+ next.push_back(dir / part);
+ continue;
+ }
+ for (const auto& e : std::filesystem::directory_iterator(dir, ec))
+ if (glob_match(word, os::fs::utf8(e.path().filename())))
+ next.push_back(e.path());
+ }
+ if (next.empty())
+ return false;
+ at = std::move(next);
+ }
+ return !at.empty();
+}
+
+capability_answer evaluate(const probe& t) {
+ const std::string system(os::name(os::host()));
+ const std::string arch(os::name(os::ARCH));
+ if (!t.os.empty() && std::ranges::find(t.os, os::host()) == t.os.end())
+ return {false, std::format("the system is {}", system)};
+ if (!t.arch.empty() && std::ranges::find(t.arch, os::ARCH) == t.arch.end())
+ return {false, std::format("the architecture is {}", arch)};
+ for (const auto& [k, want] : t.env) {
+ const auto have = os::env::get(k);
+ if (want == "*" ? (!have || have->empty()) : have != want)
+ return {false, std::format("{} is {}", k, have ? std::format("`{}`", *have) : "unset")};
+ }
+ for (const auto& c : t.all)
+ if (const auto& a = c.answer(); !a.present)
+ return {false, std::format("`{}` is absent ({})", c.name(), a.why)};
+ for (const auto& c : t.none)
+ if (c.answer().present)
+ return {false, std::format("`{}` is present", c.name())};
+ for (const auto& program : t.program)
+ if (!os::env::find_executable(expand(program)))
+ return {false, std::format("no {} on PATH", program)};
+ if (!t.path.empty() &&
+ std::ranges::none_of(t.path, [](const auto& g) { return some_file(expand(g)); }))
+ return {false, std::format("no file matches {}", expand(t.path.front()))};
+ if (!t.command.empty()) {
+ os::process::spawn_options o;
+ o.program = os::fs::from_utf8(expand(t.command.front()));
+ for (std::size_t i = 1; i < t.command.size(); ++i)
+ o.args.push_back(expand(t.command[i]));
+ o.in = os::process::stdio::null;
+ o.out = os::process::stdio::null;
+ o.err = os::process::stdio::null;
+ os::process::bounds limits;
+ limits.hard = std::chrono::seconds{30};
+ auto r = os::process::run(std::move(o), limits);
+ if (!r)
+ return {false, std::format("{} did not start", t.command.front())};
+ if (r->timed_out || !r->status.success())
+ return {false,
+ std::format("{} ended with {}", t.command.front(),
+ r->timed_out ? std::string("a timeout")
+ : std::format("status {}", r->status.shell_code()))};
+ }
+ if (t.test && !t.test())
+ return {false, "its test does not hold"};
+ return {true, "every test holds"};
+}
+
+// Only the words both name, when both name some.
+void narrow(std::optional>& to, const std::vector& words) {
+ if (!to)
+ to = words;
+ else
+ std::erase_if(*to, [&](const std::string& w) { return !holds_word(words, w); });
+}
+
+void restrict_to(attributes& a, const std::vector& places) {
+ std::vector platforms, architectures;
+ for (const auto& p : places)
+ (p.architecture_word ? architectures : platforms).push_back(p.word);
+ if (!platforms.empty())
+ narrow(a.only_platforms, platforms);
+ if (!architectures.empty())
+ narrow(a.only_architectures, architectures);
+}
+
+void add_capability(attributes& a, const capability& c) {
+ const auto tag = "requires:" + c.name();
+ if (!holds_word(a.tags, tag))
+ a.tags.push_back(tag);
+ if (std::ranges::none_of(a.capabilities,
+ [&](const capability& x) { return x.name() == c.name(); }))
+ a.capabilities.push_back(c);
+}
+
+} // namespace
+
+struct capability::state {
+ std::string name;
+ probe tests;
+ std::once_flag once;
+ capability_answer answer;
+};
+
+capability::capability(std::string name, probe tests) : state_(std::make_shared()) {
+ state_->name = std::move(name);
+ state_->tests = std::move(tests);
+}
+
+const std::string& capability::name() const noexcept {
+ return state_->name;
+}
+
+const capability_answer& capability::answer() const {
+ std::call_once(state_->once, [this] { state_->answer = evaluate(state_->tests); });
+ return state_->answer;
+}
+
+void apply(attributes& a, only o) {
+ restrict_to(a, o.places);
+}
+
+void apply(attributes& a, except e) {
+ for (const auto& p : e.places)
+ if (!holds_word(a.excepted, p.word))
+ a.excepted.push_back(p.word);
+}
+
+void apply(attributes& a, capability c) {
+ add_capability(a, c);
+}
+
+std::optional skip_reason(const attributes& a) {
+ if (a.skip)
+ return a.skip;
+ const std::string system(os::name(os::host()));
+ const std::string arch(os::name(os::ARCH));
+ const auto outside = [](const auto& only, const std::string& w) {
+ return only && !holds_word(*only, w);
+ };
+ if (outside(a.only_platforms, system) || outside(a.only_architectures, arch)) {
+ std::vector words = a.only_platforms.value_or(std::vector{});
+ for (const auto& w : a.only_architectures.value_or(std::vector{}))
+ words.push_back(w);
+ return std::format("only on {} (this host: {})",
+ words.empty()
+ ? std::string("no place: its `only` statements exclude one another")
+ : joined(words),
+ this_host());
+ }
+ for (const auto& w : a.excepted)
+ if (w == system || w == arch)
+ return std::format("not on {} (this host: {})", w, this_host());
+ for (const auto& c : a.capabilities)
+ if (const auto& answer = c.answer(); !answer.present)
+ return std::format("missing capability: {} ({})", c.name(), answer.why);
+ return std::nullopt;
+}
+
attributes within(const attributes& outer, const attributes& inner) {
attributes a = outer;
auto add = [](std::vector& to, const std::vector& from) {
@@ -1159,6 +1389,13 @@ attributes within(const attributes& outer, const attributes& inner) {
a.retries = inner.retries;
if (inner.diagnosis)
a.diagnosis = inner.diagnosis;
+ if (inner.only_platforms)
+ narrow(a.only_platforms, *inner.only_platforms);
+ if (inner.only_architectures)
+ narrow(a.only_architectures, *inner.only_architectures);
+ add(a.excepted, inner.excepted);
+ for (const auto& c : inner.capabilities)
+ add_capability(a, c);
return a;
}
diff --git a/lib/src/mtest.cppm b/lib/src/mtest.cppm
index 6a5cc13..bc9112d 100644
--- a/lib/src/mtest.cppm
+++ b/lib/src/mtest.cppm
@@ -8,6 +8,7 @@
export module mcpplibs.mtest;
import std;
+import mcpplibs.os;
export namespace mcpplibs::mtest {
@@ -35,6 +36,75 @@ struct metric_bound {
std::optional max, min;
};
+// ---- where a case runs ------------------------------------------------------
+
+// The platforms and architectures of mcpplibs.os, and its two questions (0.8.0): whether this build
+// can run on a platform, a constant, and the platform it runs on now, `host()`, a constant on a
+// native build and known when an ok image is loaded. Code that compiles only for one platform is
+// written in `on([&](auto) { ... })`, whose body a build for another platform discards:
+//
+// "reads the registry"_test[only{platform::windows}] = [] {
+// on([&](auto) { expect(eq(registry_value(), 1)); });
+// };
+using platform = mcpplibs::os::platform;
+using architecture = mcpplibs::os::architecture;
+using mcpplibs::os::can_run_on;
+using mcpplibs::os::host;
+using mcpplibs::os::on;
+
+// A platform or an architecture, by mcpplibs.os's name for it ("windows", "aarch64").
+struct place {
+ std::string word;
+ bool architecture_word = false;
+ place(platform p) : word(mcpplibs::os::name(p)) {}
+ place(architecture a) : word(mcpplibs::os::name(a)), architecture_word(true) {}
+};
+
+// WHAT THIS MACHINE CAN DO (0.8.0), declared once in code beside the tests that need it, by the
+// tests of mtest.toml's [capabilities] (docs/config.md), and answered by the program itself, once
+// per process, whichever runner starts it:
+//
+// export const capability gcc{"gcc", {.os = {platform::linux}, .program = {"g++"}}};
+// export const capability pack{"pack", {.all = {gcc}, .program = {"patchelf"}}};
+// "links statically"_test[gcc] = [] { ... };
+//
+// A case that requires a capability this machine lacks is skipped, with the reason
+// `missing capability: ()`; it carries the tag `requires:`. A
+// capability declared in a module interface is made before the units that import it, so suites at
+// namespace scope may state it.
+class capability;
+
+struct probe {
+ std::vector os; // the system is one of them
+ std::vector arch; // the architecture is one of them
+ std::vector program; // each is found on PATH
+ std::vector path; // some file matches one (`*`, `?`, `${NAME}`)
+ std::vector command; // runs and exits 0 within 30 s
+ std::vector> env; // equals; "*": set and not empty
+ std::vector all; // each is present
+ std::vector none; // none is present
+ std::function test; // what the words above cannot state
+};
+
+struct capability_answer {
+ bool present = false;
+ std::string why; // the test that decided it, in a person's words
+};
+
+class capability {
+public:
+ capability(std::string name, probe tests);
+ const std::string& name() const noexcept;
+ // Asked once per process; every copy shares the answer.
+ const capability_answer& answer() const;
+ explicit operator bool() const {
+ return answer().present;
+ }
+private:
+ struct state;
+ std::shared_ptr state_;
+};
+
struct attributes {
std::optional kind;
std::vector tags;
@@ -52,6 +122,11 @@ struct attributes {
std::vector bounds; // what its metrics may be (at_most, at_least)
std::optional monitoring; // how closely its use is measured (monitor)
std::vector uses; // the run fixtures it uses (P11)
+ // Where it runs (only, except): the platforms and the architectures it is restricted to, none
+ // stated meaning every one, and the words of either on which it does not run.
+ std::optional> only_platforms, only_architectures;
+ std::vector excepted;
+ std::vector capabilities; // what it requires of the machine
};
// Each is written in brackets after the case's name, "x"_test[tag{"slow"}][resource{"machine"}],
@@ -115,9 +190,24 @@ struct uses {
std::string fixture;
};
-// Each attribute applied to a set of them. Tags, clauses, resources, artifacts and bounds add up,
-// and a case within an isolated suite is isolated; a kind, a skip, an expected failure, a known
-// gap, a time limit, retries and a diagnosis are the innermost one stated.
+// The case runs only on the platforms and architectures named: on one of the platforms, when any is
+// named, and on one of the architectures, when any is. Stated on a suite and on its case, both
+// hold. Elsewhere it is skipped: `only on windows (this host: linux x86_64)`.
+struct only {
+ std::vector places;
+ only(std::initializer_list p) : places(p) {}
+};
+// The case does not run on any platform or architecture named: `not on macos (this host: ...)`.
+struct except {
+ std::vector places;
+ except(std::initializer_list p) : places(p) {}
+};
+
+// Each attribute applied to a set of them. Tags, clauses, resources, artifacts, bounds, the
+// platforms not run on and the capabilities required add up, the platforms and architectures a
+// case runs only on narrow, and a case within an isolated suite is isolated; a kind, a skip, an
+// expected failure, a known gap, a time limit, retries and a diagnosis are the innermost one
+// stated.
inline void apply(attributes& a, mtest::kind k) {
a.kind = k;
}
@@ -165,6 +255,12 @@ inline void apply(attributes& a, uses u) {
a.uses.push_back(std::move(u.fixture));
}
void apply(attributes& a, known_gap g);
+void apply(attributes& a, only o);
+void apply(attributes& a, except e);
+void apply(attributes& a, capability c);
+// Why a case with these attributes does not run on this machine: its own skip, else a platform or
+// an architecture it does not run on, else a capability the machine lacks.
+std::optional skip_reason(const attributes& a);
template
concept attribute = requires(attributes& set, A a) { apply(set, std::move(a)); };
diff --git a/lib/tests/places.cpp b/lib/tests/places.cpp
new file mode 100644
index 0000000..fb74aa9
--- /dev/null
+++ b/lib/tests/places.cpp
@@ -0,0 +1,164 @@
+// Where a case runs and what the machine can do (0.8.0): `only` and `except` skip a case elsewhere
+// with one reason, a suite's and its case's narrow together, and a capability declared in code is
+// answered by the program, once, for whichever runner lists and runs it. The program runs itself
+// again under a runner with the cases declared, and reads the events.
+import std;
+import nlohmann.json;
+import mcpplibs.mtest;
+import mcpplibs.mtest.e2e;
+import mcpplibs.os;
+
+using namespace mcpplibs::mtest;
+using json = nlohmann::json;
+
+namespace {
+
+// What a machine has or lacks, declared by the vocabulary of [capabilities].
+const capability always{"always", {}};
+const capability never{"never", {.env = {{"MTEST_PLACES_NEVER_SET", "*"}}}};
+const capability on_top_of_never{"on-top-of-never", {.all = {never}}};
+const capability without_never{"without-never", {.none = {never}}};
+const capability refused{"refused", {.test = [] { return false; }}};
+
+// The questions of mcpplibs.os, answered at compile time where the build has one platform.
+static_assert(can_run_on(platform::linux) || can_run_on(platform::macos) ||
+ can_run_on(platform::windows) || can_run_on(platform::emscripten));
+
+const std::string this_host =
+ std::format("{} {}", mcpplibs::os::name(host()), mcpplibs::os::name(mcpplibs::os::ARCH));
+constexpr architecture other_architecture =
+ mcpplibs::os::ARCH == architecture::x86_64 ? architecture::aarch64 : architecture::x86_64;
+
+// What the copy started with --declared declares.
+void declare_cases() {
+ "here"_test[only{host()}] = [] {};
+ "elsewhere"_test[except{host()}] = [] {
+ expect(false) << "never runs on its excepted platform";
+ };
+ "on another architecture"_test[only{other_architecture}] = [] { expect(false); };
+ "on this architecture, any platform"_test[only{mcpplibs::os::ARCH}] = [] {};
+ Suite narrowed{"narrowed",
+ only{platform::linux, platform::macos, platform::windows, platform::openkal},
+ [] {
+ "to this host"_test[only{host()}] = [] {};
+ "to a platform its suite excludes"_test[only{platform::emscripten}] = [] {
+ expect(false);
+ };
+ }};
+ (void)narrowed;
+ "with always"_test[always] = [] {};
+ "with never"_test[never] = [] { expect(false) << "never runs where `never` is absent"; };
+ "on top of never"_test[on_top_of_never] = [] { expect(false); };
+ "without never"_test[without_never] = [] {};
+ "refused by its test"_test[refused] = [] { expect(false); };
+}
+
+std::vector events_of(std::string_view mode) {
+ const auto report = temp_dir() / std::format("{}.ndjson", mode);
+ auto argv = self_command();
+ argv.push_back("--declared");
+ e2e::command c{.argv = argv};
+ c.env = {{"MTEST_PROTOCOL", "1"},
+ {"MTEST_MODE", std::string(mode)},
+ {"MTEST_REPORT", report.string()},
+ {"MTEST_PLACES_NEVER_SET", std::nullopt}};
+ auto r = e2e::run(std::move(c));
+ expect(fatal(r.started));
+ std::vector out;
+ std::ifstream in(report);
+ for (std::string line; std::getline(in, line);)
+ if (!line.empty())
+ out.push_back(json::parse(line));
+ return out;
+}
+
+std::optional event(const std::vector& events, std::string_view kind,
+ std::string_view id) {
+ for (const auto& e : events)
+ if (e.value("e", "") == kind && e.value("id", "") == id)
+ return e;
+ return std::nullopt;
+}
+
+std::string status(const std::vector& events, std::string_view id) {
+ auto e = event(events, "end", id);
+ return e ? e->value("status", "") : "(no end)";
+}
+
+std::string reason(const std::vector& events, std::string_view id) {
+ auto e = event(events, "end", id);
+ return e ? e->value("reason", "") : "(no end)";
+}
+
+} // namespace
+
+int main(int argc, char** argv) {
+ if (argc > 1 && std::string_view(argv[1]) == "--declared") {
+ declare_cases();
+ return run(argc, argv);
+ }
+ "a case runs where its places hold and is skipped elsewhere, saying where"_test = [] {
+ const auto events = events_of("run");
+ expect(eq(status(events, "here"), std::string("pass")));
+ expect(eq(status(events, "on this architecture, any platform"), std::string("pass")));
+ expect(eq(status(events, "elsewhere"), std::string("skip")));
+ expect(eq(reason(events, "elsewhere"),
+ std::format("not on {} (this host: {})", mcpplibs::os::name(host()), this_host)));
+ expect(eq(reason(events, "on another architecture"),
+ std::format("only on {} (this host: {})", mcpplibs::os::name(other_architecture),
+ this_host)));
+ };
+ "a suite's places and its case's narrow together"_test = [] {
+ const auto events = events_of("run");
+ expect(eq(status(events, "narrowed/to this host"), std::string("pass")));
+ expect(
+ eq(status(events, "narrowed/to a platform its suite excludes"), std::string("skip")));
+ expect(contains(reason(events, "narrowed/to a platform its suite excludes"),
+ "its `only` statements exclude one another"));
+ };
+ "a capability is answered by its tests, and one absent skips naming the test"_test = [] {
+ const auto events = events_of("run");
+ expect(eq(status(events, "with always"), std::string("pass")));
+ expect(eq(status(events, "without never"), std::string("pass")));
+ expect(eq(reason(events, "with never"),
+ std::string("missing capability: never (MTEST_PLACES_NEVER_SET is unset)")));
+ expect(eq(reason(events, "on top of never"),
+ std::string("missing capability: on-top-of-never (`never` is absent "
+ "(MTEST_PLACES_NEVER_SET is unset))")));
+ expect(eq(reason(events, "refused by its test"),
+ std::string("missing capability: refused (its test does not hold)")));
+ };
+ "a listing states the places, the answers and the reason of a skip"_test = [] {
+ const auto events = events_of("list");
+ auto here = event(events, "case", "here");
+ expect(fatal(here.has_value()));
+ expect(eq(here->value("only", json::array()), json::array({mcpplibs::os::name(host())})));
+ expect(!here->contains("expect"));
+ auto elsewhere = event(events, "case", "elsewhere");
+ expect(fatal(elsewhere.has_value()));
+ expect(eq(elsewhere->value("except", json::array()),
+ json::array({mcpplibs::os::name(host())})));
+ expect(eq(elsewhere->value("expect", ""), std::string("skip")));
+ expect(contains(elsewhere->value("reason", ""), "not on"));
+ auto never_case = event(events, "case", "with never");
+ expect(fatal(never_case.has_value()));
+ expect(eq(never_case->value("capabilities", json::object()), json{{"never", false}}));
+ expect(std::ranges::any_of(never_case->value("tags", json::array()), [](const json& t) {
+ return t.get() == "requires:never";
+ })) << "a required capability is also its tag";
+ auto always_case = event(events, "case", "with always");
+ expect(fatal(always_case.has_value()));
+ expect(eq(always_case->value("capabilities", json::object()), json{{"always", true}}));
+ };
+ "code for one platform runs only there"_test = [] {
+ int ran = 0;
+ on([&](auto) { ran += host() == platform::linux; });
+ on([&](auto) { ran += host() == platform::macos; });
+ on([&](auto) { ran += host() == platform::windows; });
+ expect(eq(ran, host() == platform::linux || host() == platform::macos ||
+ host() == platform::windows
+ ? 1
+ : 0));
+ };
+ return report();
+}
diff --git a/manifest/mcpp.toml b/manifest/mcpp.toml
index 9f07ce9..2a6381c 100644
--- a/manifest/mcpp.toml
+++ b/manifest/mcpp.toml
@@ -8,7 +8,7 @@
[package]
namespace = "mcpplibs.mtest"
name = "manifest"
-version = "0.7.2"
+version = "0.8.0"
description = "The static case manifest of mtest: the cases a source file declares, read without building it"
license = "Apache-2.0"
authors = ["mcpp-community"]
diff --git a/spec/protocol.md b/spec/protocol.md
index 2655b27..6ebef39 100644
--- a/spec/protocol.md
+++ b/spec/protocol.md
@@ -21,6 +21,7 @@ on the spec; this list says which of its clauses mtest's parts answer, and since
| O5 `scheduling` | `resources` and `artifacts` (P7); `timeout`, `isolation`, `retries`, `diagnose` (P3.3) in the `case` events | 0.1.0; P3.3's 0.3.0 |
| O6 `fixtures` | `shared` and the `fixture` event; components and run fixtures (P11), served under `MTEST_SERVE` | 0.3.0; P11 0.5.0 |
| O7 `usage` | the program's `usage` at its end, and the cases' use at `MTEST_MONITOR` (P10) | 0.5.0 |
+| P3.3, places and capabilities | a `case` event states `only` and `except` (the platforms and architectures of `only{}`, `except{}`), `capabilities` (each `capability` the case requires, as the program answered it), and the `reason` of a case it lists skipped; the program skips a case outside its places or lacking a capability, whichever runner starts it | 0.8.0 |
| O8 `output` | each case's output, kept while it runs under a runner and stated as `output` events; a case declared inside another keeps its own. Where the system cannot rearrange the program's streams (an ok image), no output is kept for a case, the spec's fallback | 0.6.0 |
## The engine (`mtest`)
@@ -32,6 +33,7 @@ on the spec; this list says which of its clauses mtest's parts answer, and since
| O5 | schedules cases by their resources and history, by pools since 0.5.0; ends a case at its `timeout`, runs `retries` and `diagnose` since 0.3.0 | 0.1.0 |
| O6 | serves run fixtures once per run (P11.2, P11.3) | 0.5.0 |
| O7 | measures a program that reports no use from a process of its own (P10.4) | 0.5.0 |
+| P3.3, places | skips a listed case outside its `only` and `except`, and one its program lists skipped with a `reason`, without starting it; takes the program's answers for the capabilities it names (no `[capabilities]` entry needed) and records them in the run; `mtest ci coverage` requires a case to pass on each of its places that a collected run covered | 0.8.0 |
| O9 `session` | `mtest serve` answers `test/plan`, `test/run`, `test/add`, `test/cancel`, `test/status` and `shutdown`, and states `test/build`, `test/case`, `test/program`, `test/run`; it builds nothing, and starts each program from the launch mcpp gives (B5) | 0.6.0 |
## The adapters and the C ABI
diff --git a/spec/schema/event-v1.json b/spec/schema/event-v1.json
index 90708b1..6ead5df 100644
--- a/spec/schema/event-v1.json
+++ b/spec/schema/event-v1.json
@@ -168,6 +168,31 @@
"type": "string"
},
"description": "P11.2: the run fixtures the case uses"
+ },
+ "reason": {
+ "type": "string",
+ "description": "why the program lists the case skipped (expect skip)"
+ },
+ "only": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "0.8.0: the platforms and architectures the case runs only on"
+ },
+ "except": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "0.8.0: the platforms and architectures the case does not run on"
+ },
+ "capabilities": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "boolean"
+ },
+ "description": "0.8.0: the capabilities the case requires, as its program answered them"
}
},
"required": [