fix(managed-agent): e2e runner and image robustness from the #12692 R2 review (#13344)

* fix(managed-agent): e2e runner and image robustness from the #12692 R2 review

Six R2 review follow-ups on #12692, re-verified against the current
script (post-#13263) before patching:

- waitUntil surfaces the last predicate error in its timeout message
  instead of swallowing it, and every iteration is bounded by the
  remaining deadline so a hung predicate can no longer outlive
  timeoutMs several-fold.
- crashProcess refuses to kill a child that already died BY SIGNAL
  (exitCode stays null but signalCode is set), instead of throwing a
  raw ESRCH that hides the descriptive pre-exit diagnostic.
- An interrupt delivered after the last poll now exits 1 with the
  signal name; success JSON can no longer appear for a run the
  operator explicitly stopped.
- runMysql and mysqladmin pass --no-defaults first, so a developer's
  ~/.my.cnf client options cannot auth-fail the scratch
  empty-password server (both mysqld invocations already did).
- The packaged-jar lookup is version-independent in both the runner
  (exactly-one jar assertion over target/) and the Dockerfile COPY
  (wildcard that fails on zero or multiple matches).
- The image listens beyond loopback by default: ENV
  QWEN_MANAGED_AGENT_SERVER_ADDRESS=0.0.0.0 and
  QWEN_MANAGED_AGENT_RUNTIME_BROKER_HOST=0.0.0.0, so a documented
  docker run -p no longer refuses traffic; the application.yml
  loopback defaults stay host-appropriate.

* fix(scripts-e2e): unbreak the failover lanes from the #13344 review

Three findings on the R2-fix follow-up, each verified against the CI
failure on hosted-harness-mysql before patching:

- The packaged-jar lookup assumed exactly one qwen-managed-agent-server-*.jar
  in target/, but the pom attaches classified repackage executions
  (workspace-bundle, operator-recovery) besides the unclassified server
  jar, so the cardinality check could never pass and every
  test:e2e:managed-*-failover step died at module scope. The script now
  reads the project <version> from the pom — the one following the
  module's own <artifactId>, not the <parent> block's — and names the
  unclassified jar exactly.
- The Dockerfile wildcard COPY fails the image build outright: multiple
  matches into a file destination is a Docker error, and no workflow
  builds this image to catch it. The build stage now selects the
  unclassified jar by classifier exclusion, fails unless exactly one
  exists, and publishes it under the fixed name the runtime stage COPYs.
- waitUntil's stall timer was discarded live: a fast success idled the
  runner up to the largest timeout after the success JSON. The timer is
  unref'd.

Script suite 14/14 green (new pins: pom-version jar naming, the
classifier-exclusion line, stallTimer.unref()); Prettier and ESLint
clean. The pom regex and the Dockerfile selection loop were both
executed locally against a three-jar target before committing.

* fix(scripts-e2e): harden the image guard and record the publish consequence

Second review round on the jar/bind changes, each verified before patching:

- The glob default arm trusted sh's non-nullglob behaviour: with zero
  matches the pattern itself reaches the counting arm, passes count=1
  vacuously, and the build dies at cp with a glob in the message. The
  loop now breaks on a non-existent match candidate and the guard names
  the expectation loudly (expected exactly one unclassified server jar,
  found N).
- The wide bind was necessary (-p DNATs to the bridge IP) but its
  consequence went unrecorded while README.md:6-8 still says the server
  listens on the loopback deliberately: the API has no HTTP
  authentication, so tenancy is whatever X-Qwen-Tenant-Id says, and
  docker run -p on a shared host publishes that surface. README's
  container section and the Dockerfile ENV comment now say this
  plainly; the pre-existing private-ingress gate stays with the wider
  lifecycle deferrals (#13302 family).
- Two pins watch the bind and the guard in the script suite (both go
  red against the old text); the Reviewer Test Plan again matches the
  current head.

Suite 15/15 green; Prettier applied.

* fix(scripts-e2e): close the second round's eight follow-ups

Each verified against the runner before patching:

- The synthetic 'predicate stalled' rejection no longer overwrites the
  real predicate error; undici's cause (ECONNREFUSED) is what the
  diagnostic surfaces instead of 'fetch failed'.
- MySQL client invocations run with an isolated empty HOME: MySQL
  honours ~/.mylogin.cnf even with option files disabled, so a
  developer's mysql_config_editor credential would otherwise defeat
  the flag at exactly the scratch empty-password servers it was for.
  spawnSync probes carry their own timeouts, as a sync-blocking call
  prevents the race deadline from firing inside an iteration.
- One 'has this child exited' spelling (childExited) replaces the four
  drifted phrasings that produced the original ESRCH.
- The AST extractor exists once; the port-allocation test calls the
  shared helper instead of maintaining a second copy.
- The two surfaces that pick the server jar are named similarly in
  test intent (pom-derived exact name in the runner, classifier
  exclusion with a loud guard in the Dockerfile) so their ownership
  no longer contradicts, and the pom regex is executed against the
  real pom in the suite rather than only grepped.

Suite 16/16 green; ESLint clean; Prettier applied.

* fix(scripts-e2e): opt the image bind in explicitly and close round three

Third review round, each finding reproduced or probed before patching:

- The published image no longer binds the managed-agent control plane
  on 0.0.0.0 by default (R1-1): the module has no HTTP authentication,
  so the ENV pair published a tenant API anyone routing to the port
  could drive. The image keeps the application.yml loopback defaults;
  publishing is now an explicit docker run -e opt-in documented in the
  README's container section, including the no--p bridge exposure.
- .dockerignore excludes target/ in both spellings, so a developer's
  stale Maven output cannot ride the build context into the stage the
  jar cardinality guard inspects.
- waitUntil: the stall sentinel is compared by identity and recorded
  only when no real predicate error was seen; an answered (falsy)
  iteration clears a stale earlier error; and the remaining budget is
  handed to the predicate so synchronous probes bound themselves.
- runMysql surfaces the spawn-level reason (a timeout no longer reads
  as a bare 'MySQL command failed: '), and the isolated MySQL client
  HOME hangs off the runner's one reclaimed scratch root.
- Tests: extraction lists name childExited so the early-exit branch is
  reachable; the pom regex is lifted from the runner source and pinned
  by shape instead of a copied literal (a version bump stays green);
  the double transpile is gone.

Suite 23/23 green (7 existing + 16 added by this PR); build, typecheck
and lint clean; 14 mutation probes each red their witness.

* fix(scripts-e2e): floor the last MySQL probe and close round four (#13344)

Fourth review round, each finding reproduced or probed before patching:

- R4-1 (Critical): the container-publishing recipe named
  QWEN_MANAGED_AGENT_RUNTIME_BROKER_HOST=0.0.0.0 without
  ALLOW_NON_LOOPBACK=true, which the broker requires for a non-loopback
  bind — an operator enabling the broker got a crash-looping container.
  Dropped the broker line from the README recipe and the Dockerfile
  comment and pointed at the Broker deployment section; publishing the
  API never needs the broker's HTTP face. A pairing pin now reds any
  documented broker wildcard bind that omits the flag.
- R1-8: waitUntil handed the final poll a remaining budget below one
  client round trip, so the deadline message named a wedged client when
  the lease was simply unexpired. runMysql floors its spawn timeout at
  2s, re-admitting up to ~2s of overshoot per wedged poll against the
  ~10s the budget threading removed.
- R3-1: the mysqlClientHome pin was a whole-file count, blind to a
  fifth client invocation without the override. Assert per call site
  over the runner's AST: every spawnSync mysql/mysqladmin/mysqld passes
  --no-defaults, and the mysql/mysqladmin clients use the isolated HOME
  (the mysqld launches deliberately keep the real one).
- R3-3: the loopback-by-default guarantee is pinned at application.yml's
  own default, not only at the Dockerfile declining to override it.
- R3-2: the waitUntil extraction list lived in six pasted copies; one
  WAIT_UNTIL_DEPS inventory with one loadWaitUntil() loader now serves
  all six, and the loader takes injected new Function parameters (used
  by the crashProcess and runMysql cases).
- R4-2: the jar-selection guard is exercised as shell — the RUN stage's
  own text run under a probed sh against zero/one/three/two-unclassified
  fixture target/ populations, asserting exit status and message.

Suite 24/24 green (7 existing + 17 added by this PR); build, typecheck
and lint clean; seven mutation probes red their witnesses, with the
guarded broker variant staying green as the control.

* fix(scripts-e2e): gate the jar guard on cp and sharpen the MySQL scan (#13344)

Fifth review round, each finding probed before patching:

- R4-2 (Critical): the executable jar-guard case gated on sh alone,
  but its fixture ends in cp — on a Git-Bash-only Windows PATH sh.exe
  resolves without the coreutils, so the merge-queue lane would red at
  the status assertions, and where sh is absent the in-body return
  recorded a pass for a guard that never ran. The case now probes the
  capability it consumes (sh -c 'command -v cp') and skipIf-gates, so
  a host without either reports skipped, matching the repo's own
  hasSha256sum probe spelling.
- R3-1: the per-call-site scan matched spawnSync only, missing the
  long-lived start(mysqld, ...) launch (3 of the 4 MySQL process
  launches); a collapsed population still passed the lower-bound
  assertion; and toContain pinned the flag's presence, not its
  position, though MySQL honors --no-defaults only as the first
  option. The scan now covers spawnSync/spawn/start, asserts the
  exact four-call inventory, and pins --no-defaults as each call's
  first array element.
- R1-8: the floor pin exercised only the clamp side; the
  default-timeout call now also pins that a budget above the floor
  reaches spawnSync unchanged (10_000), so a Math.max-to-constant
  regression reds the case.

R1-12 stays open for a maintainer: it asks for a PR-body edit (the
stale Image bind bullet and the 15/15 count), which this loop cannot
perform — it has no GitHub write credentials.

Suite 24/24 green; build, typecheck, lint and prettier clean; six
mutation probes red their witnesses (clamp-to-constant; --no-defaults
dropped from the start() launch; a fifth unprotected client; the flag
moved off index 0; cardinality guard disabled; a cp-less PATH reports
the jar-guard case skipped).

* fix(scripts-e2e): document a bootable publish recipe and close round five (#13344)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(scripts-e2e): boot the documented container recipe and pin the round-two gaps (#13344)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(scripts-e2e): pass the recipe's datasource password valueless and pin the round-three gaps (#13344)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(scripts-e2e): name both valueless credentials in the publish recipe's export instruction (#13344)

* fix(scripts-e2e): cover the third pom classifier in the image jar guard (#13344)

The base-conflict merge from main added the workspace-migration repackage
classifier to the pom while the Dockerfile's exclusion list stayed at two
arms, so the cardinality guard counted two unclassified jars and the
documented image build failed at the RUN stage with no CI lane building
the image to catch it. Add the missing arm, extend the shell-fixture
populations with the real four-artifact target/ layout, and derive the
exclusion set from the pom's own <classifier> elements in the suite so a
fourth classifier reddens the Test job instead of an operator's docker
build. The three prose copies of the artifact count are retired rather
than renumbered, so they cannot drift again.

Also close the two remaining round-one test-hardening findings: the
port-allocation test now loads through the shared load() helper instead
of a second local new Function spelling, and the image ENV pins anchor on
the instruction (case-insensitive, leading whitespace tolerated) rather
than the conventional uppercase column-0 spelling.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(scripts-e2e): defer the freeze wake record and name stalls in waitUntil errors (#13344)

- The --freeze arm printed its fencedFormerWriter record eagerly inside
  the try, so a SIGINT right after it left a success-shaped document on
  stdout for a run that exits 1, and the record flipped to before the
  main result. Defer it like resultJson and print it after the main
  result, restoring the base ordering.
- When the deadline's own poll stalled, waitUntil reported a stale
  earlier error as the final state. The message now reads "predicate
  stalled; last error before the stall: ..." so both facts survive.
- README: the documented MySQL grants did not boot the server (Flyway's
  performance_schema.user_variables_by_thread probe is denied with
  ERROR 1142 and startup stops at 'foreign_key_checks'); add the SELECT
  grant to the one-liner and mention it in the container section.

* fix(scripts-e2e): floor the waitUntil stall bound so a final in-flight poll can answer (#13344)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(scripts-e2e): print the README's full grant set in the dev recipe hint (#13344)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(scripts-e2e): re-key the in-flight poll test off the clock for re-entry margin (#13344)

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: qwen-code[bot] <qwen-code[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-11 16:42:22 +00:00
1 parent a8990042e6
commit 8679b7ca75
7 files changed
+1175 -141

No files matched your search

+2
View File
@@ -6,6 +6,8 @@ node_modules
dist
**/dist
**/tsconfig.tsbuildinfo
target
**/target
# Version control
# Both forms: the release workflow checks its trusted scripts out into
@@ -14,15 +14,52 @@ RUN mvn --batch-mode --no-transfer-progress \
-DskipTests install \
&& mvn --batch-mode --no-transfer-progress \
-f packages/sdk-java/managed-agent-server/pom.xml \
-DskipTests package
-DskipTests package \
# The pom attaches classified repackage executions alongside the
# unclassified server jar, so a wildcard matches several artifacts and
# a pinned version drifts. Select
# the unclassified jar by exclusion and publish it under a fixed name;
# fail the build (rather than ship a bad image) unless exactly one
# exists. sh has no nullglob, so a zero-match glob yields the pattern
# itself — break on it so the count check below still fails, and name
# the expectation loudly instead of dying at cp.
&& cd packages/sdk-java/managed-agent-server/target \
&& main="" && count=0 \
&& for f in qwen-managed-agent-server-*.jar; do \
[ -e "$f" ] || break; \
case "$f" in \
*-workspace-bundle.jar|*-operator-recovery.jar|*-workspace-migration.jar) ;; \
*) main="$f"; count=$((count+1)) ;; \
esac; \
done \
&& { [ "$count" -eq 1 ] || { \
echo >&2 "expected exactly one unclassified server jar," \
"found $count"; exit 1; \
}; } \
&& cp "$main" /tmp/qwen-managed-agent-server.jar
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build \
/workspace/packages/sdk-java/managed-agent-server/target/qwen-managed-agent-server-0.1.0-alpha.jar \
COPY --from=build /tmp/qwen-managed-agent-server.jar \
/app/qwen-managed-agent-server.jar
# The image keeps the application.yml loopback defaults: under the shipped
# `auto` auth mode the server refuses a non-loopback bind, so a plain
# `docker run -p 8080:8080` must not — and cannot — publish it. Publishing
# is an explicit operator opt-in that names an authentication posture (see
# the README's container section):
# docker run -p 8080:8080 \
# -e QWEN_MANAGED_AGENT_SERVER_ADDRESS=0.0.0.0 \
# -e QWEN_MANAGED_AGENT_AUTH_MODE=signed \
# -e QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY \
# -e SPRING_DATASOURCE_URL='jdbc:mysql://<db-host>:3306/qwen_managed_agent' \
# -e SPRING_DATASOURCE_USERNAME='qwen' \
# -e SPRING_DATASOURCE_PASSWORD \
# <image>
# or the deliberately unauthenticated
# QWEN_MANAGED_AGENT_AUTH_ALLOW_INSECURE_BIND=true override.
EXPOSE 8080 4182
USER 10001
ENTRYPOINT ["java", "-jar", "/app/qwen-managed-agent-server.jar"]
@@ -416,8 +416,10 @@ npm run dev:managed-agent
# Once per clone, and re-run after pulling changes to qwencode/runtime-broker (~12 s):
mvn -f packages/sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true install
mvn -f packages/sdk-java/runtime-broker/pom.xml -DskipTests install
# One-time, on a fresh MySQL 8 (creates the database and user the URL names):
mysql -u root -e "CREATE DATABASE qwen_managed_agent CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'qwen'@'localhost' IDENTIFIED BY 'replace-me'; CREATE USER 'qwen'@'127.0.0.1' IDENTIFIED BY 'replace-me'; GRANT ALL ON qwen_managed_agent.* TO 'qwen'@'localhost'; GRANT ALL ON qwen_managed_agent.* TO 'qwen'@'127.0.0.1';"
# One-time, on a fresh MySQL 8 (creates the database and user the URL names;
# the performance_schema SELECT covers Flyway's boot probe — without it the
# probe is denied (ERROR 1142) and startup stops at 'foreign_key_checks'):
mysql -u root -e "CREATE DATABASE qwen_managed_agent CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'qwen'@'localhost' IDENTIFIED BY 'replace-me'; CREATE USER 'qwen'@'127.0.0.1' IDENTIFIED BY 'replace-me'; GRANT ALL ON qwen_managed_agent.* TO 'qwen'@'localhost'; GRANT ALL ON qwen_managed_agent.* TO 'qwen'@'127.0.0.1'; GRANT SELECT ON performance_schema.user_variables_by_thread TO 'qwen'@'localhost'; GRANT SELECT ON performance_schema.user_variables_by_thread TO 'qwen'@'127.0.0.1';"
# (official MySQL images enable skip-name-resolve, so 'qwen'@'localhost' alone never
# matches TCP clients; a containerized MySQL sees the gateway address — grant at
# 'qwen'@'%' or the container-visible host instead)
@@ -975,6 +977,61 @@ The stock image contains the Java control plane only. Use the static Runtime
provisioner, or provide a derived image/mount with Node.js and the Qwen worker
artifacts, before enabling the local-process provisioner in a container.
The image keeps the application's `127.0.0.1` defaults, so a plain
`docker run -p 8080:8080` publishes nothing: `-p` DNATs to the container's
bridge IP, and a loopback listener is unreachable through it. Publishing is
an explicit opt-in, and the server refuses a non-loopback bind under the
shipped `auto` auth mode, so the command must name an authentication
posture — signed mode, as the introduction recommends (see "Broker
authentication and writer credentials" above). The signing key and the
datasource password are passed valueless so Docker forwards them from the
shell environment — the `=` spelling would leave either secret in the
command's argv for the container's whole lifetime. A valueless `-e`
forwards the variable of the same name, so export
`QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY` and `SPRING_DATASOURCE_PASSWORD` before
the run. The datasource must also be
named: the Prerequisites' `127.0.0.1` default is the container itself from
inside, so the image cannot boot against it:
```bash
docker run -p 8080:8080 \
-e QWEN_MANAGED_AGENT_SERVER_ADDRESS=0.0.0.0 \
-e QWEN_MANAGED_AGENT_AUTH_MODE=signed \
-e QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY \
-e SPRING_DATASOURCE_URL='jdbc:mysql://<db-host>:3306/qwen_managed_agent' \
-e SPRING_DATASOURCE_USERNAME='qwen' \
-e SPRING_DATASOURCE_PASSWORD \
<image>
```
or the deliberately unauthenticated
`-e QWEN_MANAGED_AGENT_AUTH_ALLOW_INSECURE_BIND=true` override.
The `qwen` user the recipe names needs the grants from the Full WebShell
entry's MySQL one-liner — including `SELECT` on
`performance_schema.user_variables_by_thread`, granted at the
container-visible host — or Flyway's boot probe is denied (`ERROR 1142`)
and startup stops.
Publishing the API needs no Runtime Broker face: the broker ships disabled
by default, and a non-loopback broker bind is a separate opt-in — the broker
refuses one unless `QWEN_MANAGED_AGENT_RUNTIME_BROKER_ALLOW_NON_LOOPBACK=true`
is also set (see Broker deployment above).
The opt-in makes the authentication consequence load-bearing. Under the
shipped default — `auto` resolving to `open` — there is no HTTP
authentication: tenancy is whatever `X-Qwen-Tenant-Id` says. Signed mode
HMAC-authenticates every request's tenant and actor headers; the
insecure-bind override publishes with request authentication off. Either way the tenant-scoped API is
exposed to anything that can route to the listener. That exposure does
not depend on publishing a port: on the default bridge network the API
also answers on the container's own bridge address, so every process on
the Docker host and every peer container on that bridge can reach it
with no `-p` at all. Run a published deployment behind an ingress that
authenticates the tenant before setting `X-Qwen-Tenant-Id`, map ports
only inside your own network policy, and attach the container only to
networks whose peers you trust.
## Managed Session Store verification
Unit and H2 contract tests run with the normal Maven test phase. `mvn verify`
+1 -1
View File
@@ -352,7 +352,7 @@ export function buildSpringRecipeLines({
springPs1Path,
}) {
const lines = [
` # one-time DB/user: mysql -u root -e "CREATE DATABASE qwen_managed_agent CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'qwen'@'localhost' IDENTIFIED BY 'replace-me'; CREATE USER 'qwen'@'127.0.0.1' IDENTIFIED BY 'replace-me'; GRANT ALL ON qwen_managed_agent.* TO 'qwen'@'localhost'; GRANT ALL ON qwen_managed_agent.* TO 'qwen'@'127.0.0.1';"`,
` # one-time DB/user: mysql -u root -e "CREATE DATABASE qwen_managed_agent CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'qwen'@'localhost' IDENTIFIED BY 'replace-me'; CREATE USER 'qwen'@'127.0.0.1' IDENTIFIED BY 'replace-me'; GRANT ALL ON qwen_managed_agent.* TO 'qwen'@'localhost'; GRANT ALL ON qwen_managed_agent.* TO 'qwen'@'127.0.0.1'; GRANT SELECT ON performance_schema.user_variables_by_thread TO 'qwen'@'localhost'; GRANT SELECT ON performance_schema.user_variables_by_thread TO 'qwen'@'127.0.0.1';"`,
` # (official MySQL images enable skip-name-resolve, so 'qwen'@'localhost' alone never matches TCP clients; a containerized MySQL sees the gateway address — grant at 'qwen'@'%' or the container-visible host instead)`,
' # once per clone, and re-run after pulling changes to qwencode/runtime-broker (~12 s):',
' # mvn -f packages/sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true install',
+266 -121
View File
@@ -12,6 +12,7 @@ import {
import { createServer, type Server } from 'node:http';
import { homedir, tmpdir } from 'node:os';
import path from 'node:path';
import { inspect } from 'node:util';
import {
fakeToolCall,
startFakeOpenAIServer,
@@ -105,13 +106,33 @@ if (!Number.isSafeInteger(runtimeDelayMs) || runtimeDelayMs < 0) {
}
const cliBundle = path.join(root, 'dist', 'cli.js');
const springJar = path.join(
const springModule = path.join(
root,
'packages',
'sdk-java',
'managed-agent-server',
'target',
'qwen-managed-agent-server-0.1.0-alpha.jar',
);
const springTarget = path.join(springModule, 'target');
// The pom attaches classified repackage executions alongside the
// unclassified server jar, so a wildcard over target/ matches several
// artifacts. Read the project version from the
// pom and name the unclassified jar exactly — version-independent without
// assuming it is the only packaged artifact. The <version> immediately
// following the module's own <artifactId> (not the <parent> block's). The
// capture itself is whitespace-tolerant, so a formatter wrapping or
// re-indenting the element cannot flow newlines into the jar path.
const pomXml = readFileSync(path.join(springModule, 'pom.xml'), 'utf8');
const pomVersion = pomXml.match(
/<artifactId>qwen-managed-agent-server<\/artifactId>\s*<version>\s*([^<]*?\S)\s*<\/version>/,
)?.[1];
if (!pomVersion) {
throw new Error(
'Could not read the project <version> from managed-agent-server/pom.xml',
);
}
const springJar = path.join(
springTarget,
`qwen-managed-agent-server-${pomVersion}.jar`,
);
for (const required of [
cliBundle,
@@ -336,10 +357,16 @@ function start(
return registered;
}
// One spelling for "has this child exited": five independent phrasings
// drifted into the pre-fix ESRCH that process.kill raised on a reaped pid.
function childExited(child: ChildProcess): boolean {
return child.exitCode !== null || child.signalCode !== null;
}
function processTreeExists(child: ChildProcess): boolean {
if (child.pid === undefined) return false;
if (process.platform === 'win32') {
return child.exitCode === null && child.signalCode === null;
return !childExited(child);
}
try {
process.kill(-child.pid, 0);
@@ -392,19 +419,17 @@ async function crashChild(child: ChildProcess, name: string): Promise<void> {
}
async function crashProcess(child: ChildProcess, name: string): Promise<void> {
if (child.pid === undefined || child.exitCode !== null) {
// A child killed BY signal has exitCode null but signalCode set; missing
// that branch kills a pid that no longer exists and throws raw ESRCH.
if (child.pid === undefined || childExited(child)) {
throw new Error(`${name} exited before the crash was injected`);
}
process.kill(child.pid, 'SIGKILL');
const deadline = Date.now() + 5_000;
while (
child.exitCode === null &&
child.signalCode === null &&
Date.now() < deadline
) {
while (!childExited(child) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
if (child.exitCode === null && child.signalCode === null) {
if (!childExited(child)) {
throw new Error(`${name} survived SIGKILL`);
}
}
@@ -542,33 +567,115 @@ async function startHeldExecutionStartProxy(
async function waitUntil(
name: string,
predicate: () => Promise<boolean> | boolean,
predicate: (remainingMs: number) => Promise<boolean> | boolean,
timeoutMs: number,
child?: Child,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
let lastErrorPredatesStall = false;
// Identity, not text: matching the rendered message would let a reworded
// sentinel silently invert the error precedence in the catch below.
const stall = new Error(`${name} predicate stalled`);
while (Date.now() < deadline) {
if (receivedSignal) throw new Error(`Interrupted by ${receivedSignal}`);
if (
child &&
(child.child.exitCode !== null || child.child.signalCode !== null)
) {
if (child && childExited(child.child)) {
throw new Error(`${name} exited early\n${child.log()}`);
}
try {
if (await predicate()) return;
} catch {
// The dependency is still starting.
// Bound one iteration against the remaining deadline: a hung
// predicate must not outlive timeoutMs, and its last error must
// surface instead of vanishing into "did not become ready". unref the
// stall timer: a quick success must not keep the event loop (and this
// runner) alive until the discarded timeout fires. The race bounds
// only an asynchronous predicate — a synchronous one blocks the event
// loop, so the remaining budget is handed to the predicate for its
// own probe timeout.
const ready = await Promise.race([
Promise.resolve().then(() =>
predicate(Math.max(1, deadline - Date.now())),
),
new Promise<boolean>((_, reject) => {
// Floor the bound: the final iteration can start with a sliver of
// budget left, and a poll that would answer just past the deadline
// must resolve the wait, not be discarded and misreported as a
// wedged predicate. The clamp re-admits up to ~500ms of overshoot
// on a genuinely wedged poll — the same trade runMysql's probe
// timeout floor makes for the synchronous path.
const stallTimer = setTimeout(
() => reject(stall),
Math.max(500, deadline - Date.now()),
);
stallTimer.unref();
}),
]);
if (ready) return;
// The predicate answered; an error from an earlier phase no longer
// describes the state the deadline found.
lastError = undefined;
} catch (error) {
// Prefer the cause an undici fetch rejection carries (its own message
// is "fetch failed"). A real predicate error wins over the synthetic
// stall; when the stall is what the deadline found, the older error
// is reported as predating it instead of posing as the final state.
if (error instanceof Error && error.cause instanceof Error) {
lastError = error.cause;
} else if (error !== stall) {
lastError = error;
} else if (lastError === undefined) {
lastError = error;
} else {
lastErrorPredatesStall = true;
}
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`${name} did not become ready\n${child?.log() ?? ''}`);
// A non-Error rejection (a status string, a library object) is still
// the last thing the predicate said: inspect it so the content survives —
// String() renders a plain object as "[object Object]".
let cause = '';
if (lastError instanceof Error) {
cause = `: ${lastError.message}`;
} else if (lastError !== undefined) {
try {
cause = `: ${inspect(lastError)}`;
} catch {
cause = `: ${String(lastError)}`;
}
}
if (lastErrorPredatesStall) {
cause = `: predicate stalled; last error before the stall${cause}`;
}
throw new Error(
`${name} did not become ready${cause}\n${child?.log() ?? ''}`,
);
}
function runMysql(port: number, sql: string): string {
// MySQL reads $HOME/.mylogin.cnf even when option files are disabled,
// so a mysql_config_editor credential on a developer's machine would
// silently auth-connect to the scratch empty-password server — and an
// exported MYSQL_PWD (read as the password) or MYSQL_TEST_LOGIN_FILE
// (relocates .mylogin.cnf ahead of $HOME, past --no-defaults) does the
// same. Client invocations run against an isolated environment instead of
// inheriting the real one: an empty HOME under the runner's one scratch
// root, so the finally reclaims it and QWEN_MANAGED_E2E_KEEP_TMP=1 keeps
// it for inspection, with both credential variables stripped.
const mysqlClientHome = path.join(temporary, 'mysql-client-home');
mkdirSync(mysqlClientHome, { recursive: true });
const mysqlClientEnv: NodeJS.ProcessEnv = {
...process.env,
HOME: mysqlClientHome,
};
delete mysqlClientEnv['MYSQL_PWD'];
delete mysqlClientEnv['MYSQL_TEST_LOGIN_FILE'];
function runMysql(port: number, sql: string, timeoutMs = 10_000): string {
const result = spawnSync(
mysql,
[
// First argument, honored only there: a developer's ~/.my.cnf client
// options must not auth-fail a scratch empty-password server.
'--no-defaults',
'--protocol=tcp',
'--host=127.0.0.1',
`--port=${port}`,
@@ -578,8 +685,24 @@ function runMysql(port: number, sql: string): string {
'--execute',
sql,
],
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 },
{
encoding: 'utf8',
env: mysqlClientEnv,
// waitUntil hands the lease polls its remaining budget, and the final
// iteration can shrink it below one client round trip; floor the probe
// timeout so the last poll is never handed less than it can cost. The
// clamp re-admits up to ~2s of overshoot per wedged poll, against the
// ~10s the budget threading set out to remove.
timeout: Math.max(2_000, timeoutMs),
maxBuffer: 16 * 1024 * 1024,
},
);
// A timed-out spawn sets error and leaves status null with empty stderr:
// surface the spawn-level reason, or a wedge reads as a bare
// "MySQL command failed: ".
if (result.error) {
throw new Error(`MySQL command failed: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`MySQL command failed: ${result.stderr}`);
}
@@ -801,6 +924,13 @@ let heldStartProxy: HeldExecutionStartProxy | undefined;
let replacementBrokerProxy: HeldExecutionStartProxy | undefined;
let failure: unknown;
let dumpPort: number | undefined;
// The success JSON, assembled inside the try and printed only after
// the post-finally throws, so an interrupt can never put a clean pass
// on stdout for a run the operator explicitly stopped.
let resultJson: string | undefined;
// The freeze arm's wake record reads as a pass line too, so it defers the
// same way and prints after the main result, as in the base's ordering.
let fencedWriterJson: string | undefined;
try {
const harnessToken = randomBytes(24).toString('base64url');
const brokerToken = randomBytes(24).toString('base64url');
@@ -929,17 +1059,24 @@ try {
);
await waitUntil(
'MySQL',
// spawnSync blocks the event loop, so the race deadline cannot fire
// inside one iteration: the probe carries its own timeout.
() =>
spawnSync(
mysqladmin,
[
'--no-defaults',
'--protocol=tcp',
'--host=127.0.0.1',
`--port=${mysqlPort}`,
'--user=root',
'ping',
],
{ stdio: 'ignore' },
{
stdio: 'ignore',
env: mysqlClientEnv,
timeout: 10_000,
},
).status === 0,
60_000,
mysqlServer,
@@ -1379,20 +1516,22 @@ try {
}
await waitUntil(
'Managed Session writer lease expiry',
() =>
(remainingMs) =>
runMysql(
mysqlPort,
`SELECT IF(writer_lease_until IS NULL OR writer_lease_until < CURRENT_TIMESTAMP(6), 1, 0) FROM qwen_managed_agent.qwen_managed_session_journal_head WHERE ${sessionFilter}`,
remainingMs,
) === '1',
10_000,
);
if (inflightFailover || continuationFailover) {
await waitUntil(
'Managed Turn dispatch lease expiry',
() =>
(remainingMs) =>
runMysql(
mysqlPort,
`SELECT IF(dispatch_lease_until IS NULL OR dispatch_lease_until < UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000, 1, 0) FROM qwen_managed_agent.managed_agent_turn WHERE ${sessionFilter}`,
remainingMs,
) === '1',
10_000,
);
@@ -1649,26 +1788,24 @@ try {
);
}
console.log(
JSON.stringify(
{
sessionId: session.id,
executionCallId: originalExecutionCallId,
executionState: recoveredExecution[1],
dispatchGeneration: Number(recoveredExecution[2]),
firstHarnessBootId: firstBootId,
replacementHarnessBootId: replacementBootId,
writerGeneration: `${firstHead[0]} -> ${replacementHead[0]}`,
journalRevision: `${firstHead[1]} -> ${replacementHead[1]}`,
committedSequence: `${firstHead[2]} -> ${replacementHead[2]}`,
promptReplayed: false,
physicalToolExecutions: 1,
terminalTurns: terminalCount,
oldHarnessDiskDeleted: !existsSync(harnessHome),
},
null,
2,
),
resultJson = JSON.stringify(
{
sessionId: session.id,
executionCallId: originalExecutionCallId,
executionState: recoveredExecution[1],
dispatchGeneration: Number(recoveredExecution[2]),
firstHarnessBootId: firstBootId,
replacementHarnessBootId: replacementBootId,
writerGeneration: `${firstHead[0]} -> ${replacementHead[0]}`,
journalRevision: `${firstHead[1]} -> ${replacementHead[1]}`,
committedSequence: `${firstHead[2]} -> ${replacementHead[2]}`,
promptReplayed: false,
physicalToolExecutions: 1,
terminalTurns: terminalCount,
oldHarnessDiskDeleted: !existsSync(harnessHome),
},
null,
2,
);
} else if (continuationFailover) {
await waitForTerminal(
@@ -1758,26 +1895,24 @@ try {
`Continuation failover audit failed: terminal=${recoveredTerminal?.type ?? 'missing'} text=${JSON.stringify(visibleText)} deltas=${JSON.stringify(textDeltas)} execution=${recoveredExecution.join(',')} rows=${executionCount} boot=${firstBootId}->${replacementBootId} terminals=${terminalCount} model=${initialModelRequests.length}+${continuationRequests.length} sideEffect=${JSON.stringify(sideEffectBytes)}`,
);
}
console.log(
JSON.stringify(
{
sessionId: session.id,
executionCallId: originalExecutionCallId,
firstHarnessBootId: firstBootId,
replacementHarnessBootId: replacementBootId,
promptReplayed: initialModelRequests.length !== 1,
physicalToolExecutions: executionCount,
continuationModelRequests: continuationRequests.length,
visibleText,
terminalTurns: terminalCount,
// The frozen-owner arm keeps the home on purpose (the wake
// needs it), so one key cannot mean both arms' intent.
oldHarnessDiskDeleted: !freeze && !existsSync(harnessHome),
harnessHomeRetainedForWake: freeze && existsSync(harnessHome),
},
null,
2,
),
resultJson = JSON.stringify(
{
sessionId: session.id,
executionCallId: originalExecutionCallId,
firstHarnessBootId: firstBootId,
replacementHarnessBootId: replacementBootId,
promptReplayed: initialModelRequests.length !== 1,
physicalToolExecutions: executionCount,
continuationModelRequests: continuationRequests.length,
visibleText,
terminalTurns: terminalCount,
// The frozen-owner arm keeps the home on purpose (the wake
// needs it), so one key cannot mean both arms' intent.
oldHarnessDiskDeleted: !freeze && !existsSync(harnessHome),
harnessHomeRetainedForWake: freeze && existsSync(harnessHome),
},
null,
2,
);
if (freeze) {
@@ -1892,18 +2027,16 @@ try {
`Frozen former Harness mutated the takeover after waking: head=${headBeforeWake}->${headAfterWake} oldWriterTx=${oldGenerationTxBeforeWake}->${oldGenerationTxAfterWake} boot=${replacementBootId}->${bootAfterWake} text=${JSON.stringify(visibleText)}->${JSON.stringify(awakeText)} terminals=${terminalCount}->${awakeTerminalCount}`,
);
}
console.log(
JSON.stringify(
{
fencedFormerWriter: true,
journalHead: headAfterWake,
oldGenerationTx: oldGenerationTxAfterWake,
harnessBootId: bootAfterWake,
visibleText: awakeText,
},
null,
2,
),
fencedWriterJson = JSON.stringify(
{
fencedFormerWriter: true,
journalHead: headAfterWake,
oldGenerationTx: oldGenerationTxAfterWake,
harnessBootId: bootAfterWake,
visibleText: awakeText,
},
null,
2,
);
}
} else {
@@ -2010,30 +2143,28 @@ try {
);
}
console.log(
JSON.stringify(
{
sessionId: session.id,
firstHarnessBootId: firstBootId,
replacementHarnessBootId: secondBootId,
writerGeneration: `${firstHead[0]} -> ${secondHead[0]}`,
journalRevision: `${firstHead[1]} -> ${secondHead[1]}`,
committedSequence: `${firstHead[2]} -> ${secondHead[2]}`,
terminalTurns: terminalCount,
...(bigOutput
? {
expectedCharacters: failoverFirstResponse.length,
expectedUtf8Bytes: Buffer.byteLength(failoverFirstResponse),
shortControlCharacters: failoverSecondResponse.length,
fullTextPreserved: true,
}
: {}),
restoredFirstTurnContext: true,
oldHarnessDiskDeleted: !existsSync(harnessHome),
},
null,
2,
),
resultJson = JSON.stringify(
{
sessionId: session.id,
firstHarnessBootId: firstBootId,
replacementHarnessBootId: secondBootId,
writerGeneration: `${firstHead[0]} -> ${secondHead[0]}`,
journalRevision: `${firstHead[1]} -> ${secondHead[1]}`,
committedSequence: `${firstHead[2]} -> ${secondHead[2]}`,
terminalTurns: terminalCount,
...(bigOutput
? {
expectedCharacters: failoverFirstResponse.length,
expectedUtf8Bytes: Buffer.byteLength(failoverFirstResponse),
shortControlCharacters: failoverSecondResponse.length,
fullTextPreserved: true,
}
: {}),
restoredFirstTurnContext: true,
oldHarnessDiskDeleted: !existsSync(harnessHome),
},
null,
2,
);
}
} else {
@@ -2192,25 +2323,23 @@ try {
throw new Error(`Durable event audit failed: ${durable.join(',')}`);
}
console.log(
JSON.stringify(
{
model,
sessionId: session.id,
createAdmissionMs: createdAt - requestStartedAt,
firstModelEventMs: firstModel.observedAt - requestStartedAt,
runtimeReadyMs: runtimeReady.observedAt - requestStartedAt,
terminalMs: terminal.observedAt - requestStartedAt,
modelBeforeRuntimeReady:
firstModel.event.sequence < runtimeReady.event.sequence,
toolSideEffect: true,
idempotentReplay: true,
crossTenantStatus: crossTenant.status,
durableEventCount: Number(durable[0]),
},
null,
2,
),
resultJson = JSON.stringify(
{
model,
sessionId: session.id,
createAdmissionMs: createdAt - requestStartedAt,
firstModelEventMs: firstModel.observedAt - requestStartedAt,
runtimeReadyMs: runtimeReady.observedAt - requestStartedAt,
terminalMs: terminal.observedAt - requestStartedAt,
modelBeforeRuntimeReady:
firstModel.event.sequence < runtimeReady.event.sequence,
toolSideEffect: true,
idempotentReplay: true,
crossTenantStatus: crossTenant.status,
durableEventCount: Number(durable[0]),
},
null,
2,
);
}
} catch (error) {
@@ -2265,7 +2394,13 @@ try {
await replacementBrokerProxy?.close();
releaseContinuationHold();
await fake?.close();
if (failure && process.env['QWEN_MANAGED_E2E_KEEP_TMP'] === '1') {
// An interrupt during teardown is a non-pass: the keep switch must still
// hold, or a stopped run deletes its own evidence before the post-finally
// throw reports it.
if (
(failure || receivedSignal) &&
process.env['QWEN_MANAGED_E2E_KEEP_TMP'] === '1'
) {
console.error(`Keeping temporary directory: ${temporary}`);
} else {
rmSync(temporary, { recursive: true, force: true });
@@ -2275,3 +2410,13 @@ try {
}
if (failure) throw failure;
// An interrupt after the last poll — or during the finally's teardown —
// must not print the success JSON and exit 0: a supervising script would
// record a clean pass for a run the operator explicitly stopped. The
// payload stays a variable until both guards have run, so the decision
// precedes the record.
if (receivedSignal) throw new Error(`Interrupted by ${receivedSignal}`);
if (resultJson !== undefined) {
console.log(resultJson);
if (fencedWriterJson !== undefined) console.log(fencedWriterJson);
}
+21
View File
@@ -1577,6 +1577,27 @@ describe('buildSpringRecipeLines', () => {
);
}
});
it('prints the README MySQL one-liner verbatim, grants included', () => {
const readme = readFileSync(
path.join(
root,
'packages',
'sdk-java',
'managed-agent-server',
'README.md',
),
'utf8',
);
const oneLiner = readme.match(/mysql -u root -e "[^"]+"/)?.[0];
expect(oneLiner).toBeTruthy();
const joined = buildSpringRecipeLines({
isWinPlatform: false,
springEnvPath: envPath,
springPs1Path: ps1Path,
}).join('\n');
expect(joined).toContain(oneLiner);
});
});
describe('script entry', () => {
+786 -14
View File
@@ -4,9 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { existsSync, readFileSync } from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { inspect } from 'node:util';
import {
createSourceFile,
isArrayLiteralExpression,
isCallExpression,
isFunctionDeclaration,
isVariableStatement,
ScriptTarget,
@@ -39,31 +51,101 @@ const fencedShellBlocks = (text) =>
].map((match) => match[1]);
describe('managed-agent-server e2e runner', () => {
it('keeps service and proxy ports distinct when an ephemeral port repeats', async () => {
const extracted = (names) => {
const source = createSourceFile(
'runner.ts',
read('scripts/run-managed-agent-server-e2e.ts'),
ScriptTarget.Latest,
true,
);
const allocation = source.statements
const text = source.statements
.filter(
(node) =>
(isFunctionDeclaration(node) &&
['freePort', 'startHeldExecutionStartProxy'].includes(
node.name?.text,
)) ||
(isFunctionDeclaration(node) && names.includes(node.name?.text)) ||
(isVariableStatement(node) &&
node.declarationList.declarations.some(
(declaration) =>
declaration.name.getText(source) === 'allocatedPorts',
node.declarationList.declarations.some((declaration) =>
names.includes(declaration.name.getText(source)),
)),
)
.map((node) => node.getText(source))
.join('\n');
const { outputText } = transpileModule(allocation, {
return transpileModule(text, {
compilerOptions: { target: ScriptTarget.ES2022 },
});
}).outputText;
};
// waitUntil calls childExited, so its extraction list must name the
// predicate: a free identifier in generated code resolves against the
// global scope, and the early-exit branch would die with a ReferenceError
// that points at the runner instead of at this harness's extraction list.
// One inventory with one loader — pasted copies drift, and the drift reds
// a test whose author did not touch it while the other copies stay
// silently stale.
const WAIT_UNTIL_DEPS = ['waitUntil', 'receivedSignal', 'childExited'];
const load = (names, returns, ...params) =>
new Function(...params, `${extracted(names)}\nreturn { ${returns} };`);
// The interrupt setter closes over the extracted body's own
// receivedSignal: without it the binding is permanently undefined and no
// test can reach the poll-head interrupt early-exit.
const loadWaitUntil = () =>
load(
WAIT_UNTIL_DEPS,
'waitUntil, interrupt: (signal) => { receivedSignal = signal }',
'inspect',
)(inspect);
// Source-level MySQL launch sites, selected on the launched binary
// rather than the callee or a hand-listed identifier: a launch through
// any spawner, with the binary spelled as a bare identifier or any quoted
// literal, is inventoried the same way. The binary names come from the
// runner's own command() resolutions, so a future mysql* binary joins the
// inventory with its declaration.
const mysqlCalls = (sourceText) => {
const binaries = new Set();
for (const match of sourceText.matchAll(
/const (\w+) = command\('([^']+)'\)/g,
)) {
if (match[2].startsWith('mysql')) {
binaries.add(match[1]).add(match[2]);
}
}
const ast = createSourceFile(
'runner.ts',
sourceText,
ScriptTarget.Latest,
true,
);
const calls = [];
const visit = (node) => {
if (isCallExpression(node) && node.arguments.length > 0) {
// command('mysql…') resolves a binary path; it is not a launch.
if (node.expression.getText(ast) === 'command') {
node.forEachChild(visit);
return;
}
// getText returns a string literal with its quotes, so compare the
// unquoted spelling: the quote style must not hide a launch.
const binary = node.arguments[0]
.getText(ast)
.replace(/^['"`]|['"`]$/g, '');
if (binaries.has(binary)) {
const args = node.arguments[1];
const firstArg =
args !== undefined &&
isArrayLiteralExpression(args) &&
args.elements.length > 0
? args.elements[0].getText(ast)
: undefined;
calls.push({ binary, text: node.getText(ast), firstArg });
}
}
node.forEachChild(visit);
};
visit(ast);
return { calls, binaries };
};
it('keeps service and proxy ports distinct when an ephemeral port repeats', async () => {
const sequence = [
33061, 33231, 36301, 36302, 36301, 36303, 38943, 36417, 36417, 36418,
36417, 36418, 36419,
@@ -84,9 +166,10 @@ describe('managed-agent-server e2e runner', () => {
done?.();
},
});
const { freePort, startHeldExecutionStartProxy } = new Function(
const { freePort, startHeldExecutionStartProxy } = load(
['freePort', 'startHeldExecutionStartProxy', 'allocatedPorts'],
'freePort, startHeldExecutionStartProxy',
'createServer',
`${outputText}\nreturn { freePort, startHeldExecutionStartProxy };`,
)(createServer);
const ports = [];
for (const count of [4, 3]) {
@@ -496,4 +579,693 @@ describe('managed-agent-server e2e runner', () => {
'`managed_session_store_conflict` fence is target design',
);
});
it('waitUntil surfaces the last predicate error', async () => {
const { waitUntil } = loadWaitUntil();
await expect(
waitUntil(
'probe',
() => Promise.reject(new Error('HTTP 503 wedged')),
300,
),
).rejects.toThrow(/wedged/);
});
// A non-Error rejection is recorded on every poll; rendering only Error
// instances would drop the one diagnostic the budget produced.
it('waitUntil surfaces a non-Error predicate rejection', async () => {
const { waitUntil } = loadWaitUntil();
// An object rejection discriminates inspect from String: the latter
// renders "[object Object]" and drops the one diagnostic the poll
// produced.
await expect(
waitUntil('probe', () => Promise.reject({ status: 503 }), 300),
).rejects.toThrow(/503/);
// The harness injects its own inspect as a parameter, so the runner's
// binding is pinned by text: without the import, the free identifier
// resolves to Node's deprecated global inspect in production.
expect(read('scripts/run-managed-agent-server-e2e.ts')).toMatch(
/import \{ inspect \} from 'node:util'/,
);
});
it('exercises the poll-head interrupt early-exit through the setter', async () => {
const { waitUntil, interrupt } = loadWaitUntil();
interrupt('SIGINT');
await expect(waitUntil('probe', () => false, 300)).rejects.toThrow(
/Interrupted by SIGINT/,
);
});
// Positive control: with no signal set the same poll fails by deadline,
// so the setter — not a spurious initial value — drives the exit above.
it('does not throw before the signal is set (positive control)', async () => {
const { waitUntil } = loadWaitUntil();
await expect(waitUntil('probe', () => false, 300)).rejects.toThrow(
/did not become ready/,
);
});
it('waitUntil bounds a stalled iteration by the deadline', async () => {
const { waitUntil } = loadWaitUntil();
const started = Date.now();
await expect(
waitUntil('probe', () => new Promise(() => {}), 300),
).rejects.toThrow(/predicate stalled/);
// A hung predicate used to outlive timeoutMs several-fold.
expect(Date.now() - started).toBeLessThan(2_000);
});
// The mirror of the stall bound: a poll already in flight at the deadline
// must be allowed to answer. The final iteration can start with a sliver
// of budget left, and a dependency that becomes ready inside that sliver
// is ready — discarding its answer misreports it as a wedged predicate.
it('waitUntil lets the final in-flight poll answer past the deadline', async () => {
const { waitUntil } = loadWaitUntil();
let polls = 0;
await expect(
waitUntil(
'probe',
async () => {
// Key the transition on the poll count, not the clock: a
// clock-keyed predicate must outwait one interval before it can
// answer true, leaving the loop's re-entry a ~30ms sliver that
// ordinary CI timer lateness eats. The instant first decline
// re-enters with ~150ms of budget, and this poll's fixed sleep
// still answers past the 250ms deadline.
if (polls++ === 0) return false;
await new Promise((resolve) => setTimeout(resolve, 300));
return true;
},
250,
),
).resolves.toBeUndefined();
});
it('waitUntil reports a child that exited early', async () => {
const { waitUntil } = loadWaitUntil();
await expect(
waitUntil('probe', () => new Promise(() => {}), 300, {
child: { exitCode: 1, signalCode: null },
log: () => '',
}),
).rejects.toThrow(/exited early/);
});
// A child killed BY signal (OOM-kill, segfault) keeps exitCode null and
// sets signalCode: an exitCode-only early-exit guard never trips, the
// poll burns its whole site budget, and the failure reads "did not
// become ready" — pointing at the dependency instead of the dead child.
it('waitUntil reports a child that died by signal as exited early', async () => {
const { waitUntil } = loadWaitUntil();
await expect(
waitUntil('probe', () => false, 300, {
child: { exitCode: null, signalCode: 'SIGKILL' },
log: () => '',
}),
).rejects.toThrow(/exited early/);
});
// A rejecting-then-hanging predicate must keep both facts: the deadline
// found the hang, so the older real error is named as predating the stall
// rather than posing as the state the deadline saw.
it('waitUntil keeps a real predicate error when a later iteration stalls', async () => {
const { waitUntil } = loadWaitUntil();
let calls = 0;
const failure = await waitUntil(
'probe',
() =>
calls++ === 0
? Promise.reject(
new Error('fetch failed', { cause: new Error('ECONNREFUSED') }),
)
: new Promise(() => {}),
300,
).catch((error) => error);
expect(failure.message).toContain(
'predicate stalled; last error before the stall: ECONNREFUSED',
);
});
// The stall wording is earned by a stalled final poll: a predicate that
// answers every poll with the same real error must not acquire it.
it('waitUntil names no stall when the error is the deadline state', async () => {
const { waitUntil } = loadWaitUntil();
const failure = await waitUntil(
'probe',
() =>
Promise.reject(
new Error('fetch failed', { cause: new Error('ECONNREFUSED') }),
),
300,
).catch((error) => error);
expect(failure.message).toContain('ECONNREFUSED');
expect(failure.message).not.toContain('predicate stalled');
});
// The mirror: once the predicate answers falsy, a connectivity error from
// an earlier phase no longer describes the state the deadline found.
it('waitUntil drops an error that later answered iterations supersede', async () => {
const { waitUntil } = loadWaitUntil();
let calls = 0;
const failure = await waitUntil(
'probe',
() =>
calls++ === 0
? Promise.reject(
new Error('fetch failed', { cause: new Error('ECONNREFUSED') }),
)
: false,
300,
).catch((error) => error);
expect(failure.message).toContain('did not become ready');
expect(failure.message).not.toContain('ECONNREFUSED');
});
// spawnSync blocks the event loop, so the stall race cannot bound a
// synchronous predicate: the remaining budget is handed to the predicate
// for its own probe timeout, and wall time must stay near the budget
// rather than near budget + a site-local probe timeout.
it('waitUntil threads the remaining budget into a synchronous predicate', async () => {
const { waitUntil } = loadWaitUntil();
const started = Date.now();
await expect(
waitUntil(
'probe',
(remainingMs) =>
spawnSync(process.execPath, ['-e', 'setTimeout(() => {}, 5_000)'], {
timeout: remainingMs,
}).status === 0,
300,
),
).rejects.toThrow(/did not become ready/);
expect(Date.now() - started).toBeLessThan(2_000);
});
it('crashProcess reports a by-signal exit instead of throwing ESRCH', async () => {
const { crashProcess } = load(
['crashProcess', 'childExited'],
'crashProcess',
'process',
)(process);
const child = spawn('node', [
'-e',
'setTimeout(() => process.kill(process.pid, "SIGKILL"), 20)',
]);
// Let the by-signal death land first: guard reads signalCode, not
// exitCode, and must refuse with the descriptive pre-exit diagnostic.
await new Promise((resolve) => child.once('exit', resolve));
await expect(crashProcess(child, 'probe')).rejects.toThrow(
/exited before the crash was injected/,
);
});
// The pre-crash guard is pinned above; the poll loop and the
// survived-SIGKILL throw must read a by-signal death too: an exitCode-only
// poll burns the 5 s deadline on a child SIGKILL already reaped, and an
// exitCode-only throw reports that dead child as having survived.
it('crashProcess returns promptly once its SIGKILL lands', async () => {
const { crashProcess } = load(
['crashProcess', 'childExited'],
'crashProcess',
'process',
)(process);
const child = spawn('node', ['-e', 'setTimeout(() => {}, 30_000)']);
const started = Date.now();
await expect(crashProcess(child, 'probe')).resolves.toBeUndefined();
expect(Date.now() - started).toBeLessThan(2_000);
});
// The success document must not reach stdout for a run the operator
// stopped: every payload is assembled inside the try and printed only
// after both post-finally throws, so the guard decides before the record
// exists. A print inside the try would leave the finally's teardown —
// seconds of stopChild awaits with the handlers still attached —
// unguarded.
it('checks the received signal at the success exit too', () => {
const source = read('scripts/run-managed-agent-server-e2e.ts');
// The base's eager prints were multi-line calls, so a single-line
// negative pin is true of base and head alike and can never fire: pin
// the deferred assignment on every mode branch, and that no success
// payload is printed eagerly. The freeze arm's wake record reads as a
// pass line on stdout too, so it defers the same way — no eager
// console.log of a JSON payload remains inside the try at all.
expect(source.match(/resultJson = JSON\.stringify\(/g)).toHaveLength(4);
expect(source.match(/fencedWriterJson = JSON\.stringify\(/g)).toHaveLength(
1,
);
expect(source).not.toMatch(/console\.log\(\s*JSON\.stringify\(/);
// No gate typechecks scripts/ (tsx strips types unchecked), so the
// module-scope binding the deferred print reads is pinned by text:
// dropped, the print below throws ReferenceError on the success path.
expect(source).toMatch(/^let resultJson: string \| undefined;$/m);
expect(source).toMatch(/^let fencedWriterJson: string \| undefined;$/m);
// The behavioural tests inject the signal through this harness's
// synthesized setter, so the production link — the runner's own
// handleSignal assigning the module-scope receivedSignal the guards
// below read — is pinned by text: registered with an empty body, the
// handlers swallow Ctrl-C and a CI cancellation, and the run prints
// the success payload and exits 0. Pin the process.on pair only; the
// finally removes both listeners during teardown.
expect(source).toMatch(
/const handleSignal = \(signal: NodeJS\.Signals\) => \{\s*receivedSignal = signal;\s*\};/,
);
expect(source).toMatch(/process\.on\('SIGINT', handleSignal\);/);
expect(source).toMatch(/process\.on\('SIGTERM', handleSignal\);/);
// An interrupted run is a non-pass: the finally's keep decision must
// still honor the keep switch, or a stopped run deletes its own
// evidence before the signal throw below reports it.
expect(source).toMatch(
/\(failure \|\| receivedSignal\) &&\s*process\.env\['QWEN_MANAGED_E2E_KEEP_TMP'\]/,
);
const failureThrow = source.indexOf('if (failure) throw failure;');
const signalThrow = source.indexOf(
'if (receivedSignal) throw new Error',
failureThrow,
);
const print = source.indexOf('console.log(resultJson)');
expect(failureThrow).toBeGreaterThan(-1);
expect(signalThrow).toBeGreaterThan(failureThrow);
expect(
print,
'the success payload must print only after both exit guards',
).toBeGreaterThan(signalThrow);
// The freeze arm's wake record follows the main result, restoring the
// base's ordering, and sits behind the same two guards.
const wakePrint = source.indexOf('console.log(fencedWriterJson)');
expect(
wakePrint,
'the freeze wake record must print only after the main result',
).toBeGreaterThan(print);
});
// A timed-out spawn sets error and leaves status null with empty stderr,
// so a status-only check throws "MySQL command failed: " and the one
// diagnostic a wedged durable-state dump exists to produce is lost.
it('runMysql surfaces the spawn-level reason on a timeout', () => {
const spawns = [];
const { runMysql } = load(
['runMysql'],
'runMysql',
'spawnSync',
'mysql',
'mysqlClientEnv',
)(
(...args) => {
spawns.push(args);
return {
status: null,
signal: 'SIGTERM',
stderr: '',
error: new Error('spawnSync mysql ETIMEDOUT'),
};
},
'mysql',
{ HOME: '/tmp/mysql-client-home' },
);
expect(() => runMysql(3306, 'SELECT 1')).toThrow(/ETIMEDOUT/);
// A final waitUntil poll can hand runMysql a remaining budget below one
// client round trip; the floor keeps the last probe possible.
expect(() => runMysql(3306, 'SELECT 1', 3)).toThrow(/ETIMEDOUT/);
expect(spawns[1][2].timeout).toBeGreaterThanOrEqual(2_000);
// The default-timeout call pins the opposite direction: a budget above
// the floor reaches spawnSync unchanged.
expect(spawns[0][2].timeout).toBe(10_000);
});
// --no-defaults does not disable $HOME/.mylogin.cnf, so a developer's
// mysql_config_editor credential would still auth-connect to the scratch
// empty-password server. Both client invocations — the mysql client in
// runMysql and the mysqladmin readiness probe — run against an isolated
// environment: an empty HOME under the runner's one scratch root (so the
// finally reclaims it) with MYSQL_PWD and MYSQL_TEST_LOGIN_FILE stripped;
// the two mysqld server launches deliberately keep the real HOME.
it('isolates the MySQL client HOME under the runner scratch root', () => {
const source = read('scripts/run-managed-agent-server-e2e.ts');
expect(source).toContain("path.join(temporary, 'mysql-client-home')");
// --no-defaults and the isolated HOME leave two credential sources
// open: MYSQL_PWD is read as the password, and MYSQL_TEST_LOGIN_FILE
// relocates .mylogin.cnf ahead of $HOME and past --no-defaults. The
// shared client environment must strip both, or an exported developer
// credential reaches the scratch empty-password server.
expect(source).toContain("delete mysqlClientEnv['MYSQL_PWD'];");
expect(source).toContain("delete mysqlClientEnv['MYSQL_TEST_LOGIN_FILE'];");
// Per call site, not a whole-file count: a count detects a removed
// override but stays green when a fifth client invocation without one is
// added. The mysqld server launches deliberately keep the real HOME, so
// the isolated override is required on the mysql/mysqladmin clients.
const { calls, binaries } = mysqlCalls(source);
// The exact inventory, not a lower bound — and the scan's binary set is
// the runner's own command() resolutions, pinned exactly — so a scan
// that silently stops seeing a call site or a declaration fails here
// instead of passing against a collapsed population.
expect([...binaries].sort()).toEqual(['mysql', 'mysqladmin', 'mysqld']);
expect(calls).toHaveLength(4);
for (const call of calls) {
// MySQL honors --no-defaults only as the first option, so pin the
// position: containment would survive the flag moving off index 0.
expect(
call.firstArg,
`${call.binary} must pass --no-defaults first`,
).toBe("'--no-defaults'");
if (call.binary === 'mysqld') continue;
expect(
call.text,
`${call.binary} must run with the isolated mysqlClientEnv`,
).toContain('env: mysqlClientEnv');
}
});
// The inventory promises quote-style blindness: TypeScript reports a
// no-substitution template literal with its backticks on, so the strip
// class must cover the third JS string spelling or a backticked launch
// is never inventoried.
it('inventories a MySQL launch spelled with any quote style', () => {
const { calls } = mysqlCalls(`
const mysql = command('mysql');
spawnSync('mysql', ['--no-defaults']);
spawnSync("mysql", ['--no-defaults']);
spawnSync(\`mysql\`, ['--no-defaults']);
`);
expect(calls).toHaveLength(3);
});
// spawnSync blocks the event loop, so waitUntil's race cannot bound a
// synchronous probe: the mysqladmin readiness probe carries its own
// timeout, and the two lease polls derive each call's timeout from the
// remaining waitUntil budget rather than a site-local constant that can
// overshoot the declared budget several-fold.
it('bounds every synchronous MySQL probe by the poll budget', () => {
const source = read('scripts/run-managed-agent-server-e2e.ts');
expect(source).toMatch(/spawnSync\(\s*mysqladmin,[\s\S]*?timeout: 10_000/);
// Pin the forwarded argument, not the parameter list: an arrow that
// takes remainingMs but never passes it on falls back to the 10 s
// default inside a budget that may be smaller.
expect(
source.match(/\(remainingMs\) =>\s*runMysql\([\s\S]*?remainingMs,/g),
'both lease polls must derive the probe timeout from the waitUntil budget',
).toHaveLength(2);
});
it('names the jar explicitly on both surfaces — pom-derived name in the runner, classifier exclusion at image build', () => {
const script = read('scripts/run-managed-agent-server-e2e.ts');
expect(script).not.toContain('qwen-managed-agent-server-0.1.0-alpha');
// The pom's repackage executions leave the unclassified jar plus one
// artifact per classifier in target/. The runner derives the
// unclassified name from the pom (one source of truth); the Dockerfile
// selects by classifier exclusion with a loud cardinality guard.
expect(script).toContain('qwen-managed-agent-server-${pomVersion}.jar');
expect(script).not.toContain('packagedJars');
const dockerfile = read(
'packages/sdk-java/managed-agent-server/Dockerfile',
);
expect(dockerfile).not.toContain('qwen-managed-agent-server-0.1.0-alpha');
expect(dockerfile).toContain('/tmp/qwen-managed-agent-server.jar');
// The exclusion set must cover every classifier the pom declares, or a
// pom edit surfaces as an operator's failed docker build ("found 2")
// instead of a red here: derive the set from the pom, the one source
// of truth, rather than pinning a literal copy of it.
const classifiers = [
...read('packages/sdk-java/managed-agent-server/pom.xml').matchAll(
/<classifier>([^<]+)<\/classifier>/g,
),
].map((match) => match[1]);
expect(classifiers.length).toBeGreaterThan(0);
for (const classifier of classifiers) {
expect(dockerfile).toContain(`*-${classifier}.jar`);
}
});
it('unrefs the waitUntil stall timer so a fast success does not idle the runner', () => {
const source = read('scripts/run-managed-agent-server-e2e.ts');
expect(source).toMatch(/stallTimer\.unref\(\)/);
});
it('resolves the project version from the real pom', () => {
// The runner's own regex executes against the real pom — lifted from
// the runner source, not copied — so a whitespace change between
// <artifactId> and <version> or a ${revision} indirection surfaces
// here, not at the next local run of the script. A routine version
// bump must stay green: pin the shape the runner depends on (the match
// anchored at the module's own artifactId and a concrete, non-property
// version), never today's value.
const script = read('scripts/run-managed-agent-server-e2e.ts');
const patternSource = script.match(
/pomXml\.match\(\s*(\/(?:\\.|[^\\/])*\/)/,
)?.[1];
expect(patternSource).toBeTruthy();
const pattern = new Function(`return ${patternSource};`)();
const match = read('packages/sdk-java/managed-agent-server/pom.xml').match(
pattern,
);
expect(match?.[0]).toContain(
'<artifactId>qwen-managed-agent-server</artifactId>',
);
expect(match?.[1]).toBeTruthy();
expect(match?.[1]).not.toContain('${');
// The descriptive throw stays part of the lookup: a pom whose version
// the regex cannot read must fail there, not at the jar existsSync.
expect(script).toContain('Could not read the project <version>');
// Whitespace inside the element (a formatter wrapping the value) must
// not flow into the jar path: the lifted pattern's capture is
// whitespace-tolerant, and an empty element still matches nothing, so
// the runner's descriptive guard fires.
const wrapped =
'<artifactId>qwen-managed-agent-server</artifactId>\n <version>\n 9.9.9\n </version>';
expect(wrapped.match(pattern)?.[1]).toBe('9.9.9');
expect(
'<artifactId>qwen-managed-agent-server</artifactId><version></version>'.match(
pattern,
),
).toBeNull();
});
it('keeps the image on the loopback default and the jar guard loud', () => {
const dockerfile = read(
'packages/sdk-java/managed-agent-server/Dockerfile',
);
// The module has no HTTP authentication, so the published image must
// not bind beyond loopback by default: publishing is the operator's
// explicit -e opt-in at docker run, documented in the README's
// container section. The cardinality guard stays loud, so the build
// fails rather than shipping a wrong or glob-stat jar.
// Dockerfile instructions are case-insensitive and tolerate leading
// whitespace, so anchor on the instruction rather than its
// conventional spelling: a lowercase or indented ENV would
// reintroduce the published wide bind with an uppercase-only pin
// green.
expect(dockerfile).not.toMatch(
/^[ \t]*ENV[ \t]+QWEN_MANAGED_AGENT_SERVER_ADDRESS/im,
);
expect(dockerfile).not.toMatch(
/^[ \t]*ENV[ \t]+QWEN_MANAGED_AGENT_RUNTIME_BROKER_HOST/im,
);
// Pin the bind default at the file that owns it, not only at one
// downstream file declining to override it: every Java test passes
// --server.address on the command line and the e2e runner passes none,
// so a flipped yml default would otherwise ship silently.
const applicationYml = read(
'packages/sdk-java/managed-agent-server/src/main/resources/application.yml',
);
expect(applicationYml).toMatch(
/address: '\$\{QWEN_MANAGED_AGENT_SERVER_ADDRESS:127\.0\.0\.1\}'/,
);
expect(dockerfile).toMatch(/\{ \[ "\$count" -eq 1 \] \|\|/);
expect(dockerfile).toContain(
'expected exactly one unclassified server jar',
);
// Host Maven output must not ride the build context into the stage the
// guard inspects: a stale jar from a developer's target/ would trip the
// cardinality check with an artifact this build did not produce. Both
// spellings — a bare `target` is root-anchored under Docker's pattern
// syntax.
const dockerignore = read('.dockerignore');
expect(dockerignore).toMatch(/^target$/m);
expect(dockerignore).toMatch(/^\*\*\/target$/m);
// The container section must document the opt-in and name the
// default-bridge exposure, or an operator who skips -p concludes the
// surface is closed.
const readme = read('packages/sdk-java/managed-agent-server/README.md');
expect(readme).toContain('-e QWEN_MANAGED_AGENT_SERVER_ADDRESS=0.0.0.0');
expect(readme).toMatch(/with no `-p` at all/);
// RuntimeBrokerHttpServer refuses a non-loopback broker bind unless the
// allow-non-loopback flag is set, so a documented broker wildcard opt-in
// must name the flag in the same block: the unguarded pair crash-loops
// the container the moment the broker is enabled.
for (const document of [dockerfile, readme]) {
const wildcard = document.indexOf(
'QWEN_MANAGED_AGENT_RUNTIME_BROKER_HOST=0.0.0.0',
);
if (wildcard === -1) continue;
const before = document.lastIndexOf('\n\n', wildcard);
const after = document.indexOf('\n\n', wildcard);
expect(
document.slice(
before === -1 ? 0 : before,
after === -1 ? undefined : after,
),
'a documented broker wildcard bind must name ALLOW_NON_LOOPBACK=true',
).toMatch(
/QWEN_MANAGED_AGENT_RUNTIME_BROKER_ALLOW_NON_LOOPBACK='?true'?/,
);
}
// BrokerSecurity refuses a non-loopback server.address under the
// shipped auto / allow-insecure-bind=false defaults, so a documented
// wildcard opt-in whose command names neither override crash-loops the
// container: every block setting SERVER_ADDRESS=0.0.0.0 must pass
// signed mode with its signing key or the explicit insecure override as
// -e flags — an override mentioned only in prose is not in the command
// an operator copies.
for (const document of [dockerfile, readme]) {
for (const occurrence of document.matchAll(
/QWEN_MANAGED_AGENT_SERVER_ADDRESS=0\.0\.0\.0/g,
)) {
const before = document.lastIndexOf('\n\n', occurrence.index);
const after = document.indexOf('\n\n', occurrence.index);
const block = document.slice(
before === -1 ? 0 : before,
after === -1 ? undefined : after,
);
// The signing key must be forwarded valueless: the = spelling
// leaves the HMAC secret in the docker run argv, readable by any
// local user from /proc/<pid>/cmdline for the container's
// lifetime.
const signed =
/-e QWEN_MANAGED_AGENT_AUTH_MODE='?signed'?/.test(block) &&
/-e QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY(?!=)/.test(block);
const insecure =
/-e QWEN_MANAGED_AGENT_AUTH_ALLOW_INSECURE_BIND='?true'?/.test(block);
expect(
signed || insecure,
'a documented server wildcard bind must pass -e AUTH_MODE=signed with a valueless signing key or -e AUTH_ALLOW_INSECURE_BIND=true',
).toBe(true);
// The datasource password is a credential of the same class as
// the signing key above: spelled with = it sits in the docker run
// argv, readable from /proc/<pid>/cmdline for the container's
// lifetime, so the recipe must forward it valueless.
expect(
block,
'a documented server wildcard bind must pass -e SPRING_DATASOURCE_PASSWORD valueless',
).toMatch(/-e SPRING_DATASOURCE_PASSWORD(?!=)/);
// Flyway runs against SPRING_DATASOURCE_URL at container start and
// the yml default's 127.0.0.1 is the container itself: a publish
// recipe that omits the datasource — or points it at loopback —
// passes the auth guard and then dies at the datasource with the
// published port refused. toContain stops at the flag name, so pin
// the loopback spellings away from the host; the <db-host>
// placeholder the recipes document stays valid.
expect(
block,
'a documented server wildcard bind must pass -e SPRING_DATASOURCE_URL= with a container-reachable host',
).toMatch(
/-e SPRING_DATASOURCE_URL='?jdbc:mysql:\/\/(?!127\.0\.0\.1|localhost)/,
);
// Docker drops a valueless -e whose variable is unset without a
// warning, so the recipe prose must have the operator export BOTH
// valueless variables: an instruction naming only the password
// boots the container with an empty signing key, and the JVM dies
// at BrokerSecurity's minimum-key guard with the published port
// refused. The Dockerfile's comment defers to the README's
// container section rather than repeating the precondition, so
// the pin is README-only.
if (document === readme) {
const preceding = document.slice(0, before === -1 ? 0 : before);
expect(
preceding,
'the prose ahead of a documented server wildcard bind must have the operator export both valueless variables',
).toMatch(
/export[\s\S]{0,160}?QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY[\s\S]{0,160}?SPRING_DATASOURCE_PASSWORD/,
);
}
}
}
});
// The jar-selection guard is executable shell, and the text pins above
// never look inside the loop body: deleting the glob break or forcing the
// count both left them green. Run the stage's own shell text against
// fixture target/ populations so the guard's behaviour is pinned, not its
// spelling.
// The fixture ends in `cp`, and a Git-Bash-only Windows PATH resolves
// sh.exe without the coreutils: probe the capability the fixture consumes
// and skip visibly — an in-body return would record the case as passed for
// a guard that never ran.
const hasShAndCp =
spawnSync('sh', ['-c', 'command -v cp >/dev/null 2>&1'], {
stdio: 'ignore',
}).status === 0;
it.skipIf(!hasShAndCp)(
'fails the image build loudly when the jar selection is ambiguous',
() => {
const dockerfile = read(
'packages/sdk-java/managed-agent-server/Dockerfile',
);
const stage = dockerfile.match(
/cd packages\/sdk-java\/managed-agent-server\/target \\[\s\S]*?&& cp "\$main" \/tmp\/qwen-managed-agent-server\.jar/,
);
expect(
stage,
'the jar-selection RUN stage must be extractable from the Dockerfile',
).not.toBeNull();
const script = stage[0]
.split('\n')
.filter((line) => !line.trimStart().startsWith('#'))
.map((line) => line.trimEnd().replace(/\\$/, ''))
.join(' ');
const populations = [
{ jars: [], status: 1, found: 0 },
{ jars: ['qwen-managed-agent-server-1.0.jar'], status: 0 },
{
jars: [
'qwen-managed-agent-server-1.0.jar',
'qwen-managed-agent-server-1.0-workspace-bundle.jar',
'qwen-managed-agent-server-1.0-operator-recovery.jar',
],
status: 0,
},
{
jars: [
'qwen-managed-agent-server-1.0.jar',
'qwen-managed-agent-server-1.0-workspace-bundle.jar',
'qwen-managed-agent-server-1.0-workspace-migration.jar',
'qwen-managed-agent-server-1.0-operator-recovery.jar',
],
status: 0,
},
{
jars: [
'qwen-managed-agent-server-1.0.jar',
'qwen-managed-agent-server-2.0.jar',
],
status: 1,
found: 2,
},
];
for (const { jars, status, found } of populations) {
const dir = mkdtempSync(join(tmpdir(), 'jar-guard-'));
try {
for (const jar of jars) writeFileSync(join(dir, jar), '');
const fixture = script
.replace(
'cd packages/sdk-java/managed-agent-server/target',
`cd '${dir.replaceAll('\\', '/')}'`,
)
.replace(
'/tmp/qwen-managed-agent-server.jar',
`'${join(dir, 'published.jar').replaceAll('\\', '/')}'`,
);
const run = spawnSync('sh', ['-c', fixture], { encoding: 'utf8' });
expect(run.status, `${jars.length} jars: ${run.stderr}`).toBe(status);
if (found !== undefined) {
expect(run.stderr).toContain(
`expected exactly one unclassified server jar, found ${found}`,
);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
},
);
});