test(managed-agent): Close the deferred Hosted no-tool gate follow-ups (#12864)

The SDK Java integration tests are split by name: every Hosted*IT runs
only in the MySQL job, every other *IT only in the MariaDB job. The
workflow guard checked that split as text, so a profile <includes>, or
-Dit.test passed through MAVEN_ARGS or .mvn/maven.config, could narrow
either job while Maven, the job and the guard all stayed green. After
its tests, each job now runs scripts/check-failsafe-reports.js, which
compares the failsafe reports with the source tree. Every *IT class of
the job's family must have run a test case, and no other test class may
have run. A report that records -Dit.test, -Dfailsafe.includesFile or
-Dfailsafe.excludesFile, or records no properties at all, fails the
check too, so a method filter that keeps one test of every class is
caught as well. The MySQL job now cleans before it builds, and changes
to the script or to any .mvn directory trigger the workflow.

The Windows lane ran only the two portable startup cases. It now runs
every Hosted case after its unit tests, with a 10-minute budget, and
fails unless every collected case passed. The macOS lane keeps the
portable subset, since its E2E legs run the whole file.

The Hosted helper and the daemon harness each had their own
listening-banner match and SIGTERM-then-SIGKILL teardown, with a 3 s
grace in one and 5 s in the other. Both now use
integration-tests/helpers/daemon-process.ts, which imports only Node
built-ins. Teardown waits for the child to exit and returns at once for
a child that has already exited, including one killed by a signal; the
harness used to wait out the full grace for such a child. A new Hosted
case fails if the caller's HOME or QWEN_HOME, or its whole environment,
reaches the child.

The route-probe comment now states its invariant. The bilingual design
doc corrects when the temp-root sweep runs, and records the lane
changes, the Java check and its limits, and this change's evidence. The
workflow guard pins the new steps. The sdk-java.yml size baseline
records the file's new size, since main had already grown it 3,485
bytes past the recorded one.

Refs #12823
This commit is contained in:
Shaojin Wen authored and GitHub committed 2026-09-28 00:40:50 +00:00
1 parent 3f5ae3ffeb
commit 0a136f89d7
12 files changed
+522 -94

No files matched your search

+1 -1
View File
@@ -55,7 +55,7 @@
35955 release.yml
44285 repo-hygiene.yml
1079 scorecard-monthly.yml
16542 sdk-java.yml
20678 sdk-java.yml
3886 sdk-python.yml
5595 security-checks.yml
6777 serve-ab-publish.yml
+11 -6
View File
@@ -1839,12 +1839,6 @@ jobs:
run: |-
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Hosted portable process smoke'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
timeout-minutes: 5
run: |-
npm run test:integration:hosted:sandbox:none -- -t 'portable startup'
- name: 'Run tests and generate reports'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
env:
@@ -1891,6 +1885,17 @@ jobs:
if [ "$RC" -ne 0 ]; then df -hT 2>/dev/null || df -h 2>/dev/null || true; df -i 2>/dev/null || true; grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo 2>/dev/null || true; fi
exit "$RC"
# Unlike macOS, whose E2E legs run this file in full, no other Windows
# lane runs the Hosted cases, so this one runs all of them and fails if
# any is skipped. It runs after the unit tests so that a Hosted failure
# cannot hide their results.
- name: 'Hosted process suite'
if: "${{ !cancelled() && needs.classify_pr.outputs.skip_ci != 'true' }}"
timeout-minutes: 10
run: |-
npm run test:integration:hosted:sandbox:none -- --reporter=default --reporter=json --outputFile.json=hosted-process-suite.json
node -e "const r = require('./integration-tests/hosted-process-suite.json'); if (r.numTotalTests === 0 || r.numPassedTests !== r.numTotalTests) { console.error('::error::' + r.numPassedTests + ' of ' + r.numTotalTests + ' Hosted cases passed; Windows must run every case'); process.exit(1); }"
# Integration tests run only in the merge queue, not on every PR push.
# They are the suite that previously ran *only* in the nightly Release
# pipeline (`release.yml`), so regressions stayed hidden until release
+12 -1
View File
@@ -7,6 +7,7 @@ on:
- 'release/**'
paths:
- 'packages/sdk-java/**'
- '**/.mvn/**'
- 'packages/core/src/config/approval-modes.json'
- 'packages/core/src/managed-runtime/**'
- 'packages/cli/src/config/**'
@@ -22,6 +23,7 @@ on:
- 'docs/developers/sdk-java.md'
- 'docs/design/java-daemon-sdk-alpha.md'
- 'scripts/run-java-daemon-sdk-e2e.ts'
- 'scripts/check-failsafe-reports.js'
- '.github/workflows/sdk-java.yml'
- '.github/workflows/release-sdk-java.yml'
push:
@@ -30,6 +32,7 @@ on:
- 'release/**'
paths:
- 'packages/sdk-java/**'
- '**/.mvn/**'
- 'packages/core/src/config/approval-modes.json'
- 'packages/core/src/managed-runtime/**'
- 'packages/cli/src/config/**'
@@ -45,6 +48,7 @@ on:
- 'docs/developers/sdk-java.md'
- 'docs/design/java-daemon-sdk-alpha.md'
- 'scripts/run-java-daemon-sdk-e2e.ts'
- 'scripts/check-failsafe-reports.js'
- '.github/workflows/sdk-java.yml'
- '.github/workflows/release-sdk-java.yml'
@@ -250,6 +254,11 @@ jobs:
-Dmysql.url='jdbc:mysql://127.0.0.1:3306/managed_agent_test?createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true&useSSL=false'
-Dmysql.user=root -Dmysql.password=runtime-broker clean verify checkstyle:check
# Maven passes a selection narrowed by a profile, MAVEN_ARGS or
# .mvn/maven.config, so compare what ran with the source tree.
- name: 'Check that every non-Hosted integration test class ran'
run: 'node scripts/check-failsafe-reports.js non-hosted packages/sdk-java/runtime-broker packages/sdk-java/managed-agent-server'
hosted-harness-mysql:
name: 'Hosted no-tool processes / MySQL 8.4 / Java 21'
runs-on: 'ubuntu-latest'
@@ -305,7 +314,9 @@ jobs:
-Dnode.executable="$(command -v node)"
-Dqwen.cli.entry="${GITHUB_WORKSPACE}/dist/cli.js"
-Dmysql.url="jdbc:mysql://127.0.0.1:${MYSQL_PORT}/hosted_harness_test?allowPublicKeyRetrieval=true&useSSL=false"
-Dmysql.user=root -Dmysql.password=hosted-fixture verify checkstyle:check
-Dmysql.user=root -Dmysql.password=hosted-fixture clean verify checkstyle:check
- name: 'Check that every Hosted integration test class ran'
run: 'node scripts/check-failsafe-reports.js hosted packages/sdk-java/managed-agent-server'
- name: 'Run Runtime Broker fault gates'
timeout-minutes: 10
env:
@@ -2,7 +2,7 @@
[English](2026-09-26-hosted-no-tool-process-gate.md) | [简体中文](2026-09-26-hosted-no-tool-process-gate.zh-CN.md)
Status: implemented for #12728 by #12733 and its review follow-up.
Status: implemented for #12728 by #12733 and its review follow-up. The items that follow-up deferred are implemented under #12823; the first self-hosted Windows run of the whole suite is still to be recorded.
## Problem and baseline
@@ -14,17 +14,17 @@ Add a focused integration suite that spawns `node dist/cli.js`, uses the existin
## Isolation and failure handling
Each case owns a temporary home, workspace and Store directory, loopback listeners on ephemeral ports, and a bounded child process. Temporary roots use the `qwen-e2e-home-` prefix, so the integration sweeper can reclaim any that a torn-down worker leaves behind. The sweep runs only in integration runs that own their scratch home and use the default temp directory. The required no-AK lane pins `QWEN_HOME`, the focused configuration has no global setup, and the E2E legs use a private temp directory, so none of them reclaims these roots. Use an allowlisted process environment and local fake credentials. Always terminate children, abort SSE readers, close fixture listeners and remove temporary state, including startup timeout and assertion failure. Retain bounded diagnostics on failure, and give teardown the same explicit hook budget in every configuration. Missing build prerequisites fail explicitly; this gate never skips for absent credentials.
Each case owns a temporary home, workspace and Store directory, loopback listeners on ephemeral ports, and a bounded child process. Temporary roots use the `qwen-e2e-home-` prefix, so the integration sweeper can reclaim any that a torn-down worker leaves behind. The sweep runs in every integration run that owns its scratch home, that is, whenever `QWEN_HOME` is unset, and scans that run's temp directory. The required no-AK lane pins `QWEN_HOME` and the focused configuration has no global setup, so neither sweeps. The Linux E2E legs that run through `run-e2e-tests.sh` sweep only the private temp directory it creates for each run, which holds no earlier roots, while the `cli` path-filter lane and the macOS E2E legs use the default temp directory and do reclaim them. Use an allowlisted process environment and local fake credentials; a case fails if the caller's `HOME` or `QWEN_HOME` reaches the child, or if the caller's environment is passed through whole. The `qwen serve` listening-banner match and teardown (SIGTERM, then SIGKILL after 5 s) live in one module shared with the daemon test harness, and that module imports neither the SDK nor core. Always terminate children, abort SSE readers, close fixture listeners and remove temporary state, including startup timeout and assertion failure. Retain bounded diagnostics on failure, and give teardown the same explicit hook budget in every configuration. Missing build prerequisites fail explicitly; this gate never skips for absent credentials.
## CI and database slice
Add a reproducible focused npm entry point and include it in the existing required no-AK PR gate, with build/bundle prerequisites and workflow guards. Add startup/bind/cleanup coverage to the existing macOS/Windows lanes. Separately exercise the real Java HostedHarnessClient against the packaged CLI and Spring private Store using isolated real MySQL; assert the engine/version so MariaDB or H2 cannot substitute. Keep Java/database evidence separate from fixture evidence.
Add a reproducible focused npm entry point and include it in the existing required no-AK PR gate, with build/bundle prerequisites and workflow guards. Run the startup/bind/cleanup subset in the existing macOS lane and the whole suite in the existing Windows lane, after its unit tests and failing if any case is skipped, since no other Windows lane runs these cases. Separately exercise the real Java HostedHarnessClient against the packaged CLI and Spring private Store using isolated real MySQL; assert the engine/version so MariaDB or H2 cannot substitute. Keep Java/database evidence separate from fixture evidence.
The suite file is also collected by the `cli` path-filter lane (merge groups and releases) and by the E2E full-suite legs under the default configuration; its describe-level timeout and retry settings apply there too. The Java profiles split integration tests by family: `Hosted*IT` runs only in the MySQL job, which selects by profile rather than `-Dit.test`, and every other `*IT` stays in the MariaDB job. The SDK Java workflow also triggers on `packages/cli/src/config/**`, `packages/core/src/config/**` and `packages/core/src/core/**`, which the Hosted runtime loads. Replayed over main's commits from 2026-08-27 to 2026-09-26, that raises its trigger rate from about 32% to 42%, each run adding the MySQL job.
The suite file is also collected by the `cli` path-filter lane (merge groups and releases) and by the E2E full-suite legs under the default configuration; its describe-level timeout and retry settings apply there too. The `managed-agent-server` profiles split integration tests by family: `Hosted*IT` runs only in the MySQL job, which selects by profile rather than `-Dit.test`, and every other `*IT` stays in the MariaDB job. `runtime-broker` has no Hosted family, so a `Hosted*IT` there fails the MariaDB check. Maven still passes when a profile's `<includes>`, or `-Dit.test` in `MAVEN_ARGS` or `.mvn/maven.config`, narrows that selection, so after its tests each job compares the failsafe reports with the source tree: every `*IT` class of its family must have run at least one test, and no other test class may have run. Failsafe also records Maven's user properties in its reports, so the check fails a run under `-Dit.test`, `-Dfailsafe.includesFile` or `-Dfailsafe.excludesFile` given on the command line, in `MAVEN_ARGS` or in `.mvn/maven.config`, and any report without properties. The workflow guard rejects those selections, and a `<test>` element, in the workflow and in the POMs. The MariaDB job checks `runtime-broker` and `managed-agent-server`, the MySQL job `managed-agent-server`. The workflow also runs when the check script or any `.mvn` directory changes, since Maven takes its configuration from the nearest `.mvn` directory above the build. The SDK Java workflow also triggers on `packages/cli/src/config/**`, `packages/core/src/config/**` and `packages/core/src/core/**`, which the Hosted runtime loads. Replayed over main's commits from 2026-08-27 to 2026-09-26, that raises its trigger rate from about 32% to 42%, each run adding the MySQL job.
## Files and scope
Changes are limited to integration test helpers/cases/configuration, npm scripts, CI workflows and their guard tests, Java integration tests and their Maven test selection, and this design. No tools, approvals, output artifacts, public control-plane pipeline, kill/takeover recovery, uncertain turn replay, multi-instance failover, or production certification. No paid model calls.
Changes are limited to integration test helpers/cases/configuration, npm scripts, CI workflows with their guard tests and report check, Java integration tests and their Maven test selection, and this design. No tools, approvals, output artifacts, public control-plane pipeline, kill/takeover recovery, uncertain turn replay, multi-instance failover, or production certification. No paid model calls.
## Validation and acceptance
@@ -32,9 +32,9 @@ Record exact SHA, platform, commands, results and omissions. Demonstrate separat
## Reproduction and recorded evidence
Install with `corepack pnpm install --frozen-lockfile` (its prepare step builds and bundles), or run `npm run build && npm run bundle` after a worktree bootstrap. Run `npm run test:integration:hosted:sandbox:none`. The portable subset is `npm run test:integration:hosted:sandbox:none -- -t 'portable startup'`.
Install with `corepack pnpm install --frozen-lockfile` (its prepare step builds and bundles), or run `npm run build && npm run bundle` after a worktree bootstrap. Run `npm run test:integration:hosted:sandbox:none`. The portable subset, which the macOS lane runs, is `npm run test:integration:hosted:sandbox:none -- -t 'portable startup'`.
For the database slice, install the `qwencode` and `runtime-broker` Maven modules, then run `mvn -f packages/sdk-java/managed-agent-server/pom.xml -Phosted-harness-mysql -Dqwen.cli.entry=<absolute-path-to-dist/cli.js> -Dmysql.url=<isolated-MySQL-JDBC-URL> -Dmysql.user=<user> -Dmysql.password=<password> verify checkstyle:check` using Java 21 and Node 22. The profile fails when prerequisites or tests are missing. The existing MariaDB profile excludes `Hosted*IT`; the new CI job supplies an ephemeral `mysql:8.4.6` service.
For the database slice, install the `qwencode` and `runtime-broker` Maven modules, then run `mvn -f packages/sdk-java/managed-agent-server/pom.xml -Phosted-harness-mysql -Dqwen.cli.entry=<absolute-path-to-dist/cli.js> -Dmysql.url=<isolated-MySQL-JDBC-URL> -Dmysql.user=<user> -Dmysql.password=<password> clean verify checkstyle:check` using Java 21 and Node 22. The profile fails when prerequisites or tests are missing. The existing MariaDB profile excludes `Hosted*IT`; the new CI job supplies an ephemeral `mysql:8.4.6` service. `node scripts/check-failsafe-reports.js hosted packages/sdk-java/managed-agent-server` then compares the reports with the tree; the MariaDB job runs it as `non-hosted packages/sdk-java/runtime-broker packages/sdk-java/managed-agent-server`.
On 2026-09-26, macOS 26.5.1 arm64 / Node 22.22.2 independently passed all 7 fixture-backed process tests against production SHA `5aa0b70526cc3bd5ebc32c247e44c6915f1734c5` without production source changes. Removing `lenientToolWarmup: true` from the bundled model function restored the original initialization defect: the fresh-session test failed with zero model requests and `SkillManager not available`. Restoring the old `setHistory(history)` made both failed/cancelled history cases fail because B contained A. Restoring and rebuilding the bundle passed again; all 1303 bundle files matched the baseline checksums.
@@ -46,9 +46,12 @@ Remote CI on the final #12733 head `fe524758a0bd4bf03d5a8582530b239654f06aa4` ex
The review follow-up was verified on Debian 13 x86_64 / Node 22.22.2 at main `663d98eac5`. All 8 process cases passed. In the bundle, removing either Hosted route gate, the forced `/health` authentication at all three sites, the Store writer check or the empty-answer filter each failed only its intended case, while the previous 7-case suite passed every one of them. The original configuration and history defects still failed their cases, and all 1316 bundle files matched the baseline after restoration. Java 21.0.10 ran the Hosted profile without `-Dit.test` against docker `mysql:8.4` (8.4.11): 95 unit tests and 1 integration test passed, none skipped. The MariaDB 10.11.18 profile ran `ManagedAgentMySqlIT` alone. A temporary `HostedZzzProbeIT` ran only in the MySQL job. Under the previous POM it ran in the MariaDB job and was absent from the Hosted job.
The #12823 follow-up was verified on Debian 13 x86_64 / Node 22.22.2 / Java 21.0.10 at main `848cf5e6c4`, replaying each Java job's steps against docker MariaDB 10.11.18 and MySQL 8.4.11. Both report checks passed and listed what ran: `JdbcRuntimeBrokerMySqlIT` (2 tests) and `ManagedAgentMySqlIT` (10) in the MariaDB job, `HostedHarnessMySqlIT` (1) in the MySQL job. Temporary `ZzzProbeIT` and `HostedZzzProbeIT` each ran only in their own job. Each of the following left Maven green and failed the job's check, naming the class that did not run: an `<includes>` narrowing either profile, including one that selects nothing in the MariaDB profile, which has no `failIfNoTests`; `-Dit.test` in `MAVEN_ARGS` for either job; and `-Dit.test` in the module's `.mvn/maven.config`. The check also named the `-Dit.test` value, and that alone failed a method filter that kept one of `ManagedAgentMySqlIT`'s 10 tests. Selecting that method through `-Dfailsafe.includesFile`, or adding `-DenablePropertiesElement=false` so that the reports recorded no properties, failed it as well. A unit test class that failsafe ran in the MariaDB job failed the check as a class outside the family. Widening the MySQL job with `-Dit.test=*MySqlIT` also left Maven green, because `ManagedAgentMySqlIT` passes on MySQL 8.4 too; the check failed it as a test class outside the Hosted family. The Hosted suite passed all 9 cases. The required no-AK configuration passed every file that uses either daemon helper, and so did the other daemon-harness suites that need no model credentials: both live-journal recovery suites and both mem0 daemon suites. The configuration's one failure, the child-crash case of `qwen-serve-streaming.test.ts`, fails identically at base, because it looks for `qwen` in the child's command line and the worktree path had none. Merging the caller's environment into the child, or dropping the child's `HOME` or `QWEN_HOME`, each failed the new environment case.
## Limits
- Windows CI runs only the portable subset. The 7 cases of that time, fixture-backed ones included, passed on a GitHub-hosted `windows-2022` runner once #12718 made directory sync tolerant on Windows, as recorded in the second verification round of #12733. No QwenLM lane runs the fixture-backed cases, and the cases added later have run only on Linux.
- The Windows lane runs the whole suite; the macOS lane runs only the portable subset, because the macOS E2E full-suite legs already run the whole file on push and nightly. Before #12823, the fixture-backed cases had run on Windows only on a GitHub-hosted `windows-2022` runner: the 7 cases of that time passed once #12718 made directory sync tolerant on Windows, as recorded in the second verification round of #12733.
- The macOS and Windows lanes run on merge group, schedule or dispatch, not on ordinary PRs.
- The Java report check works class by class. Within a class it relies on the Maven user properties that the reports record, so a narrowing that keeps one test of every class still passes when it comes as a JVM option, such as `-Dit.test` in `.mvn/jvm.config` or in a `MAVEN_OPTS` set outside the workflow.
- Core rejects a model stream without a finish reason (`NO_FINISH_REASON`) after about 20 s of retry backoff, before the Hosted guard sees it. The process gate does not include that case.
- Kill/takeover recovery, uncertain-turn replay, multi-instance failover and production readiness stay outside this gate; see #12740.
@@ -2,7 +2,7 @@
[English](2026-09-26-hosted-no-tool-process-gate.md) | [简体中文](2026-09-26-hosted-no-tool-process-gate.zh-CN.md)
状态:已由 #12733 及其审查后续实现 #12728。
状态:已由 #12733 及其审查后续实现 #12728。该审查后续延后的事项已在 #12823 下实现;完整套件在自托管 Windows 上的首次运行尚待记录。
## 问题与基线
@@ -14,17 +14,17 @@ PR #12713 的 Hosted 无工具运行时曾通过 mock 测试,但打包 CLI 边
## 隔离和失败处理
每个用例拥有独立临时 home、工作区及 Store 目录、临时端口上的 loopback listener,以及受时限约束的子进程。临时根目录使用 `qwen-e2e-home-` 前缀,被中途拆除的 worker 遗留的目录可由集成测试清扫器回收。清扫只在自己拥有临时 home 且使用默认临时目录的集成运行中执行。必需的无 AK lane 固定了 `QWEN_HOME`,聚焦配置没有 global setup,E2E leg 使用私有临时目录,因此它们都不会回收这些目录。子进程环境采用白名单,仅使用本地假凭据。包括启动超时和断言失败在内,始终终止进程、中止 SSE reader、关闭 fixture listener 并清理临时状态。失败时保留有界诊断,teardown 在所有配置下使用同一显式 hook 时限。缺少构建前置必须明确失败,不得因缺少凭据而跳过。
每个用例拥有独立临时 home、工作区及 Store 目录、临时端口上的 loopback listener,以及受时限约束的子进程。临时根目录使用 `qwen-e2e-home-` 前缀,被中途拆除的 worker 遗留的目录可由集成测试清扫器回收。只要集成运行自己拥有临时 home(即未设置 `QWEN_HOME`),就会执行清扫,扫描该运行的临时目录。必需的无 AK lane 固定了 `QWEN_HOME`,聚焦配置没有 global setup,因此两者都不清扫。经 `run-e2e-tests.sh` 运行的各 Linux E2E leg 只清扫该脚本为每次运行新建的私有临时目录,其中没有早先留下的目录;`cli` 路径过滤 lane 和 macOS E2E leg 使用默认临时目录,会回收这些目录。子进程环境采用白名单,仅使用本地假凭据;调用方的 `HOME` 或 `QWEN_HOME` 进入子进程,或调用方环境被整体传入时,用例失败。`qwen serve` 的 listening banner 匹配和清理序列(SIGTERM,5 秒后 SIGKILL)位于与 daemon 测试 harness 共用的模块中,该模块不导入 SDK 和 core。包括启动超时和断言失败在内,始终终止进程、中止 SSE reader、关闭 fixture listener 并清理临时状态。失败时保留有界诊断,teardown 在所有配置下使用同一显式 hook 时限。缺少构建前置必须明确失败,不得因缺少凭据而跳过。
## CI 与数据库切片
新增可复现 npm 入口并接入现有无 AK PR 必跑门禁,包含构建/打包前置及 workflow 守护测试。在现有 macOS/Windows lane 添加启动、绑定及清理验证。独立使用真实 Java HostedHarnessClient → 打包 CLI → Spring 私有 Store → 隔离真实 MySQL,检查数据库种类和版本以拒绝 MariaDB/H2 替代。Java/数据库与 fixture 证据分别记录。
新增可复现 npm 入口并接入现有无 AK PR 必跑门禁,包含构建/打包前置及 workflow 守护测试。现有 macOS lane 运行启动、绑定及清理子集,现有 Windows lane 在单测之后运行完整套件,任何用例被跳过即失败,因为没有其他 Windows lane 运行这些用例。独立使用真实 Java HostedHarnessClient → 打包 CLI → Spring 私有 Store → 隔离真实 MySQL,检查数据库种类和版本以拒绝 MariaDB/H2 替代。Java/数据库与 fixture 证据分别记录。
该测试文件也会被 `cli` 路径过滤 lane(merge group 与发布)和 E2E 完整套件 leg 以默认配置收集,describe 级超时和重试设置在那里同样生效。Java profile 按测试族分流集成测试:`Hosted*IT` 只在 MySQL 作业中运行,该作业按 profile 选择测试而非 `-Dit.test`;其余 `*IT` 留在 MariaDB 作业。SDK Java workflow 还会因 `packages/cli/src/config/**`、`packages/core/src/config/**` 和 `packages/core/src/core/**` 触发,因为 Hosted 运行时会加载这些代码。以 2026-08-27 至 2026-09-26 的 main 提交回放,触发率由约 32% 升至 42%,每次运行都会多跑 MySQL 作业。
该测试文件也会被 `cli` 路径过滤 lane(merge group 与发布)和 E2E 完整套件 leg 以默认配置收集,describe 级超时和重试设置在那里同样生效。`managed-agent-server` 的 profile 按测试族分流集成测试:`Hosted*IT` 只在 MySQL 作业中运行,该作业按 profile 选择测试而非 `-Dit.test`;其余 `*IT` 留在 MariaDB 作业。`runtime-broker` 没有 Hosted 族,放在那里的 `Hosted*IT` 会让 MariaDB 检查失败。profile 的 `<includes>`,或 `MAVEN_ARGS`、`.mvn/maven.config` 中的 `-Dit.test` 收窄这一选择时 Maven 仍会通过,因此每个作业在测试后把 failsafe 报告与源码树比对:本族每个 `*IT` 类都必须至少执行一项测试,其他测试类都不得执行。failsafe 还会把 Maven 的用户属性记录在报告中,因此在命令行、`MAVEN_ARGS` 或 `.mvn/maven.config` 中传入 `-Dit.test`、`-Dfailsafe.includesFile` 或 `-Dfailsafe.excludesFile` 的运行会让检查失败,缺少属性的报告也会让检查失败。workflow 守护拒绝在 workflow 和 POM 中出现这些选择方式以及 `<test>` 元素。MariaDB 作业检查 `runtime-broker` 和 `managed-agent-server`,MySQL 作业检查 `managed-agent-server`。检查脚本或任何 `.mvn` 目录改动时 workflow 也会运行,因为 Maven 从构建目录向上最近的 `.mvn` 目录读取配置。SDK Java workflow 还会因 `packages/cli/src/config/**`、`packages/core/src/config/**` 和 `packages/core/src/core/**` 触发,因为 Hosted 运行时会加载这些代码。以 2026-08-27 至 2026-09-26 的 main 提交回放,触发率由约 32% 升至 42%,每次运行都会多跑 MySQL 作业。
## 文件与范围
变更仅涉及集成测试辅助程序、用例及配置、npm 脚本、CI workflow 及守护测试、Java 集成测试及其 Maven 测试选择,以及本设计。不包含工具、审批、输出产物、公共控制面流程、强杀接管恢复、不确定轮次重放、多实例故障转移或生产认证。不调用付费模型。
变更仅涉及集成测试辅助程序、用例及配置、npm 脚本、CI workflow 及其守护测试和报告检查、Java 集成测试及其 Maven 测试选择,以及本设计。不包含工具、审批、输出产物、公共控制面流程、强杀接管恢复、不确定轮次重放、多实例故障转移或生产认证。不调用付费模型。
## 验证与验收
@@ -32,9 +32,9 @@ PR #12713 的 Hosted 无工具运行时曾通过 mock 测试,但打包 CLI 边
## 复现入口与已记录证据
使用 `corepack pnpm install --frozen-lockfile` 安装(prepare 会构建并打包),或在工作树依赖准备后执行 `npm run build && npm run bundle`。测试入口为 `npm run test:integration:hosted:sandbox:none`;可移植子集为 `npm run test:integration:hosted:sandbox:none -- -t 'portable startup'`。
使用 `corepack pnpm install --frozen-lockfile` 安装(prepare 会构建并打包),或在工作树依赖准备后执行 `npm run build && npm run bundle`。测试入口为 `npm run test:integration:hosted:sandbox:none`;macOS lane 运行的可移植子集为 `npm run test:integration:hosted:sandbox:none -- -t 'portable startup'`。
数据库切片先安装 `qwencode` 和 `runtime-broker` Maven 模块,再使用 Java 21 和 Node 22 执行 `mvn -f packages/sdk-java/managed-agent-server/pom.xml -Phosted-harness-mysql -Dqwen.cli.entry=<absolute-path-to-dist/cli.js> -Dmysql.url=<isolated-MySQL-JDBC-URL> -Dmysql.user=<user> -Dmysql.password=<password> verify checkstyle:check`。缺少前置或测试时该 profile 必须失败。现有 MariaDB profile 排除 `Hosted*IT`;新增 CI 作业提供临时 `mysql:8.4.6` service。
数据库切片先安装 `qwencode` 和 `runtime-broker` Maven 模块,再使用 Java 21 和 Node 22 执行 `mvn -f packages/sdk-java/managed-agent-server/pom.xml -Phosted-harness-mysql -Dqwen.cli.entry=<absolute-path-to-dist/cli.js> -Dmysql.url=<isolated-MySQL-JDBC-URL> -Dmysql.user=<user> -Dmysql.password=<password> clean verify checkstyle:check`。缺少前置或测试时该 profile 必须失败。现有 MariaDB profile 排除 `Hosted*IT`;新增 CI 作业提供临时 `mysql:8.4.6` service。随后 `node scripts/check-failsafe-reports.js hosted packages/sdk-java/managed-agent-server` 把报告与源码树比对;MariaDB 作业以 `non-hosted packages/sdk-java/runtime-broker packages/sdk-java/managed-agent-server` 运行它。
2026-09-26,在 macOS 26.5.1 arm64 / Node 22.22.2 上,针对生产提交 `5aa0b70526cc3bd5ebc32c247e44c6915f1734c5` 独立跑通全部 7 项 fixture 进程测试,无生产源码改动。从打包后的模型函数移除 `lenientToolWarmup: true` 会恢复原初始化缺陷:新会话测试失败,模型请求为 0,错误为 `SkillManager not available`。恢复旧 `setHistory(history)` 后,失败/取消两项历史测试均因 B 包含 A 而失败。恢复并重新打包后再次通过,全部 1303 个 bundle 文件与基线校验值相同。
@@ -46,9 +46,12 @@ PR #12713 的 Hosted 无工具运行时曾通过 mock 测试,但打包 CLI 边
审查后续在 Debian 13 x86_64 / Node 22.22.2、main `663d98eac5` 上验证。全部 8 项进程用例通过。在 bundle 中分别去掉任一道 Hosted 路由闸门、全部三处强制 `/health` 认证、Store writer 校验或空回答过滤,都只让对应用例失败,而原先 7 项的套件对这些变异全部放行。原始配置缺陷和历史缺陷仍使对应用例失败;恢复后全部 1316 个 bundle 文件与基线一致。Java 21.0.10 在不带 `-Dit.test` 的情况下以 Hosted profile 连接 docker `mysql:8.4`(8.4.11)运行:95 项单测和 1 项集成测试通过,跳过 0 项。MariaDB 10.11.18 profile 只运行 `ManagedAgentMySqlIT`。临时 `HostedZzzProbeIT` 只在 MySQL 作业中运行;使用旧 POM 时它会在 MariaDB 作业中运行,且不出现在 Hosted 作业中。
#12823 后续在 Debian 13 x86_64 / Node 22.22.2 / Java 21.0.10、main `848cf5e6c4` 上验证,按步骤重放两个 Java 作业,数据库为 docker MariaDB 10.11.18 和 MySQL 8.4.11。两项报告检查都通过,并列出实际执行的测试:MariaDB 作业中是 `JdbcRuntimeBrokerMySqlIT`(2 项)和 `ManagedAgentMySqlIT`(10 项),MySQL 作业中是 `HostedHarnessMySqlIT`(1 项)。临时加入的 `ZzzProbeIT` 和 `HostedZzzProbeIT` 各自只在本族作业中执行。以下每种情况都让 Maven 保持通过,而作业的检查失败并列出未执行的类:在任一 profile 中加入收窄的 `<includes>`,包括在没有 `failIfNoTests` 的 MariaDB profile 中一项都不选;在任一作业的 `MAVEN_ARGS` 中传入 `-Dit.test`;在模块的 `.mvn/maven.config` 中写入 `-Dit.test`。检查还会列出 `-Dit.test` 的值,仅凭这一点,只保留 `ManagedAgentMySqlIT` 10 项测试中一项的方法过滤也会失败。通过 `-Dfailsafe.includesFile` 选中同一个方法,或加上 `-DenablePropertiesElement=false` 使报告不记录属性,同样会失败。failsafe 在 MariaDB 作业中运行的单测类会被判为族外的类而失败。在 MySQL 作业中用 `-Dit.test=*MySqlIT` 放宽选择时 Maven 同样通过,因为 `ManagedAgentMySqlIT` 在 MySQL 8.4 上也能通过;检查将它判为 Hosted 族以外的测试类而失败。Hosted 套件全部 9 项用例通过。必需的无 AK 配置中使用任一 daemon 辅助程序的文件全部通过,其余无需模型凭据的 daemon harness 套件(两个 live journal 恢复套件和两个 mem0 daemon 套件)也全部通过。该配置唯一的失败是 `qwen-serve-streaming.test.ts` 的子进程崩溃用例,它在基线上以同样方式失败:该用例在子进程命令行中查找 `qwen`,而 worktree 路径中没有。把调用方环境合并进子进程,或去掉子进程的 `HOME` 或 `QWEN_HOME`,都会让新增的环境用例失败。
## 限制
- Windows CI 只运行可移植子集。#12718 让目录同步在 Windows 上可容忍失败后,当时的 7 项用例(含基于 fixture 的用例)已在 GitHub 托管的 `windows-2022` runner 上通过(见 #12733 第二轮验证记录)。QwenLM 没有 lane 运行基于 fixture 的用例,之后新增的用例只在 Linux 上运行过。
- Windows lane 运行完整套件;macOS lane 只运行可移植子集,因为 macOS E2E 完整套件 leg 已在 push 和 nightly 时运行整个文件。#12823 之前,基于 fixture 的用例在 Windows 上只在 GitHub 托管的 `windows-2022` runner 上运行过:#12718 让目录同步在 Windows 上可容忍失败后,当时的 7 项用例通过(见 #12733 第二轮验证记录)。
- macOS 和 Windows lane 在 merge group、定时或手动触发时运行,不在普通 PR 上运行。
- Java 报告检查按类进行。在类内部,它依赖报告记录的 Maven 用户属性,因此为每个类保留一项测试的收窄若以 JVM 选项的形式出现,例如写在 `.mvn/jvm.config` 中或 workflow 之外设置的 `MAVEN_OPTS` 中的 `-Dit.test`,仍能通过检查。
- 缺少 finish reason 的模型流会先被 core 以 `NO_FINISH_REASON` 拒绝,重试退避约 20 秒,Hosted 守卫看不到它。进程门禁不包含该场景。
- 强杀接管恢复、不确定轮次重放、多实例故障转移及生产就绪不在本门禁范围内,见 #12740。
+5 -25
View File
@@ -45,6 +45,9 @@ import {
hashMcpServerConfig,
type MCPServerConfig,
} from '@qwen-code/qwen-code-core';
import { LISTENING_LINE_RE, stopDaemon } from '../helpers/daemon-process.js';
export { LISTENING_LINE_RE };
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -89,13 +92,10 @@ export interface SpawnedDaemon {
stdoutBuf: { value: string };
/** Drain stderr similarly — surface on dispose if exit code != 0. */
stderrBuf: { value: string };
/** Idempotent. Sends SIGTERM, awaits exit (up to 5s). */
/** Idempotent. Resolves once the daemon has exited (see `stopDaemon`). */
dispose: () => Promise<void>;
}
export const LISTENING_LINE_RE =
/^(?<line>.*listening on http:\/\/127\.0\.0\.1:(?<port>\d+).*)$/m;
const DISPOSE_GRACE_MS = 5_000;
const MATCHED_DESCENDANT_DEPTH = 4;
export async function spawnDaemon(
@@ -188,27 +188,7 @@ export async function spawnDaemon(
const base = `http://127.0.0.1:${port}`;
const client = new DaemonClient({ baseUrl: base, token });
const dispose = async () => {
if (daemon.exitCode !== null) return;
daemon.kill('SIGTERM');
await new Promise<void>((resolve) => {
const t = setTimeout(() => {
// Force kill if SIGTERM didn't take in time. We don't await
// exit again — the OS will clean up either way and a 5s
// hang here multiplies into 5s × N tests on flaky machines.
try {
daemon.kill('SIGKILL');
} catch {
/* already gone */
}
resolve();
}, DISPOSE_GRACE_MS);
daemon.once('exit', () => {
clearTimeout(t);
resolve();
});
});
};
const dispose = () => stopDaemon(daemon);
return {
client,
@@ -5,12 +5,13 @@
*/
import { createHash, randomUUID } from 'node:crypto';
import { once } from 'node:events';
import { existsSync } from 'node:fs';
import { createServer } from 'node:net';
import path from 'node:path';
import WebSocket from 'ws';
import type { ManagedSessionDurableRef } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
fakeToolCall,
startFakeOpenAIServer,
@@ -479,8 +480,8 @@ describe(
expect(
(await cli.request('/health', { headers: new Headers() })).status,
).toBe(401);
// The Hosted gates answer with a bare 404. An ordinary route's own JSON
// not-found (the shell route's session_not_found) must not pass.
// Only a Hosted gate's bare 404 `Not Found` passes; whatever an ordinary
// route answers instead fails, even its own JSON 404.
const hidden = async (
method: string,
route: string,
@@ -550,6 +551,32 @@ describe(
expect(model!.requests.length).toBe(0);
});
it("keeps the caller's HOME, QWEN_HOME and environment out of the child", async () => {
vi.stubEnv('QWEN_HOME', 'caller-qwen-home');
vi.stubEnv('HOSTED_CALLER_ONLY', 'leaked');
try {
cli = new HostedHarnessProcess();
await expect(
cli.start('http://127.0.0.1:9/v1', {
args: [
'-e',
'const e = process.env; process.stdout.write(JSON.stringify([e.HOME, e.USERPROFILE, e.QWEN_HOME, e.HOSTED_CALLER_ONLY]))',
],
}),
).rejects.toThrow('Hosted CLI exited');
} finally {
vi.unstubAllEnvs();
}
const stdout = cli.child!.stdout!;
if (!stdout.readableEnded) await once(stdout, 'end');
expect(JSON.parse(cli.output)).toEqual([
cli.root,
cli.root,
path.join(cli.root, '.qwen'),
null,
]);
});
it('portable startup: binds loopback and cleans up after an assertion failure', async () => {
await start();
const urls = [cli.baseUrl, store!.baseUrl, model!.baseUrl];
@@ -0,0 +1,42 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { ChildProcess } from 'node:child_process';
// The `qwen serve` spawn/teardown contract shared by cli/_daemon-harness.ts
// and helpers/hosted-harness-process.ts. It imports only Node built-ins, so
// the Hosted helper does not pick up the SDK or core through it.
/** The line `qwen serve` prints once its loopback listener is bound. */
export const LISTENING_LINE_RE =
/^(?<line>.*listening on http:\/\/127\.0\.0\.1:(?<port>\d+).*)$/m;
const DAEMON_STOP_GRACE_MS = 5_000;
/**
* Sends SIGTERM, then SIGKILL once the grace period passes, and resolves when
* the child has exited. Returns at once for a child that has already exited
* or never spawned.
*/
export async function stopDaemon(child: ChildProcess): Promise<void> {
if (
child.pid === undefined ||
child.exitCode !== null ||
child.signalCode !== null
) {
return;
}
const exited = new Promise<void>((resolve) => {
child.once('exit', () => resolve());
});
child.kill('SIGTERM');
const timer = setTimeout(() => child.kill('SIGKILL'), DAEMON_STOP_GRACE_MS);
try {
await exited;
} finally {
clearTimeout(timer);
}
}
@@ -10,6 +10,7 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { HOSTED_HOME_PREFIX } from '../scratch-dir.js';
import { LISTENING_LINE_RE, stopDaemon } from './daemon-process.js';
export const HOSTED_TOKEN = 'hosted-process-fixture-token';
export const HOSTED_DIGEST = `sha256:${'a'.repeat(64)}`;
@@ -35,7 +36,6 @@ export class HostedHarnessProcess {
bootId = '';
output = '';
child?: ChildProcess;
private exited?: Promise<void>;
private spawnError?: Error;
async start(
@@ -134,22 +134,16 @@ export class HostedHarnessProcess {
};
this.child.stdout!.on('data', append);
this.child.stderr!.on('data', append);
this.exited = new Promise<void>((resolve) => {
this.child!.once('error', (error) => {
this.spawnError = error;
resolve();
});
this.child!.once('close', () => resolve());
this.child.once('error', (error) => {
this.spawnError = error;
});
await waitUntil(() => {
if (this.spawnError) throw this.spawnError;
if (this.child!.exitCode !== null || this.child!.signalCode !== null)
throw new Error(`Hosted CLI exited: ${this.output}`);
const match = this.output.match(
/qwen serve listening on (http:\/\/127\.0\.0\.1:\d+)/,
);
if (match) this.baseUrl = match[1];
return !!match;
const port = this.output.match(LISTENING_LINE_RE)?.groups?.['port'];
if (port) this.baseUrl = `http://127.0.0.1:${port}`;
return !!port;
}, options.startupTimeout);
await waitUntil(async () => {
const capabilities = await this.request('/capabilities');
@@ -188,19 +182,7 @@ export class HostedHarnessProcess {
}
async close() {
if (
this.child &&
this.child.exitCode === null &&
this.child.signalCode === null
) {
this.child.kill('SIGTERM');
const timer = setTimeout(() => this.child?.kill('SIGKILL'), 3_000);
try {
await this.exited;
} finally {
clearTimeout(timer);
}
}
if (this.child) await stopDaemon(this.child);
if (this.root)
await rm(this.root, {
recursive: true,
+106
View File
@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// The Java integration-test jobs split `*IT.java` by name: `Hosted*IT` runs
// only in the Hosted MySQL job, every other `*IT` only in the MariaDB job.
// Maven still passes when a profile's <includes>, or -Dit.test in MAVEN_ARGS
// or .mvn/maven.config, narrows that selection, so after a job's tests this
// compares the failsafe reports with the source tree of each module: every
// `*IT` class of the job's family must have run at least one test (so an
// abstract base class must not end in IT), and no other test class may have
// run. The reports also record Maven's user properties, so a run under
// -Dit.test or -Dfailsafe.includesFile/excludesFile given on the command line,
// in MAVEN_ARGS or in .mvn/maven.config fails too, even one that keeps a
// single method of every class, and so does a report without properties.
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
const isHosted = (className) => /^Hosted.*IT$/.test(className);
const [family, ...modules] = process.argv.slice(2);
const inFamily =
family === 'hosted'
? isHosted
: family === 'non-hosted'
? (className) => /IT$/.test(className) && !isHosted(className)
: undefined;
if (!inFamily || modules.length === 0) {
console.error(
'usage: node scripts/check-failsafe-reports.js hosted|non-hosted <maven-module-dir>...',
);
process.exit(2);
}
const simpleName = (className) => className.split('.').at(-1);
// Not readdirSync's `recursive`: a Node older than 18.17 ignores it, and the
// check would then find no integration test at all.
const javaFiles = (dir, prefix = []) =>
readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory()
? javaFiles(path.join(dir, entry.name), [...prefix, entry.name])
: [[...prefix, entry.name].join('.')],
);
let failed = false;
for (const module of modules) {
const expected = javaFiles(path.join(module, 'src', 'test', 'java'))
.filter((file) => file.endsWith('IT.java'))
.map((file) => file.slice(0, -'.java'.length))
.filter((className) => inFamily(simpleName(className)));
const reports = path.join(module, 'target', 'failsafe-reports');
const ran = new Map();
const filters = new Set();
const unrecorded = [];
for (const file of existsSync(reports) ? readdirSync(reports) : []) {
// A nested class that gets a report of its own counts as its outer class.
const className = /^TEST-(.+)\.xml$/.exec(file)?.[1].split('$')[0];
if (!className) continue;
// Count test cases, not the suite's `tests` attribute: some failsafe
// versions fold @Nested cases into the outer report and leave it at 0.
const report = readFileSync(path.join(reports, file), 'utf8').replace(
/<!\[CDATA\[[\s\S]*?\]\]>/g,
'',
);
const count = (element) =>
report.match(new RegExp(`<${element}\\b`, 'g'))?.length ?? 0;
ran.set(
className,
(ran.get(className) ?? 0) + count('testcase') - count('skipped'),
);
if (!/<properties\b/.test(report)) unrecorded.push(file);
for (const [, name, value] of report.matchAll(
/<property name="(it\.test|failsafe\.(?:in|ex)cludesFile)" value="([^"]*)"/g,
)) {
filters.add(`-D${name}=${value}`);
}
}
for (const filter of filters) {
failed = true;
console.error(`::error::${module}: ${filter} narrowed this run`);
}
for (const file of unrecorded) {
failed = true;
console.error(
`::error::${module}: ${file} records no properties, so its selection cannot be checked`,
);
}
for (const className of expected) {
if (ran.get(className) > 0) {
console.log(`${module}: ${className} ran ${ran.get(className)} test(s)`);
} else {
failed = true;
console.error(`::error::${module}: ${className} ran no test in this job`);
}
}
for (const className of ran.keys()) {
if (!inFamily(simpleName(className))) {
failed = true;
console.error(
`::error::${module}: ${className} ran outside the ${family} family`,
);
}
}
}
process.exitCode = failed ? 1 : 0;
@@ -0,0 +1,208 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const script = join(
dirname(fileURLToPath(import.meta.url)),
'..',
'check-failsafe-reports.js',
);
let root;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'check-failsafe-reports-'));
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
// A Maven module holding test sources and the failsafe reports of a run.
// Each report lists `[ran, skipped]` test cases as failsafe writes them, with
// a passing test's log in CDATA, where markup is only text.
function module(name, sources, reports = {}, properties = '') {
const dir = join(root, name);
mkdirSync(join(dir, 'src', 'test', 'java'), { recursive: true });
for (const className of sources) {
const file = `${join(dir, 'src', 'test', 'java', ...className.split('.'))}.java`;
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, '');
}
const reportDir = join(dir, 'target', 'failsafe-reports');
mkdirSync(reportDir, { recursive: true });
writeFileSync(join(reportDir, 'failsafe-summary.xml'), '<failsafe-summary/>');
for (const [className, [ran, skipped]] of Object.entries(reports)) {
const cases = [
...Array(ran).fill(
'<testcase name="passes"><system-out><![CDATA[log: <skipped/>]]></system-out></testcase>',
),
...Array(skipped).fill(
'<testcase name="skips"><skipped message="disabled"/></testcase>',
),
];
writeFileSync(
join(reportDir, `TEST-${className}.xml`),
`<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="${className}" tests="${ran + skipped}" skipped="${skipped}">\n<properties>${properties}</properties>\n${cases.join('\n')}\n</testsuite>\n`,
);
}
return dir;
}
function check(...args) {
const result = spawnSync(process.execPath, [script, ...args], {
encoding: 'utf8',
});
return { status: result.status, output: result.stdout + result.stderr };
}
describe('check-failsafe-reports', () => {
it('passes when every integration test of the family ran, whatever the other family did', () => {
const sources = [
'com.example.OrdersIT',
'com.example.store.LedgerIT',
'com.example.HostedFlowIT',
'com.example.HostedFlowTest',
];
const mariadb = module('mariadb', sources, {
'com.example.OrdersIT': [2, 1],
'com.example.OrdersIT$Retry': [1, 0],
'com.example.store.LedgerIT': [1, 0],
});
const result = check('non-hosted', mariadb);
expect(result.output).toContain('com.example.OrdersIT ran 3 test(s)');
expect(result.output).toContain('com.example.store.LedgerIT ran 1 test(s)');
expect(result.status).toBe(0);
const hosted = module('hosted', sources, {
'com.example.HostedFlowIT': [1, 0],
});
expect(check('hosted', hosted).status).toBe(0);
});
it('counts the test cases failsafe folds into an outer report', () => {
const dir = module('nested', ['com.example.OrdersIT']);
writeFileSync(
join(dir, 'target', 'failsafe-reports', 'TEST-com.example.OrdersIT.xml'),
'<testsuite name="com.example.OrdersIT" tests="0" skipped="0">\n<properties/>\n<testcase name="runs" classname="com.example.OrdersIT$Inner"/>\n</testsuite>\n',
);
const result = check('non-hosted', dir);
expect(result.output).toContain('com.example.OrdersIT ran 1 test(s)');
expect(result.status).toBe(0);
});
it('names every integration test of the family that ran no test', () => {
const dir = module(
'narrowed',
['com.example.OrdersIT', 'com.example.LedgerIT', 'com.example.AuditIT'],
{
'com.example.OrdersIT': [2, 0],
'com.example.LedgerIT': [0, 0],
'com.example.AuditIT': [0, 2],
},
);
const result = check('non-hosted', dir);
expect(result.output).toContain('com.example.LedgerIT ran no test');
expect(result.output).toContain('com.example.AuditIT ran no test');
expect(result.output).not.toContain('com.example.OrdersIT ran no test');
expect(result.status).toBe(1);
const unrun = module('unrun', ['com.example.HostedFlowIT']);
expect(check('hosted', unrun).output).toContain(
'com.example.HostedFlowIT ran no test',
);
});
it('rejects a run under -Dit.test even when every class ran a test', () => {
const dir = module(
'filtered',
['com.example.OrdersIT', 'com.example.LedgerIT'],
{
'com.example.OrdersIT': [1, 0],
'com.example.LedgerIT': [1, 0],
},
'<property name="it.test" value="OrdersIT#one,LedgerIT#one"/>',
);
const result = check('non-hosted', dir);
expect(result.output).toContain(
'-Dit.test=OrdersIT#one,LedgerIT#one narrowed this run',
);
expect(result.status).toBe(1);
const listed = module(
'listed',
['com.example.OrdersIT'],
{ 'com.example.OrdersIT': [1, 0] },
'<property name="failsafe.includesFile" value="/work/selected.txt"/>',
);
expect(check('non-hosted', listed).output).toContain(
'-Dfailsafe.includesFile=/work/selected.txt narrowed this run',
);
});
it('rejects a report that records no properties', () => {
const dir = module('bare', ['com.example.OrdersIT']);
writeFileSync(
join(dir, 'target', 'failsafe-reports', 'TEST-com.example.OrdersIT.xml'),
'<testsuite name="com.example.OrdersIT" tests="1" skipped="0">\n<testcase name="runs"/>\n</testsuite>\n',
);
const result = check('non-hosted', dir);
expect(result.output).toContain(
'TEST-com.example.OrdersIT.xml records no properties',
);
expect(result.status).toBe(1);
});
it('rejects a test class of the other family, or no family at all', () => {
const mariadb = module('mariadb', ['com.example.HostedFlowIT'], {
'com.example.HostedFlowIT$Retry': [1, 0],
'com.example.OrdersTest': [1, 0],
});
const result = check('non-hosted', mariadb);
expect(result.output).toContain(
'com.example.HostedFlowIT ran outside the non-hosted family',
);
expect(result.output).toContain(
'com.example.OrdersTest ran outside the non-hosted family',
);
expect(result.status).toBe(1);
const hosted = module(
'hosted',
['com.example.HostedFlowIT', 'com.example.OrdersIT'],
{
'com.example.HostedFlowIT': [1, 0],
'com.example.OrdersIT': [1, 0],
'com.example.HostedFlowTest': [1, 0],
},
);
const widened = check('hosted', hosted);
expect(widened.output).toContain(
'com.example.OrdersIT ran outside the hosted family',
);
expect(widened.output).toContain(
'com.example.HostedFlowTest ran outside the hosted family',
);
expect(widened.status).toBe(1);
});
it('checks every module it is given', () => {
const complete = module('complete', ['com.example.OrdersIT'], {
'com.example.OrdersIT': [1, 0],
});
const incomplete = module('incomplete', ['com.example.LedgerIT']);
const result = check('non-hosted', complete, incomplete);
expect(result.output).toContain('com.example.LedgerIT ran no test');
expect(result.status).toBe(1);
const noFamily = join(root, 'no-family');
mkdirSync(join(noFamily, 'src', 'test', 'java'), { recursive: true });
expect(check('hosted', noFamily).status).toBe(0);
});
it('refuses an unknown family or a missing module', () => {
expect(check('mariadb', root).status).toBe(2);
expect(check('hosted').status).toBe(2);
expect(check('hosted', join(root, 'absent')).status).not.toBe(0);
});
});
+78 -17
View File
@@ -16,6 +16,10 @@ const ci = parse(read('.github/workflows/ci.yml'));
const java = parse(read('.github/workflows/sdk-java.yml'));
const pkg = JSON.parse(read('package.json'));
const focused = 'test:integration:hosted:sandbox:none';
const windowsRun = [
`npm run ${focused} -- --reporter=default --reporter=json --outputFile.json=hosted-process-suite.json`,
`node -e "const r = require('./integration-tests/hosted-process-suite.json'); if (r.numTotalTests === 0 || r.numPassedTests !== r.numTotalTests) { console.error('::error::' + r.numPassedTests + ' of ' + r.numTotalTests + ' Hosted cases passed; Windows must run every case'); process.exit(1); }"`,
].join('\n');
describe('Hosted real-process gates', () => {
it('runs the packaged suite on relevant PRs without credentials or optional prerequisites', () => {
@@ -42,8 +46,11 @@ describe('Hosted real-process gates', () => {
expect(pkg.scripts['test:integration:no-ak:sandbox:none']).toContain(
'./cli/hosted-harness-process.test.ts',
);
expect(pkg.scripts[focused]).toContain(
'--config ./vitest.hosted.config.ts',
expect(pkg.scripts[focused]).toBe(
'cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --config ./vitest.hosted.config.ts',
);
expect(read('integration-tests/vitest.hosted.config.ts')).not.toContain(
'testNamePattern',
);
// Any unlisted path classifies as full, so the check means something only
// for paths that exist: none of them may take a PR out of the full profile.
@@ -68,27 +75,47 @@ describe('Hosted real-process gates', () => {
it('keeps the portable smoke filter matched to real cases', () => {
// vitest exits 0 when -t matches nothing, so renaming these titles would
// turn both platform smokes into silent no-ops.
// turn the macOS smoke into a silent no-op.
const source = read('integration-tests/cli/hosted-harness-process.test.ts');
expect(source.match(/\bit\(\s*'portable startup:/g)).toHaveLength(2);
// The Windows lane runs every case, so none may skip itself, by a
// modifier or an options object.
expect(source).not.toMatch(
/\.(skip|skipIf|runIf|todo|only)\b|\b(skip|todo|only)\s*:/,
);
});
it.each(['test_macos', 'test_windows'])(
'runs portable startup and cleanup on %s',
(name) => {
it.each([
{
name: 'test_macos',
step: 'Hosted portable process smoke',
after: /Install dependencies/i,
condition: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}",
run: `npm run ${focused} -- -t 'portable startup'`,
minutes: 5,
},
{
// After the unit tests, so that a Hosted failure cannot skip them, and
// failing unless every collected case passed.
name: 'test_windows',
step: 'Hosted process suite',
after: /^Run tests and generate reports$/,
condition:
"${{ !cancelled() && needs.classify_pr.outputs.skip_ci != 'true' }}",
run: windowsRun,
minutes: 10,
},
])(
'runs the Hosted process cases on $name',
({ name, step: stepName, after, condition, run, minutes }) => {
const steps = ci.jobs[name].steps;
const index = steps.findIndex(
(step) => step.name === 'Hosted portable process smoke',
);
const index = steps.findIndex((step) => step.name === stepName);
expect(index).toBeGreaterThan(
steps.findIndex((step) =>
/Install dependencies/i.test(step.name ?? ''),
),
steps.findIndex((step) => after.test(step.name ?? '')),
);
expect(steps[index].run).toBe(
`npm run ${focused} -- -t 'portable startup'`,
);
expect(steps[index]['timeout-minutes']).toBe(5);
expect(steps[index].if).toBe(condition);
expect(steps[index].run).toBe(run);
expect(steps[index]['timeout-minutes']).toBe(minutes);
expect(steps[index]['continue-on-error']).toBeUndefined();
},
);
@@ -110,6 +137,8 @@ describe('Hosted real-process gates', () => {
'packages/core/src/config/**',
'packages/core/src/core/**',
'pnpm-lock.yaml',
'scripts/check-failsafe-reports.js',
'**/.mvn/**',
]) {
expect(java.on[event].paths).toContain(path);
}
@@ -124,13 +153,22 @@ describe('Hosted real-process gates', () => {
expect(run.run).toContain(
'-Dqwen.cli.entry="${GITHUB_WORKSPACE}/dist/cli.js"',
);
expect(run.run).toContain('verify checkstyle:check');
expect(run.run).toContain('clean verify checkstyle:check');
expect(run.run).not.toContain('skip');
expect(run['continue-on-error']).toBeUndefined();
const pom = read('packages/sdk-java/managed-agent-server/pom.xml').replace(
/<!--[\s\S]*?-->/g,
'',
);
// The report check sees a test selection only when Maven passes it as a
// user property. Set in the workflow (MAVEN_OPTS included) or in a POM,
// it would narrow the run unrecorded.
const selection = /it\.test|includesFile|excludesFile|<test>/;
expect(read('.github/workflows/sdk-java.yml')).not.toMatch(selection);
expect(pom).not.toMatch(selection);
expect(read('packages/sdk-java/runtime-broker/pom.xml')).not.toMatch(
selection,
);
const [mariadb, hosted] = pom.split('<id>hosted-harness-mysql</id>');
expect(mariadb).toContain('<id>mysql-integration</id>');
expect(mariadb).toContain('<exclude>**/Hosted*IT.java</exclude>');
@@ -145,6 +183,29 @@ describe('Hosted real-process gates', () => {
);
expect(mariadbRun.run).toContain('-Pmysql-integration');
expect(mariadbRun.run).not.toContain('-Dit.test');
// Those checks read only the command lines, so each job also compares
// the reports of its run with the source tree, right after its last
// integration-test step.
for (const [name, after, check] of [
[
'mysql-integration',
mariadbRun.name,
'non-hosted packages/sdk-java/runtime-broker packages/sdk-java/managed-agent-server',
],
[
'hosted-harness-mysql',
run.name,
'hosted packages/sdk-java/managed-agent-server',
],
]) {
const steps = java.jobs[name].steps;
const index = steps.findIndex((step) => step.name === after) + 1;
expect(steps[index].run, name).toBe(
`node scripts/check-failsafe-reports.js ${check}`,
);
expect(steps[index].if, name).toBeUndefined();
expect(steps[index]['continue-on-error'], name).toBeUndefined();
}
// Keep the existing integration tests in their families: a renamed Hosted
// test would move to MariaDB, a renamed MariaDB test to the Hosted job.
const its = readdirSync(