Files
qwen-code/scripts/tests/web-shell-managed-host-workflow.test.js
1888aaf49d fix(web-shell): close managed host integration gaps left by the host mount entry (#16200)
* fix(web-shell): close managed host integration gaps from #15928

Follow-up to #15928. Each change below was reproduced against the merged
head before fixing, and every new assertion was checked to fail without
its fix.

The host entry required a Java base URL even when a provider was injected,
so the documented mount only compiled inside test files and a JavaScript
host that dropped the provider silently addressed /undefined/api/... .
The base URL is now optional and only the Java path constructs a client;
with neither input the entry renders the unavailable notice.

Returning to an unconfirmed request's owner was the only navigation that
bypassed the shared open path: it dropped the owning workspace selection
and carried the previous conversation's error into the newly opened one.
Pending records now remember their workspace and the redirect uses the
shared path, so the owner keeps its workspace even after the catalog stops
listing it. An unconfirmed create still returns to the new-conversation
page.

The archived-status callback was forwarded by identity into an effect
that emits a fresh object, so an inline host callback re-rendered without
bound. It is routed through a ref like the other host callbacks.

Host navigation slots sit outside the shell container, where the compiled
stylesheet matches nothing. The entry now wraps each portaled list in the
portal scope and syncs theme class, language and custom properties.

The managed empty state centered every in-flow child, floating the
session header mid-pane and pushing content out of scroll reach once it
grew taller than the pane; it also stayed set while a transcript was
loading or a first send was in flight. Centering moved to auto margins on
the content wrapper and the marker now requires an idle, non-loading
conversation. The welcome header no longer stacks with a failure banner.

Host mode renders no session list, so both focus fallbacks became
no-ops and dropped focus to the document body, and the pane measurement
never re-ran after the waiting state. Focus falls back to the page
wrapper and measurement re-runs when the host supplies a Session ID.

Shared style release was not idempotent: disposing one mount twice
stripped the stylesheet a sibling still used. Both release paths are now
guarded.

The managed-host build mode ran in no gate and wrote inside the directory
the app build empties. It is part of the package build and writes to
dist-managed-host, staying out of the published exports and files.

Weak assertions from the original change were strengthened: the injected
provider test now spies on Java construction, the host-scope teardown
keeps the Session ID constant, the shadow-style test exercises the
constructable-sheet path, and the pending-row exclusion names the real
copy. New coverage pins the host pending key isolation, the notice reset
on a host switch, and the empty failed conversation.

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

* fix(web-shell): render the unavailable notice and gate the pending workspace fallback

Address the two blocking review items on the #15928 managed-host follow-up:

- The mount docs/README promise an unavailable notice when neither a provider
  nor a baseUrl is supplied, but the shell rendered an empty wrapper and the
  test only asserted the Java provider was not called. Render the notice via
  the existing managed.unavailable i18n key and strengthen the test to assert
  the notice text (red without the render, green with it).
- The 'open the original conversation' handler fell back to the on-screen
  summary's workspace, which in the cross-conversation case is a foreign
  workspace. Gate that fallback on pending.sessionId === sessionId so the
  owner is never bound to the wrong workspace when its own workspace is
  unknown.

The third review item (managed-host bundle in npm run build) is a maintainer
call on build cost and is left unchanged pending that decision.

ManagedAgentWebShell 18/18, ManagedSessionsPage 173/173; eslint exit 0,
prettier clean.

* fix(web-shell): theme only our own host slot wrappers and cover centering in a browser

Two review items on this PR remained after a61bfc04.

The slot sync identified its wrapper as the target's first element child.
That is wrong whenever the host slot already holds host chrome, and it is
also wrong when both slots name the same element. A probe against the
reviewed head showed the real consequence is worse than "fails silently":
it wrote lang, the dark class and every custom property onto the host's own
node (host chrome lang fr -> zh-CN) while leaving the actual list wrapper
unstyled. The wrappers now register themselves through a ref, so the sync
only ever touches nodes this entry rendered, and disconnected wrappers are
dropped. Both cases are pinned by tests that fail on the first-child lookup.

The centering claim had no layout evidence: jsdom performs no layout, so the
component test can only pin data-managed-empty, and the visuals bot reported
that no scenario renders this surface. Added a browser regression with its
own host-slot fixture that measures the pane at a cramped viewport. With the
previous justify-content rule the session header sits at -51px, above the
scroll origin and permanently unreachable; with auto margins it sits at 0
and stays in the viewport. The test fails on the old rule and passes on the
new one.

The build-wiring cost stays unchanged pending the maintainer decision. For
that decision, measured locally on this machine: the managed-host pass takes
about 13-14s wall clock against about 9s for the default lib pass and about
3s for the transcript pass, and emits 38 MB.

ManagedAgentWebShell 20/20, the five affected suites 212/212, the new browser
regression 1/1; eslint exit 0, prettier clean.

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

* fix(web-shell): keep the host nav slot from blocking session-list scroll

R1-4: the portal-scope/theme wrapper added around the host navigation slot
generates a block box, so in a bounded flex-column host sidebar it becomes the
flex item and its default min-height:auto pins the nav to content height — the
nav's overflow-y-auto never engages and the list is clipped.

Make the wrapper display:contents: it stays in the DOM (portal-scope attributes
and synced theme variables still apply and inherit) but generates no box, so the
nav remains the host's flex item and scrolls within the bounded slot.

Witness: browser A/B on this head — bounded 300px flex slot, 40 sessions:
navClientHeight 300 / navScrollHeight 1516 (scrollable), last row reachable;
the slot test asserts display:contents (red without it). Theme-sync suite 20/20.

* ci(web-shell): gate the managed host bundle in its own lane, not every build

Resolves the maintainer-call item on this PR. The managed-host mode
externalizes nothing, so riding `npm run build` charged every local build and
every fresh-clone `prepare` with a full bundle of React, xterm, mermaid,
echarts, shiki and codemirror — measured locally at about 13-14s and 38 MB —
for an artifact that stays out of `exports`/`files` and has no in-repo
consumer. The pass bought rot detection and nothing else.

Rot detection now lives in a dedicated workflow that runs the mode on pull
requests and post-merge pushes touching the entry, its build config or the
lockfile. It installs with QWEN_SKIP_PREPARE because the entry needs no
sibling workspace dist/, so the lane costs one bundling pass rather than a
monorepo rebuild first. It runs on an ephemeral hosted runner with a
read-only token and no secrets, and produces no artifact.

A green exit code alone would not catch the rot, so the lane asserts a
non-empty entry containing the mount symbol and the scoped empty-state CSS,
and checks the output stays outside the directory the app build empties and
out of the published exports/files. The local entry point is now
`npm run build:managed-host`.

A workflow meta-test pins both halves of the split: putting the mode back
into the default build fails it, and so does dropping the build config from
the lane's path filter. Both were confirmed by mutation. The .ci-budget
entry is added for the new PR-stage workflow.

Verified locally: actionlint and yamllint clean, CI budget holds, the new
meta-test 9/9, package-scripts and ci-platform-lanes 134 passed, the
all-workflow expression-length assertion passes with the new file, the five
affected web-shell suites 212/212, and `npm run build:managed-host` succeeds
with the lane's verify assertions holding.

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

* ci(web-shell): build core before the managed host bundle

The managed host entry imports one compiled core module, and the package
`exports` map resolves it through `dist/src/`, which `QWEN_SKIP_PREPARE=1`
never produces. The lane failed to resolve it before Rollup had anything
to bundle.

Build that single package instead of running the full `prepare`, and record
the new workflow in the size baseline so the ratchet tracks it.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw0ykbnlbb

* ci(web-shell): build the SDK dist the managed host entry imports

The managed-host lane installs with QWEN_SKIP_PREPARE=1, so no workspace
dist exists except the ones the lane builds explicitly. managed-host mode
externalizes nothing (`vite.lib.config.ts` returns false), and the entry's
module graph reaches a runtime value import of `@qwen-code/sdk/daemon`,
whose exports map points into `packages/sdk-typescript/dist/`. Building
only core therefore left the bundle step red on this head:

  [vite]: Rollup failed to resolve import "@qwen-code/sdk/daemon" from
  packages/web-shell/client/components/messages/tools/ToolDetailsContext.tsx

Build `@qwen-code/sdk` after core (its `tsconfig.reference.json` only
references `../core`, so the existing step already satisfies it) and pin
the new step in the lane's workflow test.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw19a4jbbr

* ci(web-shell): build acp-bridge before the SDK in the managed host lane

The lane installs with QWEN_SKIP_PREPARE=1 and built only core, then the
SDK. The SDK's daemon sources import `@qwen-code/acp-bridge/<subpath>`,
which resolves through that package's `dist/` exports map, and
`tsconfig.reference.json` references core alone, so `tsc --build` never
produces acp-bridge: the lane died on 94 TS2307/TS2339 errors before it
bundled anything.

Build it between core and the SDK, the order live-host-release.yml already
uses. Reproduced both arms locally: without acp-bridge/dist the SDK build
fails with the same 94 errors as run 41732515178; with it, the build exits 0.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw1bfabvbu

* fix(web-shell): close the managed host R1 criticals

R1-2: the identity-switch branch dropped an explicit host sessionId when
the provider arrived after mount, because `selection.storageKey` started as
undefined. Guard on a previous identity existing; a real storageKey change
still drops the carried id.

R1-1: a wrapper that mounted in a commit the sync effect did not watch kept
no theme at all. The ref callback now themes the element it just saw, and
the effect discovers our own wrappers by their portal-scope marker instead
of a registry that dropped a detached wrapper for good. `style` joins the
effect deps so a host token change re-copies without a theme flip.

R1-3: the page-wrapper focus fallback parked focus where the discard latch
did not recognise it. The latch now accepts the node the discard parked on
instead of the page wrapper wholesale.

Tests: late-mount + style-only token arms and the late-provider sessionId
arm in ManagedAgentWebShell.test.tsx (22/22 green).

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw1bfabvbu

* fix(web-shell): keep the managed empty marker after a failed pending restore

A failed pending restore leaves `busy` set until the user discards, so the
new `!busy` term dropped `data-managed-empty` in a state the base marker
still covered, losing the centring and overflow rule this PR adds. The shell
root ref is now a stable callback so a wrapper mounting in a commit the sync
effect does not watch is still themed.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw1jzxglc6

* fix(web-shell): drop a carried session id across a provider-less identity switch

Closes the R4 Critical and three review items that made this PR's own
guarantees unverifiable.

R4-1: an identity switch routed through a provider-less render handed the
previous tenant's session to the next one. The provider-less render wrote
`storageKey: undefined`, so the next real provider compared against
undefined and kept the carried id: A -> none -> C mounted C's page on A's
`s1`. The direct A -> C transition dropped it, so this was the one path
around the drop-on-scope-change rule the design doc requires. The selection
now retains the last real identity while no provider is mounted and
compares against it. Both the branch condition and the drop predicate are
gated on a provider being present — the review's suggested shape gated only
the predicate, which I confirmed loops with "Too many re-renders" because
the retained key keeps the condition permanently true.

R2-3: the lane's scoped-CSS assertion could not fail. `data-managed-empty`
is a JSX prop name that survives minification as an object key, so a
stopped injection guard still passed it. It now keys on the injection
preamble (`__qwenWebShellCss`, the per-entry `managed-host` guard) and the
portal scope selector. Witness: renaming the entry in the plugin's guard
produced a build that exits 0 with zero styles — the old grep passed, the
new step fails.

R3-3: the meta-test's no-secrets guard only matched dot access, so
`secrets['TOKEN']` kept it green. It now covers bracket form too, still
without matching the prose in the security comment.

R3-1: the test named for building every workspace dist pinned only the SDK
step. Core and acp-bridge are equally load-bearing, so all three are pinned
along with their required order. Deleting the core step now reds it.

R1-9: the README example imported the pre-move `./managed-host/index.js`,
which the current flat output no longer produces.

The stale comment claiming the entry needs no sibling workspace dist is
corrected: the lane builds core, acp-bridge and the SDK precisely because it
resolves through them.

Verified: the five affected web-shell suites 216/216, the workflow
meta-test 10/10, actionlint and yamllint clean, CI budget holds, eslint
exit 0, prettier clean, and the lane's verify step run verbatim against a
freshly built bundle.

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

* fix(ci): make the managed-host bundle assertions able to fail

R2-3: the portal wrapper ships in its own chunk, and every occurrence of
`data-web-shell-portal-root` in the entry sits inside the injected
stylesheet, so the entry grep passed on a bundle whose wrapper was deleted.
Search the whole output for the quoted JSX prop instead, which the scoped
CSS cannot produce.

R5-1: a job-level `permissions:` block replaces the workflow default, so pin
the job's effective scope rather than the workflow-level block.

R5-2: match any expression that reads `secrets`; `${{ toJSON(secrets) }}` is
as much a read as `secrets.TOKEN`. The workflow's own `${{ github.* }}`
expressions are the control against over-broadening the guard.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw29pur5d6

* fix(ci): trigger the managed-host lane on every input it builds from

The lane's `paths` filters listed the entry, its manifest, two build configs,
the lockfile and the workflow itself — none of the workspace sources the
non-externalized bundle is built from. A PR touching `packages/core/src/**`,
`packages/acp-bridge/src/**` or `packages/sdk-typescript/src/**` reported this
lane `skipped`, so a break inside the bundle surfaced only when a host loaded
it. `packages/web-shell/vite.config.ts` was missing the same way even though
`vite.lib.config.ts` imports `WEB_SHELL_BUILD_TARGET` from it and uses it as
the bundle's `target`.

Both triggers now list those four inputs. The meta-test also pinned the list
with `arrayContaining`, which let two of the six entries be deleted with the
suite green; it now pins the exact set, so extending or dropping an entry is a
reviewed change rather than a silent one.

Addresses review thread R2-2 on #16200 and the same gap reported as the only
finding by the deep verification on b84f2339.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw420o4bfo

* fix(web-shell): keep the managed-host bundle out of lint and clean its outDir

The managed-host pass writes `dist-managed-host/`, which `packages/**/dist/**`
does not match, so a local build followed by `npm run lint` linted the 1.25 MB
bundle as source. Ignore the directory and let the managed-host pass empty its
own outDir so rebuilds stop accumulating previous hashed chunks.

Also drop the unreachable same-session workspace fallback in the pending-owner
button (`pendingElsewhere` implies `pending.sessionId !== sessionId`), and
restore the broad pending-row guard in the host dom test.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw4plfohgl

* fix(web-shell): close managed-host correctness gaps from review round 8

Four review findings describe defects in behavior this PR introduced; each
lands with a regression test confirmed red without its fix.

- readPending now validates the new workspaceId field like its siblings, so
  a corrupt stored record cannot hand a non-string to the host's workspace
  selection (R1-7).
- The managed page wrapper is a non-interactive focus parking spot; the
  composer's global keydown gate now treats a marked focus region like body
  focus, restoring type-anywhere after a click inside the pane (R1-11).
- A host that swaps its archived-status handler now receives the current
  status: the shell replays it when the replaced callback was stable, while
  churning inline callbacks never trigger a replay, preserving the bounded
  render behavior the frozen forwarded identity provides (R1-10).
- Slot token sync removes custom properties the shell root no longer
  defines, so a withdrawn host token cannot stay pinned inline above the
  host's cascade (R2-6).

Deferred to #16471 with recorded reasoning: mount-specific unavailable copy
(R1-5), real-page render-prop and busy-marker test pinning (R1-13, R1-14),
and a mutation trigger for host class-driven token changes (R3-2).

* fix(web-shell): read a parked focus region from the container root

The focus-region arm added for the managed page wrapper read
document.activeElement, which a shadow-root mount retargets to the host,
so clicking the pane parked focus on the wrapper while the gate still
dropped every printable keystroke. Read the active element from the
container's own root and keep the document.body arm untouched.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw58vvp6hc

* fix(web-shell): scope a parked focus region to its own shell

Two managed shells can share one shadow root and park focus on sibling
`data-web-shell-focus-region` wrappers. The global keydown gate accepted the
attribute alone, so shell A captured a keystroke aimed at shell B and inserted
it into the wrong conversation's editor before the session submitted it.

Require the parked region to contain this composer before treating it as body
focus, and reshape both fixtures so the wrapper is an ancestor of the composer
as in the real managed DOM, with a regression witness for the sibling case.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw5d677mhi

* fix(web-shell): drop the unreachable provider-less identity arm

Main's `baseUrl` guard makes a provider-less render unreachable, so the
sessionId-carry-over arm that passed through one has no producer left to
pin. Prettier also collapses the i18n import.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw69blwmis

* fix(web-shell): read the parked focus node from its own root (C2)

In shadow-DOM portal mode document.activeElement retargets to the shadow
host, so the page's parked-focus comparisons to body/sessions nav/discard
node were always false and the composer latch treated a click inside the
pane as a real move. Resolve the active element from the page wrapper's
own root, as App and the other focus users already do.

* test(web-shell): pin the real page as the composer focus region (C1)

The attribute plus tabIndex pair is what useComposerCore keys type-anywhere
off; the existing assertions all build their own region. Render the real
page and pin both; deleting either side reds this test.

* fix(web-shell): watch the workspace manifests in the managed-host lane (C4)

The lane builds core, acp-bridge and the SDK from source, so a change to
those workspace manifests can break the bundle while the filter reports
skipped. Add the three package.json entries to both triggers and to the
exact-set pin.

* chore(web-shell): raise the managed-host lane size baseline (C3)

Measured 7051 bytes for web-shell-managed-host.yml after C4; the baseline
still recorded 5672, so the size gate would fail on the next run.

* test(web-shell): pin the archived slot branch and the baseUrl guard (C5, R4-1)

C5: the single shared target hid the archived branch — deleting it still
left one wrapper inside the element. Use two distinct targets and assert
each gets exactly one themed wrapper.

R4-1: patrol removed the identity arm that passed through a provider-less
render because the baseUrl guard makes that render unreachable. Pin the
guard itself: a provider-less render must show the config fallback and
commit no page render, and the next provider must still render. Relaxing
the guard (or defaulting baseUrl) reds this test, so a future refactor
cannot silently bring the arm back.

* fix(web-shell): treat an unfocused shadow root as unclaimed in the discard latch

The discard focus latch read the parked node from the page wrapper's own
root, so a shadow-root mount whose discard dropped focus to the outer
document body produced ShadowRoot.activeElement === null. Every `ours`
operand was false, the latch was dropped, and the composer that just got
the draft back was never focused. Treat an unfocused shadow root as
unclaimed, matching the body-focus operand. The latch still dies when the
user moves on (ManagedSessionsPage.test.tsx pins that).

Pinned by a new shadow-root regression: the page mounts into
host.attachShadow({mode:'open'}) with a host-side navigationTarget, a
pending record is discarded, and shadow.activeElement must be the
composer. The test is red without the fix and green with it.

Addresses review thread R13-1 on #16200.

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

* fix(web-shell): resolve host-page focus before judging the discard latch unclaimed

ShadowRoot.activeElement is null both when nothing inside the root is
focused and when focus sits on a host-page element outside the root, so
the shadow-aware read alone collapsed the two states and let the discard
latch steal focus back from a user who had demonstrably moved on. Fall
through to document.activeElement when the root reports null so the
latch sees the real focus holder, and pin the host-toolbar case with a
shadow-root regression test.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
2026-11-09 00:42:45 +08:00

203 lines
8.9 KiB
JavaScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// The managed-host mount entry is unpublished: it is absent from the package
// `exports` and `files`, and no consumer in the repo imports it. Its only
// protection against rot is this lane. It previously rode `npm run build`,
// which charged every local build and every fresh-clone `prepare` for a full
// non-externalized bundle; moving it here is only a fix if BOTH halves hold —
// the lane really runs the mode, and the default build really stopped doing so.
// These assertions pin that split, because either half silently regressing
// leaves the entry unprotected or the tax back in place with every suite green.
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { parse } from 'yaml';
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);
const workflowSource = readFileSync(
path.join(repoRoot, '.github/workflows/web-shell-managed-host.yml'),
'utf8',
);
const workflow = parse(workflowSource);
// `on:` parses as the boolean true in YAML 1.1.
const triggers = workflow[true] ?? workflow['on'];
const job = workflow.jobs.build;
const pkg = JSON.parse(
readFileSync(path.join(repoRoot, 'packages/web-shell/package.json'), 'utf8'),
);
const stepNamed = (name) =>
job.steps.find((candidate) => candidate.name === name);
describe('managed host build lane', () => {
it('runs on pull requests, post-merge pushes and manual dispatch', () => {
expect(Object.keys(triggers).sort()).toEqual([
'pull_request',
'push',
'workflow_dispatch',
]);
expect(triggers.push.branches).toEqual(['main']);
expect(triggers.pull_request.branches).toContain('main');
});
it('triggers on the entry, its build wiring and itself', () => {
// A path filter that misses the config or the entry would report "skipped"
// on the very changes that can break the mode — agreement without a run.
// The set is exact rather than `arrayContaining`: this lane builds core,
// acp-bridge and the SDK from source and reads `WEB_SHELL_BUILD_TARGET`
// from `vite.config.ts`, so a filter that omits any of them reports
// "skipped" on the change that breaks it. An exact set also means an entry
// cannot be dropped from the workflow with this suite still green.
const expectedPaths = [
'.github/workflows/web-shell-managed-host.yml',
'packages/acp-bridge/package.json',
'packages/acp-bridge/src/**',
'packages/core/package.json',
'packages/core/src/**',
'packages/sdk-typescript/package.json',
'packages/sdk-typescript/src/**',
'packages/web-shell/client/**',
'packages/web-shell/package.json',
'packages/web-shell/pierre-build.ts',
'packages/web-shell/vite.config.ts',
'packages/web-shell/vite.lib.config.ts',
'pnpm-lock.yaml',
];
for (const trigger of [triggers.pull_request, triggers.push]) {
expect(trigger.paths).toHaveLength(expectedPaths.length);
expect([...trigger.paths].sort()).toEqual(expectedPaths);
}
});
it('builds untrusted PR code with a read-only token and no secrets', () => {
// A job-level `permissions:` block replaces the workflow default, so the
// job's effective scope is the one the untrusted build actually runs with.
expect(job.permissions ?? workflow.permissions).toEqual({
contents: 'read',
});
expect(workflowSource).not.toContain('pull_request_target');
// Match any expression that reads the context: `${{ toJSON(secrets) }}` is
// as much a read as `secrets.TOKEN`. The conflict check below is the
// control — the workflow's own `${{ github.* }}` expressions, which a guard
// broadened to every expression would red on instead of passing silently.
const secretRef = /\$\{\{[^}]*\bsecrets\b/;
expect(workflowSource).not.toMatch(secretRef);
expect(workflowSource).toMatch(/\$\{\{\s*github\./);
expect(job['runs-on']).toBe('ubuntu-latest');
expect(job.if).toContain("github.repository == 'QwenLM/qwen-code'");
const checkout = job.steps.find((step) =>
String(step.uses ?? '').startsWith('actions/checkout@'),
);
expect(checkout.with['persist-credentials']).toBe(false);
});
it('bounds the job and never cancels a post-merge witness', () => {
expect(job['timeout-minutes']).toBeGreaterThan(0);
expect(workflow.concurrency['cancel-in-progress']).toBe(
"${{ github.event_name == 'pull_request' }}",
);
});
it('builds the managed-host mode without the monorepo prepare build', () => {
expect(stepNamed('Build the managed host entry').run).toBe(
'npm run build:managed-host --workspace=@qwen-code/web-shell',
);
expect(pkg.scripts['build:managed-host']).toBe(
'vite build --config vite.lib.config.ts --mode managed-host',
);
// `prepare` would rebuild every workspace; the three targeted steps below
// build only the dists this entry actually resolves through.
expect(stepNamed('Install dependencies').env.QWEN_SKIP_PREPARE).toBe('1');
});
it('builds every workspace dist the entry imports', () => {
// The client imports `@qwen-code/sdk/daemon`, whose `exports` points into
// that package's `dist/`, and the SDK in turn resolves core and acp-bridge
// through their own `dist/`. All three are load-bearing: dropping any one
// reds the lane on an unresolved import, so pin each rather than just the
// last. `tsconfig.reference.json` references only ../core, so `tsc --build`
// never produces acp-bridge — hence its own step and this fixed order.
expect(stepNamed('Build core for the managed host entry').run).toBe(
'corepack pnpm --filter @qwen-code/qwen-code-core build',
);
expect(stepNamed('Build acp-bridge for the SDK reference build').run).toBe(
'corepack pnpm --filter @qwen-code/acp-bridge build',
);
expect(stepNamed('Build the SDK for the managed host entry').run).toBe(
'corepack pnpm --filter @qwen-code/sdk build',
);
const buildOrder = job.steps
.map((step) => step.name)
.filter((name) => String(name).startsWith('Build '));
expect(buildOrder).toEqual([
'Build core for the managed host entry',
'Build acp-bridge for the SDK reference build',
'Build the SDK for the managed host entry',
'Build the managed host entry',
]);
});
it('asserts an emitted bundle rather than trusting a green exit code', () => {
const verify = stepNamed('Verify the host entry emitted a usable bundle');
expect(verify.run).toContain('dist-managed-host/index.js');
expect(verify.run).toContain('test -s');
expect(verify.run).toContain('renderManagedAgentWebShell');
// The CSS half must key on the injection preamble. A JSX prop name such as
// `data-managed-empty` survives minification as an object key even when the
// plugin's per-entry guard stops matching and no stylesheet is injected,
// so grepping it would pass on an unstyled bundle.
expect(verify.run).toContain('__qwenWebShellCss');
expect(verify.run).toContain(
'grep -qF \'data-qwen-web-shell-entry="managed-host"\'',
);
// The portal wrapper ships in its own chunk, and every occurrence in the
// entry sits inside the injected stylesheet, so the check must search the
// whole output for the quoted JSX prop a CSS scope cannot produce.
expect(verify.run).toContain(
`grep -rqF 'data-web-shell-portal-root":' 'packages/web-shell/dist-managed-host'`,
);
expect(verify.run).not.toContain("grep -q 'data-managed-empty'");
});
it('keeps the output outside the directory the app build empties', () => {
const verify = stepNamed(
'Verify the host output stays outside the app build directory',
);
expect(verify.run).toContain('packages/web-shell/dist/managed-host');
const config = readFileSync(
path.join(repoRoot, 'packages/web-shell/vite.lib.config.ts'),
'utf8',
);
expect(config).toContain(
"mode === 'managed-host' ? 'dist-managed-host' : 'dist'",
);
});
});
describe('managed host stays out of the default build and the package', () => {
it('does not charge the default build for the host bundle', () => {
expect(pkg.scripts.build).not.toContain('managed-host');
// The default passes it replaces must survive the split.
expect(pkg.scripts.build).toContain('--mode transcript');
expect(pkg.scripts.build).toContain('tsc -p tsconfig.lib.json');
});
it('keeps the entry unpublished so the lane is its only gate', () => {
const targets = Object.values(pkg.exports).flatMap((entry) =>
typeof entry === 'string' ? [entry] : Object.values(entry),
);
expect(targets.some((target) => target.includes('managed-host'))).toBe(
false,
);
expect(pkg.files).toContain('!dist/types/managed-host.d.ts');
});
});