Files
qwen-code/scripts/tests/package-scripts.test.js
T
8290c81eea feat(qwen-live): standalone voice daemon package — M1 + M2 (#10367)
* feat(qwen-live): scaffold standalone live daemon package with ported host stack

New workspace package @qwen-code/qwen-live (M1+M2 of the Live split,
issue #10118). This commit adds the package skeleton and the verbatim
ports that need no behavioral changes:

- host/: Live Host protocol v6 types, LiveHostCoordinator, and the
  discovery-file writer, ported from packages/cli/src/serve/live/
  together with their test suites (51 tests green)
- realtime/socket.ts, realtime/sanitize.ts: the two helper symbols the
  realtime client depends on, extracted from cli ui utils
- registered in scripts/build.js buildOrder and root vitest projects

* feat(qwen-live): generalize the realtime client tool surface

Port qwen-realtime-session.ts and adapt it for the orchestration model:
tools and instructions come from config, every declared tool dispatches
through onFunctionCall with the raw argument payload, and outputs are
receipt-style via submitFunctionOutput. The single-handoff machinery
(multiple_handoffs error, steering acknowledgement, open-call complete)
is removed; transport, response arbitration, pendingCalls timing,
barge-in, and defensive validation are unchanged from the port.
capturesTranscript marks handoff-style tools that capture the live
transcript tail at call time.

Test suite adapted: 27 tests green.

* feat(qwen-live): add the BackendAdaptor seam and the qwen serve adaptor

BackendAdaptor is the capability-bit-driven port between the live
orchestrator and a coding-agent backend. QwenCodeAdaptor drives a
running qwen serve daemon through the @qwen-code/sdk DaemonClient:
202-admission prompts, SSE event normalization (chunks and tool calls
live under session_update.data.update), reject-if-idle mid-turn
steering with a plain-prompt fallback, nested permission votes with
voter attribution, and attachment-backed image blocks. 23 unit tests
against an injected fake client.

* feat(qwen-live): orchestrator, tools, injector, permissions, and daemon entry

The M1+M2 core:
- seven receipt-style dispatch tools plus remain_silent, with a
  dispatcher that answers every call (timeout fallback included) and
  session_N/job_N/asset_N handle registries
- LiveSession wires Host audio, the realtime connection, tool handlers
  (appshot via the Host capture channel, handoff with voice-context
  packing and image attachments, busy-session steering), backend event
  pumps, and a bounded graceful-stop drain
- Injector: safe-insertion window (VAD + response-in-flight + playback
  estimate at 48 kB/s with a quiet gap), batch aggregation, per-job
  progress throttling, barge-in drops progress, spoken/detail split
- PermissionBroker: allow_always stays a one-shot protocol vote backed
  by a local TTL rule; remote resolutions retract queued asks
- LiveDaemon: Host WS endpoint (Bearer + Origin-reject), discovery
  publish with fail-fast mutual exclusion and dead-owner handoff,
  appshot channel marked ready (the in-process capture path needs no
  separate verification), SIGINT/SIGTERM shutdown
- SessionLog: JSONL with synchronous rotation (rename-before-reopen)

116 new unit tests; package total 167 green.

* feat(qwen-live): end-to-end test suites against a real qwen serve daemon

Test stack: a real qwen serve (spawned CLI bundle, fake-openai model
endpoint), a scriptable fake DashScope realtime WebSocket server, the
qwen-live daemon as a child process, and a protocol-v6 FakeHost client
— every boundary is the real wire.

Scenarios (11 tests): discovery publish/remove and hello/welcome,
toggle brings up the realtime session (tool schema + instructions
asserted on session.update), two-way audio byte fidelity, the full
handoff loop against real serve with [COMPLETE] injection and spoken
summary, injection-window hold/batch semantics, permission ask/vote
relay, and busy-session steering tiers.

The suites caught a real bug the unit layer could not: per-session
calls must echo the daemon-issued clientId from the create/attach
response (a self-made id is rejected by the client registration guard
and broke handoff admission and vote attribution). QwenCodeAdaptor now
tracks and echoes the issued id per session.

* chore(release): keep @qwen-code/qwen-live versioned independently

Same treatment as sdk/mobile-mcp/node-repl: the incubating package
stays at its own 0.x version instead of lock-stepping with the CLI
release train. The verbatim guard in package-scripts.test.js is
updated alongside.

* fix(qwen-live): harden review findings across dispatcher, injector, and permission relay

Self-review fixes bundled after the adversarial pass:

- adaptor: classify snake_case permission option ids (proceed_once) —
  the word-boundary regex never matched through "_", so an allow vote
  silently degraded to a cancelled outcome and serve dropped the turn
- adaptor: detect the pending-prompt limit by error name as well as
  status, and fall image-bearing steers back to a regular prompt
- dispatcher: a timed-out tool now answers with a pending receipt that
  tells the model not to retry (the handler keeps running)
- injector: permission items flush ahead of the size-capped context
  join; spoken lines merge whole-line greedily under the 280-char cap
- live-session: resubscribe the event pump with backoff until
  session_closed; a rejected permission chain can no longer become an
  unhandled rejection; receipts are clamped and never empty
- permission broker: a failed auto-approval falls back to the spoken
  ask instead of losing the request
- daemon/config/index: appshot readiness is declared at construction,
  numeric config ports parse, stray rejections are logged not fatal
- e2e harness: QWEN_LIVE_E2E_KEEP=1 keeps the temp dirs for post-mortem

* fix(qwen-live): address review round 1 — permission voting, shutdown, discovery reclaim, and hardening

Permission trust boundary:
- classify options by the wire's structured kind enum (allow_once/
  allow_always/reject_*) instead of word regexes over snake_case ids,
  and vote the least-escalating match — a spoken "allow" now lands on
  proceed_once, never proceed_always_project
- respond_permission's note is no longer discarded: it is logged and
  relayed to the backend session as a follow-up user instruction
- track own votes per session so byUs attribution works on adopted
  sessions; narrow allow_always standing rules to the exact normalized
  command instead of a two-token prefix

Lifecycle:
- daemon stop() no longer hangs when SIGINT lands before listen() or
  when a Host redials during shutdown (stopping gate + client terminate)
- dead-owner discovery reclaim now follows the cli contract (no-op
  commitOwner during handoff, republish after) — previously it could
  never succeed and a crash left the daemon unable to start
- stop drain settles on the input-commit ack instead of burning the
  full 30s budget; response.created during the drain no longer flips
  the call back to speaking; a host-initiated start can no longer race
  deactivate()

Robustness:
- a late ASR final for an already-consumed input item is ignored as
  stale_input instead of dropping the call
- raise the Host frame cap to cover a conformant CJK accessibility
  dump (64KiB frame cap contradicted the 32k-char field cap)
- bin entry works through npm's bin symlink (realpath + pathToFileURL)
- steered handoffs that join a running turn reuse the existing job
  instead of orphaning it; over-long steers fall back to a queued
  prompt; session_list unions created cwds, requests a full page, and
  maps hasActivePrompt for untracked sessions; created-session labels
  are applied via session metadata
- injector: jobless throttle keys use the full context, dedup is
  symmetric, and barge-in drops clear the throttle stamp; sentence
  shapers handle CJK punctuation and mid-token periods
- config loader: ENOENT-only swallow, BOM strip, ~ expansion, typed
  port validation, source-accurate errors; session log close() is
  terminal; transcript retention is capped

Tests and docs: new config/daemon/sanitize suites, win32 guards
restored on discovery mode assertions, protocol parity test now checks
both type copies, qwen-live e2e suites join the no-AK PR gate, fake
model gating in the inject e2e matches only the current turn and is
consumed on match, FakeHost answers capture requests, lockfile diff
reduced to qwen-live-only entries, README states the installer gap.

* test(qwen-live): pin injector size caps and steered-message delivery

- injector: 6000-char combined-context cap, whole-line spoken join at
  the 280-char boundary, single-line ellipsis truncation
- steering e2e: assert the joined instruction actually reached the
  model endpoint, not just the receipt wording

* fix(sdk): raise daemon browser bundle budget

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

* feat(qwen-live): scaffold standalone live daemon package with ported host stack

New workspace package @qwen-code/qwen-live (M1+M2 of the Live split,
issue #10118). This commit adds the package skeleton and the verbatim
ports that need no behavioral changes:

- host/: Live Host protocol v6 types, LiveHostCoordinator, and the
  discovery-file writer, ported from packages/cli/src/serve/live/
  together with their test suites (51 tests green)
- realtime/socket.ts, realtime/sanitize.ts: the two helper symbols the
  realtime client depends on, extracted from cli ui utils
- registered in scripts/build.js buildOrder and root vitest projects

* feat(qwen-live): generalize the realtime client tool surface

Port qwen-realtime-session.ts and adapt it for the orchestration model:
tools and instructions come from config, every declared tool dispatches
through onFunctionCall with the raw argument payload, and outputs are
receipt-style via submitFunctionOutput. The single-handoff machinery
(multiple_handoffs error, steering acknowledgement, open-call complete)
is removed; transport, response arbitration, pendingCalls timing,
barge-in, and defensive validation are unchanged from the port.
capturesTranscript marks handoff-style tools that capture the live
transcript tail at call time.

Test suite adapted: 27 tests green.

* feat(qwen-live): add the BackendAdaptor seam and the qwen serve adaptor

BackendAdaptor is the capability-bit-driven port between the live
orchestrator and a coding-agent backend. QwenCodeAdaptor drives a
running qwen serve daemon through the @qwen-code/sdk DaemonClient:
202-admission prompts, SSE event normalization (chunks and tool calls
live under session_update.data.update), reject-if-idle mid-turn
steering with a plain-prompt fallback, nested permission votes with
voter attribution, and attachment-backed image blocks. 23 unit tests
against an injected fake client.

* feat(qwen-live): orchestrator, tools, injector, permissions, and daemon entry

The M1+M2 core:
- seven receipt-style dispatch tools plus remain_silent, with a
  dispatcher that answers every call (timeout fallback included) and
  session_N/job_N/asset_N handle registries
- LiveSession wires Host audio, the realtime connection, tool handlers
  (appshot via the Host capture channel, handoff with voice-context
  packing and image attachments, busy-session steering), backend event
  pumps, and a bounded graceful-stop drain
- Injector: safe-insertion window (VAD + response-in-flight + playback
  estimate at 48 kB/s with a quiet gap), batch aggregation, per-job
  progress throttling, barge-in drops progress, spoken/detail split
- PermissionBroker: allow_always stays a one-shot protocol vote backed
  by a local TTL rule; remote resolutions retract queued asks
- LiveDaemon: Host WS endpoint (Bearer + Origin-reject), discovery
  publish with fail-fast mutual exclusion and dead-owner handoff,
  appshot channel marked ready (the in-process capture path needs no
  separate verification), SIGINT/SIGTERM shutdown
- SessionLog: JSONL with synchronous rotation (rename-before-reopen)

116 new unit tests; package total 167 green.

* feat(qwen-live): end-to-end test suites against a real qwen serve daemon

Test stack: a real qwen serve (spawned CLI bundle, fake-openai model
endpoint), a scriptable fake DashScope realtime WebSocket server, the
qwen-live daemon as a child process, and a protocol-v6 FakeHost client
— every boundary is the real wire.

Scenarios (11 tests): discovery publish/remove and hello/welcome,
toggle brings up the realtime session (tool schema + instructions
asserted on session.update), two-way audio byte fidelity, the full
handoff loop against real serve with [COMPLETE] injection and spoken
summary, injection-window hold/batch semantics, permission ask/vote
relay, and busy-session steering tiers.

The suites caught a real bug the unit layer could not: per-session
calls must echo the daemon-issued clientId from the create/attach
response (a self-made id is rejected by the client registration guard
and broke handoff admission and vote attribution). QwenCodeAdaptor now
tracks and echoes the issued id per session.

* chore(release): keep @qwen-code/qwen-live versioned independently

Same treatment as sdk/mobile-mcp/node-repl: the incubating package
stays at its own 0.x version instead of lock-stepping with the CLI
release train. The verbatim guard in package-scripts.test.js is
updated alongside.

* fix(qwen-live): harden review findings across dispatcher, injector, and permission relay

Self-review fixes bundled after the adversarial pass:

- adaptor: classify snake_case permission option ids (proceed_once) —
  the word-boundary regex never matched through "_", so an allow vote
  silently degraded to a cancelled outcome and serve dropped the turn
- adaptor: detect the pending-prompt limit by error name as well as
  status, and fall image-bearing steers back to a regular prompt
- dispatcher: a timed-out tool now answers with a pending receipt that
  tells the model not to retry (the handler keeps running)
- injector: permission items flush ahead of the size-capped context
  join; spoken lines merge whole-line greedily under the 280-char cap
- live-session: resubscribe the event pump with backoff until
  session_closed; a rejected permission chain can no longer become an
  unhandled rejection; receipts are clamped and never empty
- permission broker: a failed auto-approval falls back to the spoken
  ask instead of losing the request
- daemon/config/index: appshot readiness is declared at construction,
  numeric config ports parse, stray rejections are logged not fatal
- e2e harness: QWEN_LIVE_E2E_KEEP=1 keeps the temp dirs for post-mortem

* fix(qwen-live): address review round 1 — permission voting, shutdown, discovery reclaim, and hardening

Permission trust boundary:
- classify options by the wire's structured kind enum (allow_once/
  allow_always/reject_*) instead of word regexes over snake_case ids,
  and vote the least-escalating match — a spoken "allow" now lands on
  proceed_once, never proceed_always_project
- respond_permission's note is no longer discarded: it is logged and
  relayed to the backend session as a follow-up user instruction
- track own votes per session so byUs attribution works on adopted
  sessions; narrow allow_always standing rules to the exact normalized
  command instead of a two-token prefix

Lifecycle:
- daemon stop() no longer hangs when SIGINT lands before listen() or
  when a Host redials during shutdown (stopping gate + client terminate)
- dead-owner discovery reclaim now follows the cli contract (no-op
  commitOwner during handoff, republish after) — previously it could
  never succeed and a crash left the daemon unable to start
- stop drain settles on the input-commit ack instead of burning the
  full 30s budget; response.created during the drain no longer flips
  the call back to speaking; a host-initiated start can no longer race
  deactivate()

Robustness:
- a late ASR final for an already-consumed input item is ignored as
  stale_input instead of dropping the call
- raise the Host frame cap to cover a conformant CJK accessibility
  dump (64KiB frame cap contradicted the 32k-char field cap)
- bin entry works through npm's bin symlink (realpath + pathToFileURL)
- steered handoffs that join a running turn reuse the existing job
  instead of orphaning it; over-long steers fall back to a queued
  prompt; session_list unions created cwds, requests a full page, and
  maps hasActivePrompt for untracked sessions; created-session labels
  are applied via session metadata
- injector: jobless throttle keys use the full context, dedup is
  symmetric, and barge-in drops clear the throttle stamp; sentence
  shapers handle CJK punctuation and mid-token periods
- config loader: ENOENT-only swallow, BOM strip, ~ expansion, typed
  port validation, source-accurate errors; session log close() is
  terminal; transcript retention is capped

Tests and docs: new config/daemon/sanitize suites, win32 guards
restored on discovery mode assertions, protocol parity test now checks
both type copies, qwen-live e2e suites join the no-AK PR gate, fake
model gating in the inject e2e matches only the current turn and is
consumed on match, FakeHost answers capture requests, lockfile diff
reduced to qwen-live-only entries, README states the installer gap.

* test(qwen-live): pin injector size caps and steered-message delivery

- injector: 6000-char combined-context cap, whole-line spoken join at
  the 280-char boundary, single-line ellipsis truncation
- steering e2e: assert the joined instruction actually reached the
  model endpoint, not just the receipt wording

* feat(qwen-live): port the Live Host installer — M1 gap closed

`live-host-installer.ts` moves verbatim from
packages/cli/src/serve/live/ (same OSS/GitHub dual-source manifest
fetch, sha256 + codesign + team-identifier verification, staging +
backup install with rollback, and the retryable status machine), with
its 11-test suite. The daemon gains the matching HTTP surface:

- GET /live/setup — installed Host state
- POST /live/setup/install — download + verify + install the latest
  POST /live/setup/launch — open the installed app

All three sit behind the daemon's existing Bearer wall (a browser
context sending Origin is always refused, wrong token 401) — pinned by
a new daemon test; unknown paths stay 404 and GET /healthz answers
plain 200. Bootstrap no longer depends on the built-in qwen-code Live
integration having installed the Host first.

* fix(qwen-live): attach the appshot-timeout rejection handler before advancing fake timers

The timeout rejects synchronously inside advanceTimersByTimeAsync; a
handler attached only afterwards leaves an unhandled rejection for an
instant, which vitest counts as a run-level error (CI fails the
package with Errors: 1 even though all 250 tests pass). Attach the
expectation first, await it after the clock advances.

* test(qwen-live): sync the no-AK gate pin with the qwen-live suites

The no-ak-integration-ci pin (added upstream after our branch point)
asserts the gate's file list verbatim; add the four qwen-live suites
the gate has been running since they joined it.

* fix(qwen-live): address review round 2 — trust-boundary keys, backpressure, session lifecycle

Criticals (all probed and reproduced before fixing):

- broker titleKeyOf: split on the FIRST colon and keep the entire
  remainder (commands routinely contain colons — a truncated key
  auto-approved a different -m message), and stop case-folding
  (/a vs /A are different paths on Linux). The whitespace-collapse
  pin is preserved
- broker deny: an explicit spoken refusal now revokes the standing
  rule that matched the same request — the silent auto-answer's
  fallback path could otherwise be overridden by the invisible rule
  on the next identical request
- adaptor activeJobRef: a busy-session fallback (image steer, over-
  long text 400) no longer overwrites the running turn's ref with the
  queued prompt's id — later steer receipts report the right job
- adaptor byUs attribution: the daemon's originator stamp is now
  authoritative when present; ownVotes only covers anonymous
  resolutions. Another client resolving during our vote round-trip no
  longer reads as our own answer (R1-2 regression, fixed test-first)
- adaptor permission titles: control sequences stripped before the
  title reaches speech or keys a standing rule (whole command kept —
  no first-line cut, unlike the progress path)
- live-session pushAudio: the provider's backpressure signal is
  propagated (the port source fails the call) and onAudioDropped is
  wired to failCall — a socket over its buffer cap no longer silently
  garbles the utterance; the deliberate true for stale/connecting
  frames is untouched
- handles: closed sessions stop resolving entirely (registry-level),
  and session_closed clears the call's default handle so a session-
  less handoff rebuilds instead of retrying a dead target forever
- handles: read-site reconciliation — session_list and session_monitor
  transition a session's stale non-terminal jobs to done when the
  backend itself reports idle (a turn_complete emitted while no pump
  was subscribed no longer reports a running active_job forever)

Suggestions on this PR's new surfaces:

- live-host.yml: packages/qwen-live/src/host/** joins the path filter
  so the parity-test lane runs on qwen-live-only PRs instead of first
  failing in the merge queue
- installer: the install route passes force=true (the recovery path
  for a corrupted-but-present app, matching the CLI mirror), and
  launch() gains the non-darwin guard in BOTH copies so the byte
  parity pin stays green
- index: directory-form invocation (node packages/qwen-live) now
  runs main() instead of exiting 0 silently
- realtime: the dead lastHandoffEntryCount machinery is removed
  (truncate-and-reset replaced the cursor it pretended to track)

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: 易良 <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
2026-09-01 06:37:23 +00:00

686 lines
23 KiB
JavaScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
chmodSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { getWorkflowJob, getWorkflowStep } from './workflow-helpers.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '../..');
function readPackageJson() {
return JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
}
function readWorkflow(relativePath) {
return readFileSync(path.join(root, relativePath), 'utf8');
}
describe('package scripts', () => {
it('does not couple Node REPL to Qwen release versions', () => {
const versionScript = readFileSync(
path.join(root, 'scripts/version.js'),
'utf8',
);
expect(versionScript).toContain(
'const workspacesToExclude = [\n' +
" '@qwen-code/sdk',\n" +
" '@qwen-code/mobile-mcp',\n" +
" '@qwen-code/node-repl-mcp',\n" +
" '@qwen-code/qwen-live',\n" +
'];',
);
});
it('builds the standalone qwen-live daemon in the root build order', () => {
const buildScript = readFileSync(
path.join(root, 'scripts/build.js'),
'utf8',
);
// The qwen-live e2e harness spawns packages/qwen-live/dist/index.js and
// the workspace unit tests run from src, so this pin is what catches the
// root build silently dropping the package.
const startIndex = buildScript.indexOf('const buildOrder = [');
expect(startIndex).toBeGreaterThan(-1);
const buildOrder = buildScript.slice(
startIndex,
buildScript.indexOf('];', startIndex),
);
expect(buildOrder).toContain("'packages/qwen-live',");
});
it('keeps the serve fast-path bundle check outside unit test scripts', () => {
const packageJson = readPackageJson();
expect(packageJson.scripts['test:ci']).not.toContain(
'npm run check:serve-fast-path-bundle',
);
expect(packageJson.scripts.preflight).toContain(
'npm run check:serve-fast-path-bundle',
);
});
it('limits SDK integration tests through the forks pool', () => {
const packageJson = readPackageJson();
expect(packageJson.scripts['test:integration:sdk:sandbox:none']).toContain(
'--poolOptions.forks.maxForks 2',
);
expect(
packageJson.scripts['test:integration:sdk:sandbox:docker'],
).toContain('--poolOptions.forks.maxForks 2');
});
it('cleans package build artifacts before checking the serve fast path bundle', () => {
const packageJson = readPackageJson();
expect(packageJson.scripts['check:serve-fast-path-bundle']).toBe(
[
'node scripts/clean-package-build-artifacts.js',
'&& npm run build -- --cli-only',
'&& cross-env DEV=true npm run bundle',
'&& node scripts/check-serve-fast-path-bundle.js',
].join(' '),
);
expect(packageJson.scripts['check:serve-fast-path-bundle']).not.toContain(
'npm run clean',
);
});
it('defines a release test script that disables workspace coverage', () => {
const packageJson = readPackageJson();
expect(packageJson.scripts['test:release']).toBe(
[
'cross-env NODE_OPTIONS="--max-old-space-size=3072"',
'npm run test:ci --workspaces --if-present -- --coverage.enabled=false',
'&& npm run test:scripts',
].join(' '),
);
const vscodePackageJson = JSON.parse(
readFileSync(
path.join(root, 'packages/vscode-ide-companion/package.json'),
'utf8',
),
);
expect(vscodePackageJson.scripts['test:ci']).toContain('--coverage');
});
it('skips build/bundle/husky but still generates git-commit info when CI builds explicitly', () => {
const packageJson = readPackageJson();
expect(packageJson.scripts.prepare).toBe('node scripts/prepare.js');
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-skip-'));
const logFile = path.join(binDir, 'commands.log');
writeFileSync(logFile, '');
try {
if (process.platform === 'win32') {
writeFileSync(
path.join(binDir, 'husky.cmd'),
'@echo(husky>>"%PREPARE_LOG_FILE%"\r\n',
);
writeFileSync(
path.join(binDir, 'npm.cmd'),
'@echo(npm %*>>"%PREPARE_LOG_FILE%"\r\n',
);
} else {
writeFileSync(
path.join(binDir, 'husky'),
'#!/bin/sh\necho husky >> "$PREPARE_LOG_FILE"\n',
);
writeFileSync(
path.join(binDir, 'npm'),
'#!/bin/sh\necho "npm $*" >> "$PREPARE_LOG_FILE"\n',
);
chmodSync(path.join(binDir, 'husky'), 0o755);
chmodSync(path.join(binDir, 'npm'), 0o755);
}
const result = spawnSync(
process.execPath,
[path.join(root, 'scripts/prepare.js')],
{
cwd: root,
encoding: 'utf8',
env: {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
PREPARE_LOG_FILE: logFile,
QWEN_SKIP_PREPARE: '1',
},
},
);
expect(result.status).toBe(0);
expect(result.stdout).toContain('Skipping prepare');
// git-commit info is still generated so a later per-workspace build or
// typecheck (e.g. the review tooling's) doesn't fail on the missing
// module; the heavy build/bundle/husky are skipped.
expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([
'npm run generate',
]);
} finally {
rmSync(binDir, { recursive: true, force: true });
}
});
it('runs prepare steps in order when CI does not skip prepare', () => {
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-bin-'));
const logFile = path.join(binDir, 'commands.log');
try {
if (process.platform === 'win32') {
writeFileSync(
path.join(binDir, 'husky.cmd'),
'@echo(husky>>"%PREPARE_LOG_FILE%"\r\n',
);
writeFileSync(
path.join(binDir, 'npm.cmd'),
'@echo(npm %*>>"%PREPARE_LOG_FILE%"\r\n',
);
} else {
writeFileSync(
path.join(binDir, 'husky'),
'#!/bin/sh\necho husky >> "$PREPARE_LOG_FILE"\n',
);
writeFileSync(
path.join(binDir, 'npm'),
'#!/bin/sh\necho "npm $*" >> "$PREPARE_LOG_FILE"\n',
);
chmodSync(path.join(binDir, 'husky'), 0o755);
chmodSync(path.join(binDir, 'npm'), 0o755);
}
const result = spawnSync(
process.execPath,
[path.join(root, 'scripts/prepare.js')],
{
cwd: root,
encoding: 'utf8',
env: {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
PREPARE_LOG_FILE: logFile,
QWEN_SKIP_PREPARE: '',
},
},
);
expect(result.status).toBe(0);
expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([
'husky',
'npm run build',
'npm run bundle',
]);
} finally {
rmSync(binDir, { recursive: true, force: true });
}
});
it('exits when a prepare step fails', () => {
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-fail-'));
const logFile = path.join(binDir, 'commands.log');
writeFileSync(logFile, '');
try {
if (process.platform === 'win32') {
writeFileSync(path.join(binDir, 'husky.cmd'), '@exit /b 7\r\n');
writeFileSync(
path.join(binDir, 'npm.cmd'),
'@echo npm %* >> "%PREPARE_LOG_FILE%"\r\n',
);
} else {
writeFileSync(path.join(binDir, 'husky'), '#!/bin/sh\nexit 7\n');
writeFileSync(
path.join(binDir, 'npm'),
'#!/bin/sh\necho "npm $*" >> "$PREPARE_LOG_FILE"\n',
);
chmodSync(path.join(binDir, 'husky'), 0o755);
chmodSync(path.join(binDir, 'npm'), 0o755);
}
const result = spawnSync(
process.execPath,
[path.join(root, 'scripts/prepare.js')],
{
cwd: root,
encoding: 'utf8',
env: {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
PREPARE_LOG_FILE: logFile,
QWEN_SKIP_PREPARE: '',
},
},
);
expect(result.status).toBe(7);
expect(result.stderr).toContain('prepare: husky exited with status 7');
expect(readFileSync(logFile, 'utf8')).toBe('');
} finally {
rmSync(binDir, { recursive: true, force: true });
}
});
it('reports the failing prepare step after earlier steps succeed', () => {
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-late-fail-'));
const logFile = path.join(binDir, 'commands.log');
writeFileSync(logFile, '');
try {
if (process.platform === 'win32') {
writeFileSync(
path.join(binDir, 'husky.cmd'),
'@echo(husky>>"%PREPARE_LOG_FILE%"\r\n',
);
writeFileSync(
path.join(binDir, 'npm.cmd'),
[
'@echo(npm %*>>"%PREPARE_LOG_FILE%"',
'@if "%1 %2"=="run build" exit /b 7',
'@exit /b 0',
'',
].join('\r\n'),
);
} else {
writeFileSync(
path.join(binDir, 'husky'),
'#!/bin/sh\necho husky >> "$PREPARE_LOG_FILE"\n',
);
writeFileSync(
path.join(binDir, 'npm'),
[
'#!/bin/sh',
'echo "npm $*" >> "$PREPARE_LOG_FILE"',
'if [ "$1 $2" = "run build" ]; then exit 7; fi',
'',
].join('\n'),
);
chmodSync(path.join(binDir, 'husky'), 0o755);
chmodSync(path.join(binDir, 'npm'), 0o755);
}
const result = spawnSync(
process.execPath,
[path.join(root, 'scripts/prepare.js')],
{
cwd: root,
encoding: 'utf8',
env: {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
PREPARE_LOG_FILE: logFile,
QWEN_SKIP_PREPARE: '',
},
},
);
expect(result.status).toBe(7);
expect(result.stderr).toContain(
'prepare: npm run build exited with status 7',
);
expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([
'husky',
'npm run build',
]);
} finally {
rmSync(binDir, { recursive: true, force: true });
}
});
it.skipIf(process.platform === 'win32')(
'reports when a prepare command is killed by a signal',
() => {
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-signal-'));
try {
writeFileSync(path.join(binDir, 'husky'), '#!/bin/sh\nkill -TERM $$\n');
writeFileSync(path.join(binDir, 'npm'), '#!/bin/sh\nexit 0\n');
chmodSync(path.join(binDir, 'husky'), 0o755);
chmodSync(path.join(binDir, 'npm'), 0o755);
const result = spawnSync(
process.execPath,
[path.join(root, 'scripts/prepare.js')],
{
cwd: root,
encoding: 'utf8',
env: {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
QWEN_SKIP_PREPARE: '',
},
},
);
expect(result.status).toBe(1);
expect(result.stderr).toContain(
'prepare: husky killed by signal SIGTERM',
);
} finally {
rmSync(binDir, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform === 'win32')(
'reports when a prepare command cannot be spawned',
() => {
const missingBinDir = mkdtempSync(
path.join(tmpdir(), 'qwen-prepare-missing-bin-'),
);
try {
const result = spawnSync(
process.execPath,
[path.join(root, 'scripts/prepare.js')],
{
cwd: root,
encoding: 'utf8',
env: {
...process.env,
PATH: missingBinDir,
QWEN_SKIP_PREPARE: '',
},
},
);
expect(result.status).toBe(1);
expect(result.stderr).toContain('prepare: husky failed:');
} finally {
rmSync(missingBinDir, { recursive: true, force: true });
}
},
);
it('wires release quality checks to fast explicit validation steps', () => {
const workflow = readWorkflow('.github/workflows/release.yml');
const qualityJob = getWorkflowJob(workflow, 'quality');
const buildStep = getWorkflowStep(qualityJob, 'Build Project');
const serveFastPathStep = getWorkflowStep(
qualityJob,
'Check Serve Fast Path Bundle',
);
const workspaceTestStep = getWorkflowStep(
qualityJob,
'Run Workspace Tests',
);
expect(qualityJob).toContain("name: 'Check Serve Fast Path Bundle'");
expect(qualityJob).toContain('npm run check:serve-fast-path-bundle');
expect(qualityJob.indexOf(serveFastPathStep)).toBeLessThan(
qualityJob.indexOf(buildStep),
);
expect(workspaceTestStep).toContain('npm run test:release');
expect(workspaceTestStep).not.toContain('npm run test:ci');
});
it('skips release install-time prepare and builds before publish bundling', () => {
const workflow = readWorkflow('.github/workflows/release.yml');
expect(workflow.slice(0, workflow.indexOf('jobs:'))).not.toContain(
'CI_BOT_PAT',
);
const installSteps =
workflow.match(
/ {6}- name: 'Install Dependencies'[\s\S]*?(?=\n {6}- name: '|\n {4}[A-Za-z0-9_-]+:|$)/g,
) || [];
expect(installSteps.length).toBe(5);
for (const installStep of installSteps) {
expect(installStep).toContain(
'npm ci --ignore-scripts --no-audit --progress=false',
);
expect(installStep).toContain('npm run postinstall');
expect(installStep).toContain('npm run generate');
expect(installStep).not.toContain('QWEN_SKIP_PREPARE');
expect(installStep).not.toContain('CI_BOT_PAT');
}
for (const jobName of ['integration_none', 'integration_docker']) {
const integrationJob = getWorkflowJob(workflow, jobName);
const buildStep = getWorkflowStep(integrationJob, 'Build Bundle');
expect(buildStep).toContain('npm run build\n npm run bundle');
}
const publishJob = getWorkflowJob(workflow, 'publish');
expect(publishJob.slice(0, publishJob.indexOf('steps:'))).not.toContain(
'CI_BOT_PAT',
);
const checkoutStep = getWorkflowStep(publishJob, 'Checkout');
const gitConfigStep = getWorkflowStep(publishJob, 'Configure Git User');
const commitStep = getWorkflowStep(
publishJob,
'Commit and Conditionally Push package versions',
);
const buildStep = getWorkflowStep(
publishJob,
'Build Bundle and Prepare Package',
);
expect(checkoutStep).toContain('persist-credentials: false');
expect(gitConfigStep).toContain('git config core.hooksPath .husky');
expect(publishJob.indexOf(gitConfigStep)).toBeLessThan(
publishJob.indexOf(commitStep),
);
expect(commitStep).toContain("CI_BOT_PAT: '${{ secrets.CI_BOT_PAT }}'");
expect(commitStep).toContain('export GH_TOKEN="${CI_BOT_PAT}"');
expect(commitStep).toContain('gh auth setup-git');
const exportTokenIdx = commitStep.indexOf(
'export GH_TOKEN="${CI_BOT_PAT}"',
);
const setupGitIdx = commitStep.indexOf('gh auth setup-git', exportTokenIdx);
expect(setupGitIdx).toBeGreaterThan(exportTokenIdx);
expect(setupGitIdx).toBeLessThan(
commitStep.indexOf('git push --force --set-upstream'),
);
expect(buildStep).toContain('npm run build\n npm run bundle');
});
it('skips npm packages whose release version is already published', () => {
const workflow = readWorkflow('.github/workflows/release.yml');
const publishJob = getWorkflowJob(workflow, 'publish');
for (const stepName of [
'Publish @qwen-code/audio-capture',
'Publish @qwen-code/qwen-code',
'Publish @qwen-code/channel-base',
'Publish remaining channel packages',
]) {
const publishStep = getWorkflowStep(publishJob, stepName);
expect(publishStep).toContain(
"RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'",
);
expect(publishStep).toContain(
'PACKAGE_NAME="$(node -p "require(\'./package.json\').name")"',
);
expect(publishStep).toContain('PUBLISH_ARGS+=(--dry-run)');
expect(publishStep).toContain(
'npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version',
);
expect(publishStep).toContain('already published; skipping');
expect(publishStep).toContain('exit 0');
expect(publishStep).toContain(
'npm publish --provenance "${PUBLISH_ARGS[@]}"',
);
}
// The channel loop must wrap each iteration in a subshell so that
// `exit 0` skips only the current channel, not the entire step.
const channelStep = getWorkflowStep(
publishJob,
'Publish remaining channel packages',
);
expect(channelStep).toContain('(\n');
expect(channelStep).toContain(')');
// A fully-skipped publish must be visible, not silently green.
expect(channelStep).toContain(
'Every channel package was already published; nothing shipped',
);
});
it('meets npm trusted publishing requirements', () => {
for (const [workflowPath, jobName, publishStepName] of [
[
'.github/workflows/release.yml',
'publish',
'Publish @qwen-code/audio-capture',
],
[
'.github/workflows/release-sdk.yml',
'release-sdk',
'Publish @qwen-code/sdk',
],
[
'.github/workflows/cd-cua-driver.yml',
'publish-sdk',
'Publish immutable SDK tarball',
],
[
'.github/workflows/cd-cua-driver.yml',
'publish-node-repl',
'Publish immutable Node REPL tarball',
],
['.github/workflows/cd-mobile-mcp.yml', 'build-and-publish', 'Publish'],
]) {
const publishJob = getWorkflowJob(readWorkflow(workflowPath), jobName);
const installStep = getWorkflowStep(publishJob, 'Install npm 11');
const publishStep = getWorkflowStep(publishJob, publishStepName);
expect(installStep).toContain('npm install --global [email protected]');
expect(publishJob).toContain("id-token: 'write'");
expect(publishStep).toContain('--provenance');
expect(publishJob).toContain("name: 'production-release'");
expect(publishJob.indexOf(installStep)).toBeLessThan(
publishJob.indexOf(publishStep),
);
}
for (const packageDirectory of [
'packages/audio-capture',
'packages/cli',
'packages/channels/base',
'packages/channels/dingtalk',
'packages/channels/dws',
'packages/channels/feishu',
'packages/channels/github',
'packages/channels/qqbot',
'packages/channels/telegram',
'packages/channels/wecom',
'packages/channels/weixin',
'packages/cua-driver/typescript',
'packages/mobile-mcp',
'packages/node-repl',
'packages/sdk-typescript',
]) {
const packageJson = JSON.parse(
readFileSync(path.join(root, packageDirectory, 'package.json'), 'utf8'),
);
expect(packageJson.repository?.url?.replace(/^git\+/, '')).toBe(
'https://github.com/QwenLM/qwen-code.git',
);
}
});
it('fast-tracks trusted autofix issue triggers before LLM assessment', () => {
const workflow = readWorkflow('.github/workflows/qwen-autofix.yml');
const issueJob = getWorkflowJob(workflow, 'issue-autofix');
const scanStep = getWorkflowStep(issueJob, 'Find candidate issues');
const fastTrackStep = getWorkflowStep(issueJob, 'Fast-track decision');
const assessStep = getWorkflowStep(issueJob, 'Assess candidates');
expect(issueJob.indexOf(scanStep)).toBeLessThan(
issueJob.indexOf(fastTrackStep),
);
expect(issueJob.indexOf(fastTrackStep)).toBeLessThan(
issueJob.indexOf(assessStep),
);
expect(fastTrackStep).toContain("id: 'fasttrack'");
expect(fastTrackStep).toContain('FAST_TRACK=false');
expect(fastTrackStep).toContain('FAST_TRACK=true');
expect(fastTrackStep).toContain('fast_tracked=false');
expect(fastTrackStep).toContain('-n "${FORCED_ISSUE}"');
expect(fastTrackStep).toContain(
'Fast-tracked: trusted trigger bypasses LLM assessment.',
);
expect(assessStep).toContain(
"steps.fasttrack.outputs.fast_tracked != 'true'",
);
});
it('skips autofix install-time prepare without disabling dependency scripts', () => {
const workflow = readWorkflow('.github/workflows/qwen-autofix.yml');
// review-address restores the shared build-cli bundle instead of
// compiling, so its install step is npm ci only; the other two jobs
// still build from sources. Husky hooks are re-armed after the
// prepare-skip only where git commits happen (build-cli never commits).
for (const [jobName, stepName, armsHooks] of [
['issue-autofix', 'Install dependencies and build', true],
['build-cli', 'Install dependencies and build', false],
['review-address', 'Install dependencies', true],
]) {
const job = getWorkflowJob(workflow, jobName);
const installStep = getWorkflowStep(job, stepName);
expect(installStep).toContain("QWEN_SKIP_PREPARE: '1'");
expect(installStep).toContain(
'npm ci --prefer-offline --no-audit --progress=false',
);
if (armsHooks) {
expect(installStep).toContain('git config core.hooksPath .husky');
}
expect(installStep).not.toContain('--ignore-scripts');
}
});
it('runs changed autofix tests instead of full touched-package suites', () => {
const workflow = readWorkflow('.github/workflows/qwen-autofix.yml');
const reviewVerificationRunner = readWorkflow(
'.github/scripts/run-autofix-review-verification.sh',
);
const reviewJob = getWorkflowJob(workflow, 'review-address');
for (const verificationBody of [
getWorkflowStep(
getWorkflowJob(workflow, 'issue-autofix'),
'Verification gate',
),
reviewVerificationRunner,
]) {
expect(verificationBody).toContain(
'npm run test --workspace "${p}" --if-present -- --changed origin/main --passWithNoTests',
);
expect(verificationBody).toContain(
'bash "${RUNNER_TEMP}/resolve-owning-packages.sh"',
);
expect(verificationBody).toContain('pkg.scripts?.test');
expect(verificationBody).toContain('!= *vitest*');
expect(verificationBody).not.toContain(
'npm run test --workspace "${p}" --if-present\n',
);
}
expect(getWorkflowStep(reviewJob, 'Verification gate')).toContain(
'bash --norc "${RUNNER_TEMP}/run-autofix-review-verification.sh"',
);
});
});