mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-20 13:15:00 +00:00
* ci(sdk-java): run the Hosted failover arms after an unrelated Verify failure The 15 failover E2E steps had no if:, so any failing test in the Verify step skipped them all. Since #14181 merged, four consecutive main runs failed Verify on four different tests, and the seven G3 Step 3 arms have never executed in CI. The MySQL install step now runs whenever the Managed Agent dependencies installed, and first checks that the server jar was packaged; each arm runs when that step succeeded. A failing arm still fails the job. * ci(sdk-java): move the Hosted failover arms into their own job Conditions on the arm steps let them run after an integration-test failure, but they still waited behind the 80-minute Verify step and depended on its packaged jar. The fifteen arms, the MySQL binaries install, the runner-only QWEN_E2E_REGISTER_STORAGE and the Step 3 evidence upload now form hosted-failover-e2e, which bundles the CLI, installs the SDK and packages the server with -DskipTests itself, with no needs: on the Hosted MySQL job. Each arm still runs when an earlier one failed. The Hosted MySQL job ceiling drops from 325 to 175 minutes; the new job has 165. The pins move with the steps, the long-answer arm joins the arm table, the SpotBugs staging count goes to five, and D11, the Step 3 design, the server README and the runner's Linux-only hint name the new job. * docs(managed-agent): point the remaining failover-arm references at the new job The D11 Status header, the 275/290 ceiling of the Step 3 slice and the ubuntu-only lane note still named the Hosted MySQL job as where the failover arms run, and so did the G1 failover design, the Broker fault gates scope and the review-corrections gate list (whose Workspace file-tool Turn and durable-store adapter do stay in that job, so the sentence splits). Point-in-time notes keep their history and add where the arms run now. The Hosted MySQL report upload has to precede the two steps that run `clean verify` on the module, or it ships an empty artifact with only a warning; the test now pins that order. `--big-output` joins the mode flags each failover row must own alone.
1470 lines
64 KiB
JavaScript
1470 lines
64 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
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,
|
|
transpileModule,
|
|
} from 'typescript';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { QWEN_SERVER_TOKEN_ENV } from '../../packages/cli/src/serve/channel-worker-env.js';
|
|
import { HOSTED_HARNESS_CAPABILITY_DIGEST_ENV } from '../../packages/cli/src/serve/hosted-harness-contract.js';
|
|
import { validateHostedHarnessProfile } from '../../packages/cli/src/serve/hosted-harness-profile.js';
|
|
|
|
const read = (file) =>
|
|
readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8');
|
|
|
|
// Repo-root script mentions, bare or ./-prefixed. A package-relative tail such
|
|
// as packages/foo/scripts/bar.js is not a root mention, so the lookbehind
|
|
// still rejects a `scripts/` preceded by a path character.
|
|
const namedScripts = (text) => [
|
|
...new Set(
|
|
[...text.matchAll(/(?<![\w./-])(?:\.\/)?scripts\/[\w./-]*[\w-]/g)].map(
|
|
(match) => match[0].replace(/^\.\//, ''),
|
|
),
|
|
),
|
|
];
|
|
|
|
// Fenced shell blocks of a markdown text, bodies only; language-less fences
|
|
// are invisible to this scan.
|
|
const fencedShellBlocks = (text) =>
|
|
[
|
|
...text.matchAll(/```[ \t]*(?:bash|sh|shell|console|zsh)\n([\s\S]*?)```/g),
|
|
].map((match) => match[1]);
|
|
|
|
describe('managed-agent-server e2e runner', () => {
|
|
it('queries tables from the shared fresh-database baseline', () => {
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
const baseline = read(
|
|
'packages/managed-agent-server/src/main/resources/db/managed-schema/V1__managed_schema.sql',
|
|
);
|
|
const tables = new Set(
|
|
[...baseline.matchAll(/CREATE TABLE (\w+)/g)].map((match) => match[1]),
|
|
);
|
|
const queried = new Set(
|
|
[...source.matchAll(/qwen_managed_agent\.(\w+)/g)].map(
|
|
(match) => match[1],
|
|
),
|
|
);
|
|
expect(queried.size).toBeGreaterThan(0);
|
|
for (const table of queried) {
|
|
expect(
|
|
tables.has(table),
|
|
`E2E table ${table} is absent from canonical V1`,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
const extracted = (names) => {
|
|
const source = createSourceFile(
|
|
'runner.ts',
|
|
read('scripts/run-managed-agent-server-e2e.ts'),
|
|
ScriptTarget.Latest,
|
|
true,
|
|
);
|
|
const text = source.statements
|
|
.filter(
|
|
(node) =>
|
|
(isFunctionDeclaration(node) && names.includes(node.name?.text)) ||
|
|
(isVariableStatement(node) &&
|
|
node.declarationList.declarations.some((declaration) =>
|
|
names.includes(declaration.name.getText(source)),
|
|
)),
|
|
)
|
|
.map((node) => node.getText(source))
|
|
.join('\n');
|
|
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,
|
|
];
|
|
const createServer = () => ({
|
|
once() {},
|
|
off() {},
|
|
closeAllConnections() {},
|
|
listen(port, _host, ready) {
|
|
this.port = port || sequence.shift();
|
|
expect(this.port).toBeDefined();
|
|
ready();
|
|
},
|
|
address() {
|
|
return { port: this.port };
|
|
},
|
|
close(done) {
|
|
done?.();
|
|
},
|
|
});
|
|
const { freePort, startHeldExecutionStartProxy } = load(
|
|
['freePort', 'startHeldExecutionStartProxy', 'allocatedPorts'],
|
|
'freePort, startHeldExecutionStartProxy',
|
|
'createServer',
|
|
)(createServer);
|
|
const ports = [];
|
|
for (const count of [4, 3]) {
|
|
for (let index = 0; index < count; index++) ports.push(await freePort());
|
|
const proxy = await startHeldExecutionStartProxy('http://127.0.0.1:1');
|
|
ports.push(Number(new URL(proxy.baseUrl).port));
|
|
await proxy.close();
|
|
}
|
|
expect(new Set(ports).size).toBe(9);
|
|
});
|
|
|
|
// #12941: the Stage A acceptance criterion names a 15-second Runtime delay,
|
|
// but the ordering assertion was gated at 20 s, so a --runtime-delay-ms 15000
|
|
// run silently skipped it. Pin the threshold to the criterion's delay and
|
|
// the README's statement of the arming delay to the threshold.
|
|
it('arms the model-before-Runtime assertion at the criterion delay', () => {
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
expect(source).toContain('modelBeforeRuntimeAssertionDelayMs = 15_000');
|
|
expect(source).toContain(
|
|
'runtimeDelayMs >= modelBeforeRuntimeAssertionDelayMs',
|
|
);
|
|
const delaySeconds =
|
|
Number(
|
|
source
|
|
.match(/modelBeforeRuntimeAssertionDelayMs = (\d[\d_]*)/)[1]
|
|
.replace(/_/g, ''),
|
|
) / 1000;
|
|
expect(read('packages/managed-agent-server/README.md')).toContain(
|
|
`the ${delaySeconds} seconds the acceptance criterion`,
|
|
);
|
|
});
|
|
|
|
// When the assertion fires the operator must tell an ordering defect from
|
|
// provider latency, so the thrown message must carry the deciding sequence
|
|
// operands alongside the in-scope timings (observedAt is a poll-batch stamp,
|
|
// so the timings alone can be identical or argue against the verdict).
|
|
it('reports the ordering timings when the assertion fires', () => {
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
expect(source).toContain('firstModelSequence=${firstModel.event.sequence}');
|
|
expect(source).toContain(
|
|
'runtimeReadySequence=${runtimeReady.event.sequence}',
|
|
);
|
|
expect(source).toContain(
|
|
'firstModelEventMs=${firstModel.observedAt - requestStartedAt}',
|
|
);
|
|
expect(source).toContain(
|
|
'runtimeReadyMs=${runtimeReady.observedAt - requestStartedAt}',
|
|
);
|
|
expect(source).toContain('runtimeDelayMs=${runtimeDelayMs}');
|
|
});
|
|
|
|
// #12941: the README named scripts/run-managed-hosted-runtime-e2e.ts as the
|
|
// deterministic CI proof, a file that has never existed. Any script the
|
|
// README names must be real.
|
|
it('names only scripts that exist', () => {
|
|
const readme = read('packages/managed-agent-server/README.md');
|
|
expect(readme).not.toContain('run-managed-hosted-runtime-e2e');
|
|
for (const script of namedScripts(readme)) {
|
|
expect(
|
|
existsSync(new URL(`../../${script}`, import.meta.url)),
|
|
`${script} named in the managed-agent README does not exist`,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
// With the server defaults flipped on, dropping these pins would make the
|
|
// runner start the durable path off Linux and fail at server startup while
|
|
// `npm run test:scripts` stayed green: trusted recovery is pinned off at
|
|
// both Spring launch sites, and durable local process only ever follows the
|
|
// runtimeTakeover modes.
|
|
it('pins the runtime recovery flags for every runner mode', () => {
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
expect(source).toContain(
|
|
'const runtimeTakeover = inflightFailover || continuationFailover;',
|
|
);
|
|
expect(
|
|
source.match(
|
|
/if \(runtimeTakeover && !harnessOnly && process\.platform !== 'linux'\)/,
|
|
),
|
|
'only orphaned Runtime takeover modes must require Linux',
|
|
).not.toBeNull();
|
|
expect(
|
|
source.match(
|
|
/QWEN_MANAGED_AGENT_RUNTIME_TRUSTED_LOCAL_REBOOT_RECOVERY:\s*'false'/g,
|
|
),
|
|
'both Spring launch sites must pin trusted reboot recovery off',
|
|
).toHaveLength(2);
|
|
expect(
|
|
source.match(
|
|
/QWEN_MANAGED_AGENT_RUNTIME_DURABLE_LOCAL_PROCESS:\s*'false'/g,
|
|
),
|
|
'both non-runtimeTakeover branches must pin durable local process off',
|
|
).toHaveLength(2);
|
|
expect(
|
|
source.match(
|
|
/\.\.\.\(runtimeTakeover\s*\?\s*\{\s*QWEN_MANAGED_AGENT_RUNTIME_DURABLE_LOCAL_PROCESS:\s*process\.platform === 'linux' \? 'true' : 'false'/g,
|
|
),
|
|
'both durable local process branches must follow runtimeTakeover, Linux-gated',
|
|
).toHaveLength(2);
|
|
});
|
|
|
|
// The frozen control-plane arm proves #12952 Q2's binding half only if the
|
|
// woken former Spring really tried its stale claim: without the general-log
|
|
// witness every unchanged row would hold just as well after a wake that
|
|
// never reached a store call. A failed run must also wake the stopped JVM
|
|
// before teardown, or stopChild's SIGTERM stays pending on it.
|
|
it('keeps the frozen control-plane wake non-vacuous and woken at teardown', () => {
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
expect(source).toContain("SET GLOBAL general_log = 'ON'");
|
|
expect(source).toMatch(
|
|
/LOCATE\(\$\{sqlString\(formerDispatchOwner\)\}, CONVERT\(argument USING utf8mb4\)\)/,
|
|
);
|
|
// Only statements sent after the wake may count, or the frozen owner's
|
|
// pre-freeze renewals would satisfy the witness on their own.
|
|
expect(source).toMatch(
|
|
/FROM mysql\.general_log WHERE event_time >= \$\{sqlString\(wokeAt\)\}/,
|
|
);
|
|
const quiet = source.indexOf("'Replacement quiet before the wake'");
|
|
const wake = source.indexOf(
|
|
"signalProcessTree(child.child, 'SIGCONT');",
|
|
quiet,
|
|
);
|
|
const witness = source.indexOf(
|
|
'(remainingMs) => staleClaimUpdates(remainingMs) > 0',
|
|
);
|
|
const compare = source.indexOf('const afterWake = fencedState();');
|
|
expect(quiet).toBeGreaterThan(-1);
|
|
expect(
|
|
wake,
|
|
'the before-wake snapshot must settle before the former pair resumes',
|
|
).toBeGreaterThan(quiet);
|
|
expect(witness).toBeGreaterThan(wake);
|
|
expect(
|
|
compare,
|
|
'the fenced-state comparison must wait for the stale-claim witness',
|
|
).toBeGreaterThan(witness);
|
|
// The waits are awaited: a floating one would let the comparison run
|
|
// before anything it waits for was observed.
|
|
for (const label of [
|
|
'Replacement quiet before the wake',
|
|
'Woken control plane exercises its stale claim',
|
|
'Woken Harness meets the writer fence',
|
|
]) {
|
|
expect(source).toMatch(new RegExp(`await waitUntil\\(\\s*'${label}'`));
|
|
}
|
|
// A dead replacement would hold every snapshot still: each wait in the
|
|
// window watches the pair, and the verdict checks it once more.
|
|
expect(source).toContain(
|
|
'const owners = [replacementSpring, finishingHarness];',
|
|
);
|
|
for (const label of [
|
|
'Replacement quiet before the wake',
|
|
'Former control plane wake',
|
|
'Woken control plane exercises its stale claim',
|
|
'Woken Harness meets the writer fence',
|
|
]) {
|
|
const at = source.indexOf(`'${label}'`);
|
|
const end = source.indexOf('\n );', at);
|
|
expect(at, label).toBeGreaterThan(-1);
|
|
expect(source.slice(at, end), label).toMatch(/\n {10}owners,$/);
|
|
}
|
|
const ownersAlive = source.indexOf(
|
|
'for (const owner of owners) {\n if (childExited(owner.child)) {\n throw new Error(',
|
|
compare,
|
|
);
|
|
expect(ownersAlive).toBeGreaterThan(compare);
|
|
expect(ownersAlive).toBeLessThan(
|
|
source.indexOf('const moved = (', compare),
|
|
);
|
|
// The baseline the comparison uses is the snapshot the quiet poll
|
|
// settled on, taken before the wake.
|
|
const settle = source.indexOf('beforeWake = settled;', quiet);
|
|
expect(
|
|
settle,
|
|
'the snapshot the equality gate compares must be taken before the wake',
|
|
).toBeGreaterThan(quiet);
|
|
expect(settle).toBeLessThan(wake);
|
|
expect(
|
|
source.slice(wake, compare),
|
|
'the baseline must not be re-taken after the wake',
|
|
).not.toMatch(/\bbeforeWake\s*=/);
|
|
// The woken Harness needs its home, or the checks hold by absence.
|
|
const home = source.indexOf(
|
|
"'Frozen control-plane arm lost the Harness home the wake depends on'",
|
|
);
|
|
expect(home).toBeGreaterThan(quiet);
|
|
expect(home).toBeLessThan(wake);
|
|
// The writer axis: no refusal before the wake, one after it, and the
|
|
// comparison waits for it. The refusal text is the store's own.
|
|
const refusal = 'The Managed Session writer grant is stale or unavailable';
|
|
expect(source).toContain(`'${refusal}'`);
|
|
expect(
|
|
read(
|
|
'packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java',
|
|
),
|
|
).toContain(`"${refusal}."`);
|
|
const writerBefore = source.indexOf(
|
|
'if (harness.log().includes(staleWriterRefusal))',
|
|
);
|
|
// Counted on the output kept since the wake, not the rolling log tail.
|
|
expect(source).toContain(
|
|
'harnessOutputSinceWake.split(staleWriterRefusal).length - 1',
|
|
);
|
|
const writerWitness = source.indexOf('() => staleWriterRefusals() > 0');
|
|
expect(writerBefore).toBeGreaterThan(quiet);
|
|
expect(writerBefore).toBeLessThan(wake);
|
|
expect(writerWitness).toBeGreaterThan(wake);
|
|
expect(compare).toBeGreaterThan(writerWitness);
|
|
const teardown = source.lastIndexOf('} finally {');
|
|
const teardownWake = source.indexOf(
|
|
'for (const child of frozenChildren.splice(0))',
|
|
teardown,
|
|
);
|
|
const stop = source.indexOf('await stopChild(child.child);', teardown);
|
|
expect(teardownWake).toBeGreaterThan(teardown);
|
|
expect(stop).toBeGreaterThan(teardownWake);
|
|
});
|
|
|
|
// Every mode now runs through the G0 public Workspace admission, and the
|
|
// failover modes hand the same Session to a replacement owner: both Spring
|
|
// launch sites and both Harness launch sites must carry the admission
|
|
// wiring, or a mode turns red only after the failover kill with an error
|
|
// that reads like a takeover defect instead of a config asymmetry.
|
|
it('pins the G0 workspace admission at both launch sites', () => {
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
expect(source).toContain(
|
|
'const workspaceTurns = runtimeTakeover || bigOutput;',
|
|
);
|
|
expect(
|
|
source.match(
|
|
/\.\.\.\(workspaceTurns\s*\?\s*\{\s*workspace:\s*\{\s*workspace_id:\s*boundWorkspaceId\s*\}\s*\}/,
|
|
),
|
|
'every workspaceTurns mode must create a Workspace-bound Session',
|
|
).not.toBeNull();
|
|
expect(
|
|
source.match(
|
|
/directory === runtimeState && workspaceTurns\s*\?\s*\{\s*mode:\s*0o700\s*\}/,
|
|
),
|
|
'every workspaceTurns mode must keep Runtime state owner-only',
|
|
).not.toBeNull();
|
|
expect(
|
|
source.match(
|
|
/QWEN_MANAGED_AGENT_TRUSTED_ACTOR_HEADER: trustedActorHeader/g,
|
|
),
|
|
'both Spring launch sites must configure the trusted actor header',
|
|
).toHaveLength(2);
|
|
expect(
|
|
source.match(/QWEN_MANAGED_AGENT_WORKSPACE_FILES_ENABLED: 'true'/g),
|
|
'both Spring launch sites must enable Hosted Workspace files',
|
|
).toHaveLength(2);
|
|
expect(
|
|
source.match(/'--managed-runtime-broker-url'/g),
|
|
'both Harness launch sites must pass the Runtime Broker flags',
|
|
).toHaveLength(2);
|
|
// The mount argument is pushed once into the springArguments both Spring
|
|
// launch sites share; re-gating it would fail validateWorkspaceFiles at
|
|
// startup in every non-workspaceTurns mode while CI stayed green.
|
|
expect(
|
|
source.match(/workspace-mounts\[0\]\.root=/g),
|
|
'the shared Spring arguments must configure the Workspace mount',
|
|
).toHaveLength(1);
|
|
// The mount root itself must sit in the unconditional mkdir list: a
|
|
// re-gated entry still starts Spring (nothing checks the root exists)
|
|
// and only breaks the real-model side-effect assertion, which no lane
|
|
// runs.
|
|
expect(
|
|
source.match(/^\s+workspaceMount,$/m),
|
|
'the Workspace mount root must be created for every mode',
|
|
).not.toBeNull();
|
|
expect(
|
|
source.match(/INSERT INTO qwen_managed_agent\.ma_workspace\b/g),
|
|
'the Workspace registry row must be seeded for every mode',
|
|
).toHaveLength(1);
|
|
expect(
|
|
source.match(/INSERT INTO qwen_managed_agent\.ma_workspace_access/g),
|
|
'the Workspace access grant must be seeded for every mode',
|
|
).toHaveLength(1);
|
|
// The counts above cannot see WHERE an item sits: the runner before the
|
|
// admission alignment gated these same items inside workspaceTurns
|
|
// conditionals and satisfied every count. These negative pins are the
|
|
// symmetry witness. The windows stay short so the gates that must stay
|
|
// (durable local process, the Linux check, the 0700 state dir, the
|
|
// unbound create body) and comments mentioning either predicate do not
|
|
// trip them.
|
|
for (const predicate of ['workspaceTurns', 'runtimeTakeover']) {
|
|
for (const reGated of [
|
|
new RegExp(
|
|
`${predicate}[\\s\\S]{0,120}?QWEN_MANAGED_AGENT_TRUSTED_ACTOR_HEADER`,
|
|
),
|
|
new RegExp(
|
|
`${predicate}[\\s\\S]{0,120}?QWEN_MANAGED_AGENT_WORKSPACE_FILES_ENABLED`,
|
|
),
|
|
new RegExp(`${predicate}[\\s\\S]{0,120}?--managed-runtime-broker-url`),
|
|
new RegExp(`${predicate}[\\s\\S]{0,120}?workspaceMount`),
|
|
new RegExp(`if \\(${predicate}\\) \\{\\s*runMysql\\(`),
|
|
]) {
|
|
expect(
|
|
source.match(reGated),
|
|
`G0 admission re-gated behind ${predicate}: ${reGated}`,
|
|
).toBeNull();
|
|
}
|
|
}
|
|
});
|
|
|
|
// The README currently names no script, so only a fixture can pin the
|
|
// extractor itself: an extractor that stops matching must fail, not pass.
|
|
it('extracts the script spellings the README could use', () => {
|
|
expect(namedScripts('npx tsx ./scripts/nope.ts')).toEqual([
|
|
'scripts/nope.ts',
|
|
]);
|
|
expect(namedScripts('see `scripts/nope.ts`')).toEqual(['scripts/nope.ts']);
|
|
expect(namedScripts('packages/foo/scripts/nope.ts')).toEqual([]);
|
|
});
|
|
|
|
it('pins the fenced hosted-harness launch block in the server README to a startable form', () => {
|
|
// The oracle is the profile validator itself, not a copy of its rules:
|
|
// the documented launch must supply everything
|
|
// validateHostedHarnessProfile rejects for missing, or the launch fails
|
|
// at startup while this pin stays green. The block is matched exactly —
|
|
// a grammar that parses the fence into argv/env fails open on every
|
|
// shell spelling it does not model. The CLI-side credential names come
|
|
// from the production constants so renaming one reddens this pin instead
|
|
// of stranding the README's spelling.
|
|
const readme = read('packages/managed-agent-server/README.md');
|
|
const fencedBlocks = fencedShellBlocks(readme);
|
|
const command =
|
|
'qwen serve --profile hosted-harness --port 4171 --hostname 127.0.0.1 --no-web';
|
|
const launchBlocks = fencedBlocks.filter((block) =>
|
|
block.includes(command),
|
|
);
|
|
expect(
|
|
launchBlocks,
|
|
'the README must fence exactly one hosted-harness launch block',
|
|
).toHaveLength(1);
|
|
expect(launchBlocks[0]).toBe(
|
|
[
|
|
`${QWEN_SERVER_TOKEN_ENV}="$QWEN_MANAGED_AGENT_HARNESS_TOKEN" \\`,
|
|
`${HOSTED_HARNESS_CAPABILITY_DIGEST_ENV}="$QWEN_MANAGED_AGENT_CAPABILITY_DIGEST" \\`,
|
|
command,
|
|
].join('\n') + '\n',
|
|
);
|
|
// A `$VAR` reference defers to a name the reader was told to export in
|
|
// the Prerequisites section; a renamed or dropped export there expands
|
|
// to empty in the reader's shell and the launch dies at startup, so
|
|
// every name the launch block references must be assigned inside that
|
|
// section — an assignment in a fence anywhere else in the README never
|
|
// reaches the reader's shell.
|
|
const prereqStart = readme.indexOf('## Prerequisites');
|
|
const prereqEnd = readme.indexOf('## Public Session lifecycle');
|
|
expect(prereqStart).toBeGreaterThan(-1);
|
|
expect(prereqEnd).toBeGreaterThan(prereqStart);
|
|
const assigned = new Set();
|
|
for (const fenced of fencedShellBlocks(
|
|
readme.slice(prereqStart, prereqEnd),
|
|
)) {
|
|
for (const match of fenced.matchAll(
|
|
/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=/gm,
|
|
)) {
|
|
assigned.add(match[1]);
|
|
}
|
|
}
|
|
for (const match of launchBlocks[0].matchAll(
|
|
/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g,
|
|
)) {
|
|
expect(
|
|
assigned.has(match[1]),
|
|
`the launch block references $${match[1]}, which the Prerequisites section does not assign`,
|
|
).toBe(true);
|
|
}
|
|
// The validator's input is parsed from the pinned block so the fixture
|
|
// cannot drift from the documented launch: an edit that drops --no-web
|
|
// or widens --hostname must redden this oracle, not only the exact-text
|
|
// pin above. mode stays the constant serve.ts hardcodes — the launch
|
|
// carries no flag for it — and the deferred `$VAR` credentials stand in
|
|
// as conforming values so the validator judges the launch's shape rather
|
|
// than the placeholder spelling.
|
|
expect(() =>
|
|
validateHostedHarnessProfile({
|
|
profile: 'hosted-harness',
|
|
hostname: /--hostname\s+(\S+)/.exec(launchBlocks[0])[1],
|
|
port: Number(/--port\s+(\d+)/.exec(launchBlocks[0])[1]),
|
|
mode: 'http-bridge',
|
|
token: 'documented-value',
|
|
serveWebShell: !launchBlocks[0].includes('--no-web'),
|
|
hostedHarnessCapabilityDigest: `sha256:${'a'.repeat(64)}`,
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('pairs the 4171 base-url export with a startup-order note in the dual-path entry', () => {
|
|
// The base URL is read once at JVM startup, so the section that moves
|
|
// Spring to 4171 must say the value applies before (or via a restart
|
|
// of) `mvn spring-boot:run`, or the reader's running server stays on
|
|
// the 4170 value exported in Prerequisites.
|
|
const readme = read('packages/managed-agent-server/README.md');
|
|
const start = readme.indexOf(
|
|
'## Full WebShell dual-path development entry',
|
|
);
|
|
const end = readme.indexOf('## Embedded Runtime Broker');
|
|
expect(start).toBeGreaterThan(-1);
|
|
expect(end).toBeGreaterThan(start);
|
|
const slice = readme.slice(start, end);
|
|
// The export must point at the port the documented launch binds, not at
|
|
// a second copy of the number: a launch-block port bump mirrored into
|
|
// the launch pin above would otherwise leave this assertion green while
|
|
// Spring keeps calling the old port and every Managed Turn fails.
|
|
const launchBlocks = fencedShellBlocks(readme).filter((fenced) =>
|
|
fenced.includes('qwen serve --profile hosted-harness'),
|
|
);
|
|
expect(
|
|
launchBlocks,
|
|
'the README must fence exactly one hosted-harness launch block',
|
|
).toHaveLength(1);
|
|
const port = /--port\s+(\d+)/.exec(launchBlocks[0])[1];
|
|
expect(
|
|
slice,
|
|
'the Spring base URL must point at the port the launch block binds',
|
|
).toContain(
|
|
`QWEN_MANAGED_AGENT_HARNESS_BASE_URL='http://127.0.0.1:${port}'`,
|
|
);
|
|
// The caveat sentence is hard-wrapped in the README, so match across the
|
|
// line break; the fallback is the restart clause, not the bare word
|
|
// `restart` that any unrelated sentence in the slice could supply.
|
|
expect(slice.replace(/\s+/g, ' ')).toMatch(
|
|
/read once at JVM startup|restart it with the override/i,
|
|
);
|
|
// The by-hand recipe re-anchors Spring to the Prerequisites environment,
|
|
// which never names the Session Store, and application.yml defaults the
|
|
// store off — a reader who follows that path wires Spring without a
|
|
// store descriptor and the Harness answers every attach with
|
|
// 400 invalid_managed_session_store, so the slice must name the switch.
|
|
expect(slice).toContain('QWEN_MANAGED_AGENT_SESSION_STORE_ENABLED');
|
|
// The store switch is read once at JVM startup exactly like the base
|
|
// URL: exported below the restart cue it never reaches the reader's
|
|
// JVM, and every attach fails with 400 invalid_managed_session_store
|
|
// while the recipe reads complete — so the exports must land before the
|
|
// single restart. The cue is hard-wrapped, so compare on the collapsed
|
|
// slice; a missing cue fails closed through the -1.
|
|
const flat = slice.replace(/\s+/g, ' ');
|
|
expect(
|
|
flat.indexOf('QWEN_MANAGED_AGENT_SESSION_STORE_ENABLED'),
|
|
'the Session Store exports must precede the Spring restart cue',
|
|
).toBeLessThan(flat.indexOf('restart it with the override'));
|
|
});
|
|
|
|
it('keeps the review-corrections merge gates behind the CI-gated Hosted proofs', () => {
|
|
// The hosted-harness-mysql CI job runs HostedWorkspaceToolTurnIT (a real
|
|
// file-tool Turn through the packaged worker), fail-closed via the
|
|
// failsafe includes and check-failsafe-reports.js, and the
|
|
// hosted-failover-e2e job runs the three owner-failover E2E modes
|
|
// against the production HTTP durable-store adapter. While both
|
|
// oracles stand, the review-corrections gates must not re-assert those
|
|
// capabilities as unproven — the foundation-boundary banner names that
|
|
// document the current authority, so the false claim reaches
|
|
// integrators in either language.
|
|
const toolTurnIt =
|
|
'packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedWorkspaceToolTurnIT.java';
|
|
expect(
|
|
existsSync(new URL(`../../${toolTurnIt}`, import.meta.url)) &&
|
|
read('.github/workflows/sdk-java.yml').includes(
|
|
'test:e2e:managed-session-failover',
|
|
),
|
|
'the Hosted tool-turn IT and the failover E2E lane must both exist for this oracle to mean anything',
|
|
).toBe(true);
|
|
for (const [file, heading, claim] of [
|
|
[
|
|
'docs/design/2026-09-25-managed-agent-review-corrections.md',
|
|
'## Remaining integration gates',
|
|
/what remains unproven is[^.]*\./gi,
|
|
],
|
|
[
|
|
'docs/design/2026-09-25-managed-agent-review-corrections.zh-CN.md',
|
|
'## 剩余集成门禁',
|
|
/仍未证明[^。]*。/g,
|
|
],
|
|
]) {
|
|
const doc = read(file);
|
|
expect(doc, `${file} must keep its merge-gates section`).toContain(
|
|
heading,
|
|
);
|
|
const gates = doc.slice(doc.indexOf(heading));
|
|
for (const [sentence] of gates.matchAll(claim)) {
|
|
expect(
|
|
sentence,
|
|
`${file} lists CI-gated Hosted capabilities as unproven`,
|
|
).not.toMatch(
|
|
/tool turns|durable-store adapter|worker bundle|工具 Turn/i,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('pins the attach-time generation fence in the Harness attachment contract', () => {
|
|
// The attachment paragraph publishes which identities an attach does NOT
|
|
// compare. The Harness keys its in-memory Session by sessionId alone and
|
|
// compares no tenant on attach, so the paragraph must say exactly that —
|
|
// an integrator who reads a tenant-keyed coalescing claim leaves tenant
|
|
// scoping out of their own gateway on the recovery redrive, which is not
|
|
// fenced. The Harness writer generation is the one identity that IS
|
|
// enforced — the contract middleware answers a stale boot id with 409
|
|
// hosted_harness_generation_mismatch and the attach handler rejects a
|
|
// store descriptor whose writerId differs before any coalescing — so the
|
|
// paragraph must name that rejection: an integrator reading "not
|
|
// rejected" for the generation omits the 409 path and every attach fails
|
|
// after a Harness restart with no documented way out. Each fence pin
|
|
// below matches a polarity phrase, not the bare name: the name alone
|
|
// stays green when the paragraph says the generation is not rejected or
|
|
// the store fence is shipped behavior.
|
|
const readme = read('packages/managed-agent-server/README.md');
|
|
const anchor = readme.indexOf('The Java connector caches an attachment');
|
|
expect(anchor).toBeGreaterThan(-1);
|
|
const end = readme.indexOf('\n\n', anchor);
|
|
expect(end).toBeGreaterThan(anchor);
|
|
const paragraph = readme.slice(anchor, end).replace(/\s+/g, ' ');
|
|
expect(paragraph).toContain(
|
|
'keys its in-memory Session by `sessionId` alone',
|
|
);
|
|
expect(paragraph).toContain(
|
|
'fails closed with `409 hosted_harness_generation_mismatch`',
|
|
);
|
|
expect(paragraph).toContain(
|
|
'`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: () => '',
|
|
name: 'Only',
|
|
}),
|
|
).rejects.toThrow(/exited early: Only\n/);
|
|
});
|
|
|
|
// 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: () => '',
|
|
name: 'Only',
|
|
}),
|
|
).rejects.toThrow(/exited early: Only\n/);
|
|
});
|
|
|
|
// The model-round terminal wait watches the control plane and the Harness
|
|
// generation finishing the round; either one dying must end the wait and
|
|
// say which one died, and a deadline must still report both tails.
|
|
it('waitUntil watches every child it is given', async () => {
|
|
const { waitUntil } = loadWaitUntil();
|
|
const live = (name) => ({
|
|
child: { exitCode: null, signalCode: null },
|
|
log: () => `${name} log`,
|
|
name,
|
|
});
|
|
const dead = (name) => ({
|
|
child: { exitCode: 1, signalCode: null },
|
|
log: () => `${name} log`,
|
|
name,
|
|
});
|
|
await expect(
|
|
waitUntil('probe', () => false, 300, [dead('First'), live('Second')]),
|
|
).rejects.toThrow(/exited early: First\nFirst log/);
|
|
await expect(
|
|
waitUntil('probe', () => false, 300, [live('First'), dead('Second')]),
|
|
).rejects.toThrow(/exited early: Second\nSecond log/);
|
|
await expect(
|
|
waitUntil('probe', () => false, 300, [live('First'), live('Second')]),
|
|
).rejects.toThrow(/did not become ready\nFirst log\nSecond log$/);
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
expect(source).toMatch(
|
|
/waitForTerminal\(\s*replacementSpringUrl,\s*tenant,\s*session\.id,\s*0,\s*\[replacementSpring, finishingHarness\],/,
|
|
);
|
|
});
|
|
|
|
// 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(5);
|
|
expect(
|
|
source.match(/approvalResultJson = JSON\.stringify\(/g),
|
|
).toHaveLength(1);
|
|
expect(source.match(/g3SummaryJson = JSON\.stringify\(/g)).toHaveLength(1);
|
|
expect(source.match(/fencedWriterJson = JSON\.stringify\(/g)).toHaveLength(
|
|
2,
|
|
);
|
|
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 approvalResultJson: string \| undefined;$/m);
|
|
expect(source).toMatch(/^let g3SummaryJson: 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);
|
|
const approvalPrint = source.indexOf('console.log(approvalResultJson)');
|
|
const summaryPrint = source.indexOf('console.log(g3SummaryJson)');
|
|
expect(approvalPrint).toBeGreaterThan(signalThrow);
|
|
expect(summaryPrint).toBeGreaterThan(approvalPrint);
|
|
expect(summaryPrint).toBeLessThan(print);
|
|
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 lease polls (writer and dispatch lease expiry, and the
|
|
// model-round arm's second writer lease) 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),
|
|
'every lease poll must derive the probe timeout from the waitUntil budget',
|
|
).toHaveLength(3);
|
|
// The frozen arm's snapshot fans one budget out over several
|
|
// synchronous probes; each takes its share, not the whole budget.
|
|
const fenced = source.slice(
|
|
source.indexOf('const fencedState = ('),
|
|
source.indexOf('// The replacement must go quiet first'),
|
|
);
|
|
const probes = Number(source.match(/const fencedProbes = (\d+);/)?.[1]);
|
|
expect(fenced).toContain(
|
|
'const probeMs = Math.max(1, Math.floor(timeoutMs / fencedProbes));',
|
|
);
|
|
expect(fenced.match(/runMysql\(/g)).toHaveLength(probes);
|
|
expect(fenced.match(/^\s*probeMs,$/gm)).toHaveLength(probes);
|
|
expect(fenced).not.toMatch(/^\s*timeoutMs,$/m);
|
|
});
|
|
|
|
// The model-round arm samples the head once per generation: each
|
|
// takeover must advance the writer, the journal and the committed
|
|
// sequence, not the writer generation alone.
|
|
it('requires every head column to advance across the model-round takeovers', () => {
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
expect(source).toMatch(
|
|
/!\[0, 1, 2\]\.every\(\s*\(column\) => head\[column\] > heads\[index - 1\]\[column\],?\s*\)/,
|
|
);
|
|
expect(source).toMatch(
|
|
/const currentHead = \(\) =>\s*runMysql\(\s*mysqlPort,\s*`SELECT writer_generation, journal_revision, committed_sequence FROM/,
|
|
);
|
|
});
|
|
|
|
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/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/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/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/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/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/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/managed-agent-server/Dockerfile');
|
|
const stage = dockerfile.match(
|
|
/cd packages\/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/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 });
|
|
}
|
|
}
|
|
},
|
|
);
|
|
});
|