fix(web-shell): make the workspace provider guard self-diagnosing and reload on root retry (#11421)

* fix(web-shell): make the workspace provider guard self-diagnosing and reload on root retry

The strict useDaemonWorkspace guard fails closed with a message that
cannot distinguish an absent provider from a duplicated module copy
(two DaemonWorkspaceContext instances in one page, seen in dev when the
module graph is refreshed under a live page), so the root boundary
showed a dead-end fallback that only a manual reload could clear.

The provider now registers a per-module-copy marker on first render,
and the guard's error states which of the three cases it hit: no
provider rendered, a provider rendered from a different module copy
(with both copy ids), or a provider from this copy rendered and the
consumer is outside its subtree. The standalone root boundary's retry
now reloads the page, since re-mounting the same broken module graph
would throw again.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(web-shell): restore spies in afterEach so a failing retry test cannot leak the console.error mock

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(web-shell): guard the root-fallback reload behind token survivability and sharpen copy diagnostics

Review round on ca4cdfb7 found that the unconditional reload could strand
the shell unauthenticated when the daemon token never reached storage,
and that the diagnostics had four misreport shapes. Retry now reloads
only when a reload can still authenticate (URL or per-tab persisted
token) and falls back to an in-place reset otherwise, with the button
label matching the action via a new retryMode prop on RootErrorFallback.
The copy registry tracks rendered-vs-provided per module copy, prefers
the duplicate-copies branch when foreign copies exist, carries the
module URL in each copy id, and is capped so dev hot re-evaluation
cannot grow it unbounded.

Mutation checks performed: removing the reload-survivability guard,
the label plumbing, the harness try/catch, the URL-bearing id, the
helper-local root, the foreign-copy precedence, or the registry cap
each turns the corresponding new test red; reverting restores green.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(web-shell): address review round 3 — tokenless reload, shared token parse, bounded-map util, wording honesty

- main.tsx: the survivability gate now also passes when no token was
  resolved at boot (tokenless trusted loopback strands nothing), and the
  reload carries the live theme/language across, since session switches
  strip those one-shot URL params.
- config/daemon.ts: the URL token grammar is parsed once in
  readTokenFromLocation(), shared by getDaemonToken() and the
  survivability predicate so the spellings cannot drift.
- DaemonWorkspaceProvider.tsx: copy ids fall back to a module name when
  import.meta.url is lowered away (esbuild iife export documents); the
  registry insert goes through a new utils/bounded-map helper (the
  package's fourth copy of that loop, and the first with the correct
  exit test); both guard branches admit unmount/lost-client explicitly.
- export-html build.mjs: silences the deliberate empty-import-meta
  warning and fails the build if it resurfaces.
- Tests: boot-order replay pinning that the predicate never consults
  getDaemonToken()'s cache, a same-element re-render pinning 'provided'
  as terminal, a live-copy-eviction test that seeds foreign copies after
  the live one, the tokenless-reload and theme/language-carry cases in
  main.test.tsx, the zh-CN reload label, and bounded-map unit tests.
  Each new guard was exercised as a mutation first (removed or reverted)
  and confirmed to turn its test red.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(web-shell): address review round 4 — one-shot param strip, live-URL reload, a firing import.meta guard, finish the bounded-map migration

- main.tsx: boot strips the one-shot theme/language/lang params once the
  initializers have read them, so the retry reload's URL carry cannot
  become a permanent override of stored preferences; the reload URL is
  built from the live location.href.
- export-html build.mjs: the import.meta guard now keeps the warnings
  (logLevel: 'error') and fails only on reads outside the tolerated
  prebuilt transcript entry — the previous logOverride: 'silent'
  discarded them and made the check unreachable.
- bounded-map: App.tsx's same-name private copy and the
  useSessionArtifacts tail migrate to the shared helper, deleting the
  drifted duplicates (their 'if (!oldest) break' exit would stop
  evicting on an empty-string key).
- tests: the zh-CN x default-reset copy cell; the reload-carry test now
  stubs location after the last navigation and pins the live session
  path and workspace in the carried URL; scripts/tests gains an
  end-to-end case that drives the export build with an injected
  import.meta read and asserts the guard's failure.
  Mutation checks: removing the boot strip, reverting the guard to
  logOverride: silent, mutating the zh-CN retry copy, or building the
  reload URL from origin each turns the corresponding new case red.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(web-templates): extract the import.meta guard into a tested predicate and stop silencing the build

Round 5 found three problems with the round-4 guard wiring: the e2e test
drove the real build with an injected probe and left src/export-html/dist
wiped on every green run (the build rm -rf's it up front and only
repopulates on success), its existsSync bail-out reported a vacuous pass
instead of a skip on trees without the prebuilt transcript, the
file-path allowlist could not see a fourth import.meta read arriving
through that same prebuilt bundle, and logLevel: 'error' suppressed
every other warning class the build emits.

The guard is now a pure predicate in import-meta-guard.mjs — file
allowlist plus a count ratchet (the deliberate guarded ternary accounts
for exactly three empty-import-meta warnings; a fourth fails) — called
from build.mjs, unit-tested with fabricated warnings. The e2e half keeps
driving the real build but snapshots and restores the dist directory,
restores the probed file in finally, and uses describe.skipIf so a
missing prebuilt transcript reports skipped, not passed. logLevel is
gone: warnings print at the default level again while
documentBuildResult.warnings stays populated for the guard.

Mutation checks: removing the ratchet reds the fourth-read unit case and
the transcript-probe e2e; disconnecting the guard call reds both e2e
cases; reverting to logOverride: 'silent' reds the document-entry e2e.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Shaojin Wen 2026-09-10 06:33:33 +00:00 committed by GitHub
parent d8baa8730f
commit f6540d1994
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 993 additions and 59 deletions

View file

@ -70,6 +70,7 @@ import type {
import { isGoalGateBlocked as isGoalGateBlockedFor } from './utils/goalGate';
import { keepWorkspaceSplitSessionIds } from './utils/standalone-session-routing';
import { setBoundedMapEntry } from './utils/bounded-map';
import { type SessionGitIntent } from './components/GitModePopover';
import { gitModeIntentMustReset } from './utils/gitModeIntent';
import { LocalControlQrButton } from './components/LocalControlQrButton';
@ -1461,20 +1462,6 @@ const SESSION_ATTACHMENT_LIST_FEATURE = 'session_attachment_list';
const BOTTOM_PANEL_GAP_PX = 6;
const BOTTOM_PANEL_FALLBACK_INSET_PX = 40;
function setBoundedMapEntry<V>(
map: Map<string, V>,
key: string,
value: V,
): void {
map.delete(key);
map.set(key, value);
while (map.size > MAX_ARTIFACT_PANEL_SESSION_STATES) {
const oldest = map.keys().next().value;
if (!oldest) break;
map.delete(oldest);
}
}
// One preview tab per image, keyed by its content, so opening several images
// keeps a tab each while re-clicking the same image just focuses its tab.
function imageTabId(src: string): string {
@ -4414,6 +4401,7 @@ export function App({
sessionAttachmentsBySessionRef.current,
logicalSessionKey,
[],
MAX_ARTIFACT_PANEL_SESSION_STATES,
);
setSessionAttachments([]);
setSessionAttachmentsLoading(false);
@ -4453,6 +4441,7 @@ export function App({
sessionAttachmentsBySessionRef.current,
logicalSessionKey,
attachments,
MAX_ARTIFACT_PANEL_SESSION_STATES,
);
setSessionAttachments(attachments);
setSessionAttachmentsLoading(false);
@ -4469,6 +4458,7 @@ export function App({
attachmentRetryCountRef.current,
logicalSessionKey,
failures,
MAX_ARTIFACT_PANEL_SESSION_STATES,
);
retryTimer = setTimeout(
() => setAttachmentRefreshNonce((nonce) => nonce + 1),
@ -4481,6 +4471,7 @@ export function App({
sessionAttachmentsBySessionRef.current,
logicalSessionKey,
[],
MAX_ARTIFACT_PANEL_SESSION_STATES,
);
setSessionAttachments([]);
}
@ -5978,6 +5969,7 @@ export function App({
artifactPanelDeferredPersistedTabsRef.current,
nextSessionId,
deferredPersistedTabs,
MAX_ARTIFACT_PANEL_SESSION_STATES,
);
} else {
artifactPanelDeferredPersistedTabsRef.current.delete(nextSessionId);

View file

@ -31,8 +31,10 @@ afterEach(() => {
vi.unstubAllEnvs();
});
// Mirror the top-level wiring in index.tsx / main.tsx: the boundary wraps the
// whole App and renders RootErrorFallback (with retry) on a render-phase crash.
// Mirror the embeddable wiring in index.tsx: the boundary wraps the whole App
// and renders RootErrorFallback with an in-place retry. (The standalone entry
// main.tsx instead reloads when the daemon token survives a reload; that path
// is covered in main.test.tsx.)
function RootBoundary({
children,
language,
@ -85,11 +87,13 @@ describe('RootErrorFallback', () => {
error={new Error('x')}
onRetry={() => {}}
language="zh-CN"
retryMode="reload"
/>,
);
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
'出了点问题',
);
expect(container.querySelector('button')?.textContent).toBe('重新加载');
});
it('catches an App-level render crash instead of white-screening', () => {
@ -108,6 +112,30 @@ describe('RootErrorFallback', () => {
expect(alert?.textContent).toContain('app render exploded');
});
it('labels the retry button as a reload when retryMode is reload', () => {
const { container } = mount(
<RootErrorFallback
error={new Error('boom')}
onRetry={() => {}}
retryMode="reload"
/>,
);
expect(container.querySelector('button')?.textContent).toBe('Reload page');
});
it('renders the zh-CN retry label in the default reset mode', () => {
// No retryMode: the embeddable call sites (index.tsx, WebShellTranscript)
// rely on the 'reset' default this cell pins.
const { container } = mount(
<RootErrorFallback
error={new Error('boom')}
onRetry={() => {}}
language="zh-CN"
/>,
);
expect(container.querySelector('button')?.textContent).toBe('重试');
});
it('recovers when the user clicks "Try again" after the cause is gone', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
// External switch the child reads at render time. The boundary's reset()

View file

@ -6,12 +6,19 @@ interface RootErrorFallbackProps {
onRetry: () => void;
/** Selects the fallback copy. Defaults to English when omitted. */
language?: WebShellLanguage;
/**
* What the retry button does, so its label stays honest. Defaults to
* 'reset' (an in-place boundary retry); 'reload' is for callers whose
* retry navigates (e.g. the standalone root boundary reloads the page).
*/
retryMode?: 'reset' | 'reload';
}
interface FallbackCopy {
title: string;
body: string;
retry: string;
reload: string;
}
// This surface renders OUTSIDE the in-app I18nProvider (the boundary wraps the
@ -22,11 +29,13 @@ const COPY: Record<WebShellLanguage, FallbackCopy> = {
title: 'Something went wrong',
body: 'An unexpected error occurred and this content could not be displayed.',
retry: 'Try again',
reload: 'Reload page',
},
'zh-CN': {
title: '出了点问题',
body: '发生意外错误,无法显示此内容。',
retry: '重试',
reload: '重新加载',
},
};
@ -82,6 +91,7 @@ export function RootErrorFallback({
error,
onRetry,
language = 'en',
retryMode = 'reset',
}: RootErrorFallbackProps) {
const copy = COPY[language] ?? COPY.en;
return (
@ -97,7 +107,7 @@ export function RootErrorFallback({
</p>
)}
<button type="button" style={buttonStyle} onClick={onRetry}>
{copy.retry}
{retryMode === 'reload' ? copy.reload : copy.retry}
</button>
</div>
);

View file

@ -111,6 +111,30 @@ describe('getDaemonToken', () => {
});
}
// The restore half has to re-define the property with value/writable/
// configurable or window.sessionStorage stays a throwing getter for the
// rest of the file — keep the dance in one place.
async function withSessionStorageThrowing<T>(
run: () => Promise<T>,
): Promise<T> {
const original = window.sessionStorage;
Object.defineProperty(window, 'sessionStorage', {
get() {
throw new Error('storage disabled');
},
configurable: true,
});
try {
return await run();
} finally {
Object.defineProperty(window, 'sessionStorage', {
value: original,
writable: true,
configurable: true,
});
}
}
it('reads the token from the URL fragment', async () => {
setupToken('', '#token=frag-secret');
const mod = await import('./daemon');
@ -167,25 +191,61 @@ describe('getDaemonToken', () => {
});
it('degrades gracefully when sessionStorage throws', async () => {
const original = window.sessionStorage;
Object.defineProperty(window, 'sessionStorage', {
get() {
throw new Error('storage disabled');
},
configurable: true,
});
try {
await withSessionStorageThrowing(async () => {
setupToken('', '#token=frag-secret');
const mod = await import('./daemon');
// Same-load behavior is unaffected; only refresh persistence is lost.
expect(mod.getDaemonToken()).toBe('frag-secret');
} finally {
Object.defineProperty(window, 'sessionStorage', {
value: original,
writable: true,
configurable: true,
});
});
describe('hasReloadSurvivableDaemonToken', () => {
it('is true when the URL fragment carries a token', async () => {
setupToken('', '#token=frag-secret');
const mod = await import('./daemon');
expect(mod.hasReloadSurvivableDaemonToken()).toBe(true);
});
it('is true when the query parameter carries a token', async () => {
setupToken('?token=query-secret', '');
const mod = await import('./daemon');
expect(mod.hasReloadSurvivableDaemonToken()).toBe(true);
});
it('is true when a per-tab persisted token exists', async () => {
window.sessionStorage.setItem('qwen-daemon-token', 'stored-secret');
setupToken('', '#/chat');
const mod = await import('./daemon');
expect(mod.hasReloadSurvivableDaemonToken()).toBe(true);
});
it('is false when neither the URL nor storage has a token', async () => {
setupToken('', '#/chat');
const mod = await import('./daemon');
expect(mod.hasReloadSurvivableDaemonToken()).toBe(false);
});
it('is false when storage is unavailable and the URL has no token', async () => {
await withSessionStorageThrowing(async () => {
setupToken('', '#/chat');
const mod = await import('./daemon');
expect(mod.hasReloadSurvivableDaemonToken()).toBe(false);
});
}
});
it('is false when the in-memory cache holds a token a reload would lose', async () => {
await withSessionStorageThrowing(async () => {
setupToken('', '#token=boot-secret');
const mod = await import('./daemon');
// Warms the in-memory cache while the persist throws. The predicate
// must NOT consult getDaemonToken(): after boot it always reports a
// token, which would fail-open the reload.
expect(mod.getDaemonToken()).toBe('boot-secret');
// Models removeDaemonTokenFromUrl(): the URL no longer carries it.
setupToken('', '#/chat');
expect(mod.hasReloadSurvivableDaemonToken()).toBe(false);
});
});
});
});

View file

@ -54,20 +54,48 @@ export function persistDaemonToken(token: string): void {
}
}
/**
* The one parse of the URL token grammar: `#token=` (preferred unlike a
* query param it is never sent to the server, so it stays out of access logs
* and Referer headers; this is what `qwen serve --open` uses) with `?token=`
* as legacy fallback (the dev launcher, hand-built URLs). Shared by
* `getDaemonToken()` (which caches and persists the result) and
* `hasReloadSurvivableDaemonToken()` (which must not touch the cache), so the
* accepted spellings can never drift apart.
*/
function readTokenFromLocation(): string | undefined {
if (typeof window === 'undefined') return undefined;
const fromHash = new URLSearchParams(
window.location.hash.replace(/^#/, ''),
).get('token');
return (
fromHash ||
new URLSearchParams(window.location.search).get('token') ||
undefined
);
}
/**
* Whether a fresh load of this page could still authenticate: a token in the
* URL or in the per-tab persisted copy survives a reload. Retry affordances
* that reload the page must check this first when the token lives only in
* this module's memory (URL stripped at boot, persist threw), a reload
* strands the shell unauthenticated. Must not consult `getDaemonToken()`:
* its in-memory cache always reports a token after boot.
*/
export function hasReloadSurvivableDaemonToken(): boolean {
return (
readTokenFromLocation() !== undefined ||
readStoredDaemonToken() !== undefined
);
}
export function getDaemonToken(): string | undefined {
if (cachedDaemonToken) return cachedDaemonToken;
if (typeof window === 'undefined') {
return undefined;
}
// Prefer the URL fragment (#token=) — unlike a ?token= query it is never
// sent to the server, so it stays out of access logs and Referer headers
// (this is what `qwen serve --open` now uses). Fall back to ?token= for
// backward compatibility (e.g. the dev launcher / hand-built URLs).
const fromHash = new URLSearchParams(
window.location.hash.replace(/^#/, ''),
).get('token');
const fromUrl =
fromHash || new URLSearchParams(window.location.search).get('token') || '';
const fromUrl = readTokenFromLocation();
if (fromUrl) {
// Persist per-tab so the token survives navigations that do not carry it.
// sessionStorage (not localStorage) keeps the token scoped to this tab and

View file

@ -1251,7 +1251,10 @@ describe('DaemonWorkspaceProvider', () => {
});
});
it('throws when useDaemonWorkspace is used without provider', async () => {
// Exercises the strict hook with no provider above it. Helper-local
// container/root — the describe-scoped pair stays owned by
// renderWithProvider, and no mounted tree leaks past the helper's return.
function renderBareConsumer(): Error | undefined {
let error: Error | undefined;
function Harness() {
@ -1263,19 +1266,283 @@ describe('DaemonWorkspaceProvider', () => {
return null;
}
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<Harness />);
const bareContainer = document.createElement('div');
document.body.appendChild(bareContainer);
const bareRoot = createRoot(bareContainer);
act(() => {
bareRoot.render(<Harness />);
});
act(() => {
bareRoot.unmount();
});
bareContainer.remove();
return error;
}
expect(error?.message).toContain(
it('throws when useDaemonWorkspace is used without provider', () => {
expect(renderBareConsumer()?.message).toContain(
'useDaemonWorkspace must be used within DaemonWorkspaceProvider',
);
});
describe('useDaemonWorkspace guard diagnostics', () => {
const REGISTRY_KEY = '__qwenWebShellDaemonWorkspaceProviderCopies';
type Registry = Map<string, 'rendered' | 'provided'>;
function readRegistry(): Registry {
const scope = globalThis as typeof globalThis & {
[REGISTRY_KEY]?: Registry;
};
const registry = scope[REGISTRY_KEY];
if (!registry) throw new Error('provider copy registry missing');
return registry;
}
function swapRegistry(next: Registry): () => void {
const scope = globalThis as typeof globalThis & {
[REGISTRY_KEY]?: Registry;
};
const saved = scope[REGISTRY_KEY];
scope[REGISTRY_KEY] = next;
return () => {
scope[REGISTRY_KEY] = saved;
};
}
it('reports the consumer as outside the subtree once this module copy rendered a provider', async () => {
await renderWithProvider(null);
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
// A same-element re-render skips the contextValue memo (its deps are
// unchanged), so only the render-phase record runs — the copy must not
// downgrade from 'provided' because of it.
act(() => {
root?.render(
<DaemonWorkspaceProvider baseUrl="http://127.0.0.1:4170">
{null}
</DaemonWorkspaceProvider>,
);
});
expect(renderBareConsumer()?.message).toContain(
'outside its live subtree',
);
});
it('reports when no provider has rendered in this page', () => {
const restore = swapRegistry(new Map());
try {
expect(renderBareConsumer()?.message).toContain(
'no DaemonWorkspaceProvider has rendered in this page',
);
} finally {
restore();
}
});
it('reports duplicate module copies with both copy ids', () => {
const restore = swapRegistry(
new Map([['https://example.test/stale-chunk.js#abc123', 'provided']]),
);
try {
const error = renderBareConsumer();
expect(error?.message).toContain(
'https://example.test/stale-chunk.js#abc123',
);
expect(error?.message).toContain('duplicate copies');
// The hook's own id carries its module URL, so the message points at
// the offending chunk — a bare random id would fail this.
expect(error?.message).toMatch(/DaemonWorkspaceProvider\.tsx#/);
} finally {
restore();
}
});
it('reports duplicate copies even when this copy also rendered a provider', async () => {
await renderWithProvider(null);
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
const registry = readRegistry();
registry.set('https://example.test/second-copy.js#zz99', 'provided');
try {
const error = renderBareConsumer();
expect(error?.message).toContain('duplicate copies');
expect(error?.message).toContain(
'https://example.test/second-copy.js#zz99',
);
} finally {
registry.delete('https://example.test/second-copy.js#zz99');
}
});
it('reports a provider that rendered without an active client', async () => {
// Isolate from earlier tests: this module copy is already 'provided' in
// the ambient registry, and 'provided' is terminal.
const restore = swapRegistry(new Map());
let error: Error | undefined;
function Harness() {
try {
useDaemonWorkspace();
} catch (e) {
error = e as Error;
}
return null;
}
try {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<DaemonWorkspaceProvider
baseUrl="http://127.0.0.1:4170"
autoConnect={false}
>
<Harness />
</DaemonWorkspaceProvider>,
);
});
expect(error?.message).toContain('without an active client');
} finally {
restore();
}
});
it('never evicts the live copy when foreign copies accumulate past the cap', async () => {
const restore = swapRegistry(new Map());
try {
await renderWithProvider(null);
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
const registry = readRegistry();
const ownId = [...registry.keys()][0];
if (!ownId) throw new Error('live copy was not registered');
// 8 === MAX_TRACKED_PROVIDER_COPIES in the provider module.
for (let i = 0; i < 8; i++) {
registry.set(`https://example.test/copy-${i}.js#x${i}`, 'provided');
}
// A re-render that skips the contextValue memo re-records the live
// copy; the eviction must target the oldest *foreign* entry.
act(() => {
root?.render(
<DaemonWorkspaceProvider baseUrl="http://127.0.0.1:4170">
{null}
</DaemonWorkspaceProvider>,
);
});
expect(registry.size).toBe(8);
expect(registry.has(ownId)).toBe(true);
} finally {
restore();
}
});
it('still names the no-active-client cause when a copy that provided loses its client', async () => {
const restore = swapRegistry(new Map());
let error: Error | undefined;
function Harness() {
try {
useDaemonWorkspace();
} catch (e) {
error = e as Error;
}
return null;
}
try {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<DaemonWorkspaceProvider baseUrl="http://127.0.0.1:4170">
<Harness />
</DaemonWorkspaceProvider>,
);
});
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(error).toBeUndefined();
// The copy stays 'provided' (terminal), so the message must admit the
// lost-client cause rather than only naming placement.
await act(async () => {
root?.render(
<DaemonWorkspaceProvider
baseUrl="http://127.0.0.1:4170"
autoConnect={false}
>
<Harness />
</DaemonWorkspaceProvider>,
);
});
expect(error?.message).toContain('no active client');
} finally {
restore();
}
});
it('registers this module copy once across context recomputes', async () => {
const restore = swapRegistry(new Map());
try {
let context: DaemonWorkspaceContextValue | undefined;
function Harness() {
context = useOptionalDaemonWorkspace();
return null;
}
await renderWithProvider(<Harness />);
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
await act(async () => {
await context?.refreshCapabilities?.();
});
expect(readRegistry().size).toBe(1);
} finally {
restore();
}
});
it('bounds the registry so repeated module re-evaluation cannot grow it without limit', async () => {
const seeded: Registry = new Map(
Array.from({ length: 8 }, (_, i) => [
`https://example.test/copy-${i}.js#x`,
'provided' as const,
]),
);
const restore = swapRegistry(seeded);
try {
await renderWithProvider(null);
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
const registry = readRegistry();
expect(registry.size).toBe(8);
expect(registry.has('https://example.test/copy-0.js#x')).toBe(false);
} finally {
restore();
}
});
});
it('exposes workspace actions', async () => {
let actions: DaemonWorkspaceActions | undefined;

View file

@ -16,6 +16,7 @@ import {
import type { DaemonBrand, DaemonCapabilities } from '@qwen-code/sdk/daemon';
import { DaemonClient, DaemonHttpError } from '@qwen-code/sdk/daemon';
import { createDaemonWorkspaceActions } from './actions.js';
import { setBoundedMapEntry } from '../../utils/bounded-map.js';
import type {
DaemonWorkspaceContextValue,
DaemonWorkspaceProviderProps,
@ -27,6 +28,51 @@ const DaemonWorkspaceContext = createContext<
DaemonWorkspaceContextValue | undefined
>(undefined);
// Module-copy marker for diagnosing "must be used within" failures. When the
// page ends up with two copies of this module (a Vite dev module-graph hiccup,
// a host bundling both web-shell entries), each copy has its own
// DaemonWorkspaceContext, so a consumer resolved to one copy reads no provider
// even though a provider from the other copy is mounted. The registry records
// which module copies rendered a provider — and whether they produced a
// context value — so the strict hook's error can say which case it hit. The
// id carries the module URL so the message points at the offending chunk; the
// esbuild iife build for export documents lowers import.meta to {}, hence the
// guarded fallback. Diagnostic only — the context itself is never shared.
const moduleUrl =
typeof import.meta.url === 'string' && import.meta.url
? import.meta.url
: 'web-shell/DaemonWorkspaceProvider';
const moduleInstanceId = `${moduleUrl}#${Math.random().toString(36).slice(2, 8)}`;
type ProviderCopyState =
// Rendered, but never produced a context value (e.g. autoConnect={false}).
| 'rendered'
// Produced a context value at least once. Terminal state for the copy: a
// discarded or unmounted render keeps it, and the guard's wording hedges.
| 'provided';
const PROVIDER_REGISTRY_KEY = '__qwenWebShellDaemonWorkspaceProviderCopies';
// Hot re-evaluation mints a fresh id per save; cap so a long dev session
// cannot grow the breadcrumb (or the error message) without bound.
const MAX_TRACKED_PROVIDER_COPIES = 8;
function providerCopyRegistry(): Map<string, ProviderCopyState> {
const scope = globalThis as typeof globalThis & {
[PROVIDER_REGISTRY_KEY]?: Map<string, ProviderCopyState>;
};
return (scope[PROVIDER_REGISTRY_KEY] ??= new Map());
}
function recordProviderCopy(id: string, provided: boolean): void {
const registry = providerCopyRegistry();
const next: ProviderCopyState =
provided || registry.get(id) === 'provided' ? 'provided' : 'rendered';
// Re-recording moves the entry to the newest position, so the cap evicts
// stale copies (e.g. from hot re-evaluations whose provider is gone),
// never a live one — a live provider re-records on every render.
setBoundedMapEntry(registry, id, next, MAX_TRACKED_PROVIDER_COPIES);
}
// Module-level sentinel for deferred-disposal StrictMode guard.
// See the useEffect cleanup in DaemonWorkspaceProvider for details.
let pendingDisposeClient: DaemonClient | undefined;
@ -52,6 +98,9 @@ export function DaemonWorkspaceProvider({
transport,
children,
}: DaemonWorkspaceProviderProps) {
// Render-phase so the breadcrumb exists before any child can throw, and
// recorded even when no client is built (autoConnect={false}).
recordProviderCopy(moduleInstanceId, false);
const client = useMemo(
() =>
autoConnect ? new DaemonClient({ baseUrl, token, transport }) : undefined,
@ -356,6 +405,7 @@ export function DaemonWorkspaceProvider({
const contextValue = useMemo<DaemonWorkspaceContextValue | undefined>(() => {
if (!client) return undefined;
recordProviderCopy(moduleInstanceId, true);
return {
client,
token,
@ -397,8 +447,30 @@ export function DaemonWorkspaceProvider({
export function useDaemonWorkspace(): DaemonWorkspaceContextValue {
const context = useContext(DaemonWorkspaceContext);
if (!context) {
const registry = providerCopyRegistry();
const ownState = registry.get(moduleInstanceId);
const foreignIds = [...registry.keys()].filter(
(id) => id !== moduleInstanceId,
);
const detail =
foreignIds.length > 0
? `a DaemonWorkspaceProvider rendered from module copy ` +
`${foreignIds.join(', ')}, but this hook resolved module copy ` +
`${moduleInstanceId} — the page holds duplicate copies of the ` +
`DaemonWorkspaceProvider module`
: ownState === 'provided'
? 'a DaemonWorkspaceProvider from this module copy has rendered, ' +
'so this consumer is outside its live subtree, that provider ' +
'has unmounted, or it currently has no active client ' +
'(autoConnect is false)'
: ownState === 'rendered'
? 'a DaemonWorkspaceProvider from this module copy has rendered ' +
'without an active client (e.g. autoConnect is false), or ' +
'has since unmounted, so it provides no workspace context'
: 'no DaemonWorkspaceProvider has rendered in this page';
throw new Error(
'useDaemonWorkspace must be used within DaemonWorkspaceProvider',
`useDaemonWorkspace must be used within DaemonWorkspaceProvider ` +
`(${detail})`,
);
}
return context;

View file

@ -6,6 +6,7 @@ import {
useWorkspaceEventSignals,
} from '@qwen-code/web-shell/daemon-react-sdk';
import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
import { setBoundedMapEntry } from '../utils/bounded-map';
const SESSION_ARTIFACTS_FEATURE = 'session_artifacts';
const MAX_CACHED_SESSIONS = 20;
@ -19,13 +20,12 @@ function cacheArtifacts<Owner>(
artifacts: DaemonSessionArtifact[],
hydratedOwner?: Owner,
): void {
cache.delete(sessionKey);
cache.set(sessionKey, { artifacts, hydratedOwner });
while (cache.size > MAX_CACHED_SESSIONS) {
const oldest = cache.keys().next().value;
if (!oldest) break;
cache.delete(oldest);
}
setBoundedMapEntry(
cache,
sessionKey,
{ artifacts, hydratedOwner },
MAX_CACHED_SESSIONS,
);
}
// A stable empty array for sessions whose artifact list cannot load (e.g. a

View file

@ -17,6 +17,9 @@ interface CapturedWorkspaceSessionProps {
const testState = vi.hoisted(() => ({
props: undefined as CapturedWorkspaceSessionProps | undefined,
throwOnRender: false,
tokenSurvivesReload: true,
renderCount: 0,
}));
vi.mock('react-dom/client', async (importOriginal) => ({
@ -28,6 +31,10 @@ vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
}));
vi.mock('./components/WorkspaceSessionProvider', () => ({
WorkspaceSessionProvider: (props: CapturedWorkspaceSessionProps) => {
testState.renderCount += 1;
if (testState.throwOnRender) {
throw new Error('render boom');
}
testState.props = props;
return null;
},
@ -35,6 +42,7 @@ vi.mock('./components/WorkspaceSessionProvider', () => ({
vi.mock('./config/daemon', () => ({
getDaemonBaseUrl: () => '',
getDaemonToken: () => 'token',
hasReloadSurvivableDaemonToken: () => testState.tokenSurvivesReload,
persistDaemonToken: vi.fn(),
removeDaemonTokenFromUrl: vi.fn(),
waitForDaemonTokenMessage: vi.fn(),
@ -48,6 +56,9 @@ describe('StandaloneApp', () => {
beforeEach(() => {
testState.props = undefined;
testState.throwOnRender = false;
testState.tokenSurvivesReload = true;
testState.renderCount = 0;
window.history.replaceState(null, '', '/');
container = document.createElement('div');
document.body.appendChild(container);
@ -57,6 +68,124 @@ describe('StandaloneApp', () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
// A failing assertion mid-test must not leak the console.error spy into
// later tests in this file.
vi.restoreAllMocks();
});
it('reloads the page when the root error fallback retry is clicked', () => {
testState.throwOnRender = true;
const reload = vi.fn();
vi.stubGlobal('location', { ...window.location, reload });
// The boundary logs the caught error; keep the test output clean.
vi.spyOn(console, 'error').mockImplementation(() => {});
act(() => root.render(<StandaloneApp daemonToken="token" />));
const retry = container.querySelector('button');
expect(retry?.textContent).toBe('Reload page');
expect(reload).not.toHaveBeenCalled();
act(() => {
retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(reload).toHaveBeenCalledTimes(1);
});
it('falls back to an in-place reset when the token cannot survive a reload', () => {
testState.throwOnRender = true;
testState.tokenSurvivesReload = false;
const reload = vi.fn();
vi.stubGlobal('location', { ...window.location, reload });
vi.spyOn(console, 'error').mockImplementation(() => {});
act(() => root.render(<StandaloneApp daemonToken="token" />));
const retry = container.querySelector('button');
expect(retry?.textContent).toBe('Try again');
// React replays a throwing render before the boundary catches it, so pin
// the delta across the retry, not an absolute render count.
const rendersBeforeRetry = testState.renderCount;
// The transient cause is gone by the time the user retries.
testState.throwOnRender = false;
act(() => {
retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(reload).not.toHaveBeenCalled();
expect(testState.renderCount).toBeGreaterThan(rendersBeforeRetry);
expect(container.querySelector('button')).toBeNull();
});
it('reloads even without a survivable token when no token was resolved at boot', () => {
// Tokenless trusted loopback: nothing a reload could strand.
testState.throwOnRender = true;
testState.tokenSurvivesReload = false;
const reload = vi.fn();
vi.stubGlobal('location', { ...window.location, reload });
vi.spyOn(console, 'error').mockImplementation(() => {});
act(() => root.render(<StandaloneApp daemonToken={undefined} />));
const retry = container.querySelector('button');
expect(retry?.textContent).toBe('Reload page');
expect(reload).not.toHaveBeenCalled();
act(() => {
retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(reload).toHaveBeenCalledTimes(1);
});
it('carries the live theme and language across a reload retry', () => {
window.history.replaceState(null, '', '/?theme=light&language=zh-CN');
act(() => root.render(<StandaloneApp daemonToken="token" />));
// Boot consumes the one-shot params, then strips them from the URL.
expect(window.location.search).not.toContain('theme=');
expect(testState.props?.webShellProps.theme).toBe('light');
expect(testState.props?.webShellProps.language).toBe('zh-CN');
act(() => {
testState.props?.webShellProps.onSessionIdChange?.(
'session-1',
'workspace-1',
);
});
testState.throwOnRender = true;
vi.spyOn(console, 'error').mockImplementation(() => {});
act(() => {
testState.props?.webShellProps.onSessionIdChange?.(
'session-2',
'workspace-1',
);
});
// Stub after the last navigation so the snapshot href is current —
// the handler builds the reload URL from window.location.href.
const reload = vi.fn();
vi.stubGlobal('location', { ...window.location, reload });
const replaceState = vi.spyOn(window.history, 'replaceState');
const retry = container.querySelector('button');
expect(retry?.textContent).toBe('重新加载');
act(() => {
retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(reload).toHaveBeenCalledTimes(1);
const reloadUrl = String(replaceState.mock.calls.at(-1)?.[2]);
expect(reloadUrl).toContain('theme=light');
expect(reloadUrl).toContain('language=zh-CN');
// The reload must land on the live session URL, not a stale snapshot.
expect(reloadUrl).toContain('session-2');
expect(reloadUrl).toContain('workspace=workspace-1');
});
it('keeps the controlled session target in sync with URL changes', () => {

View file

@ -15,6 +15,7 @@ import { WorkspaceSessionProvider } from './components/WorkspaceSessionProvider'
import {
getDaemonBaseUrl,
getDaemonToken,
hasReloadSurvivableDaemonToken,
removeDaemonTokenFromUrl,
waitForDaemonTokenMessage,
} from './config/daemon';
@ -204,6 +205,20 @@ export function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
DaemonProductSessionContext | undefined
>(() => getSessionContextFromUrl());
const baseUrl = DAEMON_BASE_URL || window.location.origin;
// One-shot ?theme=/?language=/?lang= params are consumed by the useState
// initializers above; strip them once mounted so a bookmarked URL cannot
// keep overriding stored preferences on later loads. (The reload retry
// re-adds the live values, which the next boot consumes and strips again.)
useEffect(() => {
const url = new URL(window.location.href);
const before = url.search;
url.searchParams.delete('theme');
url.searchParams.delete('language');
url.searchParams.delete('lang');
if (url.search !== before) {
window.history.replaceState(null, '', url);
}
}, []);
// Keep the <html> theme class and <meta name="theme-color"> in sync with
// the React theme so mobile status bars / overscroll backgrounds stay
// consistent when the user toggles or when ?theme= lands via URL.
@ -255,9 +270,38 @@ export function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
return (
<ErrorBoundary
label="web-shell-root"
fallback={(error, reset) => (
<RootErrorFallback error={error} onRetry={reset} language={language} />
)}
fallback={(error, reset) => {
// A reload rebuilds the module graph — the only recovery for a crash
// rooted in page-level module state (e.g. a duplicated context module
// in dev). Reload is only safe when it cannot strand a credential:
// either no token was resolved at boot (tokenless trusted loopback —
// nothing to strand; reads the prop, never getDaemonToken(), whose
// in-memory cache always reports a token after boot), or a token
// survives in the URL or per-tab storage. Otherwise fall back to an
// in-place reset, which keeps the in-memory token.
const canReload = !daemonToken || hasReloadSurvivableDaemonToken();
return (
<RootErrorFallback
error={error}
onRetry={() => {
if (!canReload) {
reset();
return;
}
// Session switches strip the one-shot theme/language params
// from the URL; carry the live values so the reloaded page
// comes back as the user had it.
const url = new URL(window.location.href);
url.searchParams.set('theme', theme);
url.searchParams.set('language', language);
window.history.replaceState(null, '', url);
window.location.reload();
}}
retryMode={canReload ? 'reload' : 'reset'}
language={language}
/>
);
}}
>
<BrowserTurnNotifications language={language}>
<DaemonWorkspaceProvider baseUrl={baseUrl} token={daemonToken}>

View file

@ -0,0 +1,44 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { setBoundedMapEntry } from './bounded-map.js';
describe('setBoundedMapEntry', () => {
it('inserts under the cap without evicting', () => {
const map = new Map<string, number>();
setBoundedMapEntry(map, 'a', 1, 2);
setBoundedMapEntry(map, 'b', 2, 2);
expect([...map.keys()]).toEqual(['a', 'b']);
});
it('evicts the least-recently-used entry past the cap', () => {
const map = new Map<string, number>();
setBoundedMapEntry(map, 'a', 1, 2);
setBoundedMapEntry(map, 'b', 2, 2);
setBoundedMapEntry(map, 'c', 3, 2);
expect([...map.keys()]).toEqual(['b', 'c']);
});
it('moves a re-set key to the newest position before evicting', () => {
const map = new Map<string, number>();
setBoundedMapEntry(map, 'a', 1, 2);
setBoundedMapEntry(map, 'b', 2, 2);
setBoundedMapEntry(map, 'a', 10, 2);
setBoundedMapEntry(map, 'c', 3, 2);
expect(map.get('a')).toBe(10);
expect(map.has('b')).toBe(false);
expect([...map.keys()]).toEqual(['a', 'c']);
});
it('evicts an empty-string key rather than stopping at it', () => {
const map = new Map<string, number>();
setBoundedMapEntry(map, '', 0, 1);
setBoundedMapEntry(map, 'a', 1, 1);
expect(map.has('')).toBe(false);
expect(map.get('a')).toBe(1);
});
});

View file

@ -0,0 +1,28 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Sets `key` to `value`, marks it most-recently-used, and evicts
* least-recently-used entries while the map exceeds `cap`. Callers own the
* cap because legitimate budgets differ per cache.
*/
export function setBoundedMapEntry<V>(
map: Map<string, V>,
key: string,
value: V,
cap: number,
): void {
// delete + set moves an existing key to the newest position; Map.set alone
// keeps it in place, which would let the eviction loop below remove a live,
// frequently re-recorded key.
map.delete(key);
map.set(key, value);
while (map.size > cap) {
const oldest = map.keys().next().value;
if (oldest === undefined) break;
map.delete(oldest);
}
}

View file

@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { createHash } from 'node:crypto';
import { build } from 'esbuild';
import { findUnexpectedImportMeta } from './import-meta-guard.mjs';
const assetsDir = dirname(fileURLToPath(import.meta.url));
const srcDir = join(assetsDir, 'src');
@ -213,6 +214,23 @@ const documentBuildResult = await build({
},
});
// esbuild lowers import.meta to {} under iife, and the export document
// evaluates the bundle top-level, so any stray import.meta read (e.g.
// import.meta.env) would throw in every exported file. Tolerate exactly the
// deliberate guarded read inside the prebuilt web-shell transcript entry and
// fail on anything else. No logLevel/logOverride here: silencing the warning
// class would also hide every other warning this build emits, and
// logOverride 'silent' would empty result.warnings and vacate this check.
const unexpectedImportMeta = findUnexpectedImportMeta(
documentBuildResult.warnings,
);
if (unexpectedImportMeta.length > 0) {
throw new Error(
'export-transcript-document build: unexpected import.meta use in ' +
unexpectedImportMeta.join(', '),
);
}
const documentJsBundle = documentBuildResult.outputFiles.find((file) =>
file.path.endsWith('.js'),
);

View file

@ -0,0 +1,54 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// The export-document build inlines its renderer into a standalone HTML file,
// so it must run as format:'iife' — under which esbuild lowers import.meta
// to {}. Any import.meta read in the graph then breaks every exported
// document at evaluation time (import.meta.env.X becomes ({}).env.X and
// throws; import.meta.url becomes ({}).url). Exactly one read is deliberate:
// DaemonWorkspaceProvider.tsx's guarded module-copy id, which arrives via the
// prebuilt web-shell transcript bundle.
const TOLERATED_BUNDLE = /web-shell[/\\]dist[/\\]transcript\.js$/;
// The deliberate read is one ternary
// (`typeof import.meta.url === 'string' && import.meta.url ? … : …`), and
// esbuild emits one empty-import-meta warning per import.meta occurrence —
// three for that ternary. Re-measure this constant in the same change as any
// refactor of that expression.
export const TOLERATED_TRANSCRIPT_IMPORT_META_READS = 3;
function formatLocation(warning) {
const location = warning.location;
if (!location) return '<unknown>';
return `${location.file}:${location.line}:${location.column}`;
}
/**
* Returns the empty-import-meta warnings the build must not tolerate: any
* read outside the prebuilt transcript bundle, or reads from it beyond the
* one deliberate site (a file-level allowlist alone cannot see a fourth read
* landing in the same bundle).
*/
export function findUnexpectedImportMeta(warnings) {
const unexpected = [];
let tolerated = 0;
for (const warning of warnings) {
if (warning.id !== 'empty-import-meta') continue;
const file = warning.location?.file ?? '';
if (!TOLERATED_BUNDLE.test(file)) {
unexpected.push(`${formatLocation(warning)}`);
continue;
}
tolerated += 1;
if (tolerated > TOLERATED_TRANSCRIPT_IMPORT_META_READS) {
unexpected.push(
`${formatLocation(warning)} (beyond the tolerated reads)`,
);
}
}
return unexpected;
}

View file

@ -0,0 +1,160 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'node:child_process';
import {
cpSync,
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import {
findUnexpectedImportMeta,
TOLERATED_TRANSCRIPT_IMPORT_META_READS,
} from '../../packages/web-templates/src/export-html/import-meta-guard.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..', '..');
const templatesDir = path.join(root, 'packages', 'web-templates');
const documentEntry = path.join(
templatesDir,
'src',
'export-html',
'src',
'document-main.tsx',
);
const exportDist = path.join(templatesDir, 'src', 'export-html', 'dist');
const prebuiltTranscript = path.join(
root,
'packages',
'web-shell',
'dist',
'transcript.js',
);
describe('findUnexpectedImportMeta', () => {
const at = (file, line = 1, column = 1) => ({
id: 'empty-import-meta',
location: { file, line, column },
});
it('tolerates exactly the deliberate reads in the prebuilt transcript bundle', () => {
const warnings = Array.from(
{ length: TOLERATED_TRANSCRIPT_IMPORT_META_READS },
() => at('packages/web-shell/dist/transcript.js', 13513, 35),
);
expect(findUnexpectedImportMeta(warnings)).toEqual([]);
});
it('flags a fourth read arriving through the same bundle', () => {
const warnings = Array.from(
{ length: TOLERATED_TRANSCRIPT_IMPORT_META_READS + 1 },
() => at('packages/web-shell/dist/transcript.js', 13513, 35),
);
const unexpected = findUnexpectedImportMeta(warnings);
expect(unexpected).toHaveLength(1);
expect(unexpected[0]).toContain('beyond the tolerated reads');
});
it('flags reads from any other file, with line and column', () => {
const unexpected = findUnexpectedImportMeta([
at('src/export-html/src/document-main.tsx', 42, 7),
]);
expect(unexpected).toEqual(['src/export-html/src/document-main.tsx:42:7']);
});
it('flags reads with no location at all', () => {
expect(findUnexpectedImportMeta([{ id: 'empty-import-meta' }])).toEqual([
'<unknown>',
]);
});
it('ignores other warning ids', () => {
expect(
findUnexpectedImportMeta([
{ id: 'duplicate-object-key', location: { file: 'x.js' } },
]),
).toEqual([]);
});
});
// The e2e half drives the real build. build.mjs rm -rf's its dist/ up front
// and only recreates it on success, so a probe-induced throw leaves the tree
// empty — snapshot and restore it, or every passing run poisons the worktree.
const hasPrebuiltTranscript = existsSync(prebuiltTranscript);
const distSnapshot = hasPrebuiltTranscript
? mkdtempSync(path.join(tmpdir(), 'export-html-dist-'))
: undefined;
function snapshotDist() {
if (existsSync(exportDist)) {
cpSync(exportDist, path.join(distSnapshot, 'dist'), { recursive: true });
}
}
function restoreDist() {
rmSync(exportDist, { recursive: true, force: true });
const snapshot = path.join(distSnapshot, 'dist');
if (existsSync(snapshot)) {
cpSync(snapshot, exportDist, { recursive: true });
}
}
afterEach(() => {
if (distSnapshot) rmSync(distSnapshot, { recursive: true, force: true });
});
describe.skipIf(!hasPrebuiltTranscript)(
'export-html import.meta guard (end-to-end)',
() => {
function runBuildWithProbe(targetFile, probeLine) {
const original = readFileSync(targetFile, 'utf8');
writeFileSync(targetFile, `${original}\n${probeLine}\n`);
try {
snapshotDist();
return spawnSync(process.execPath, ['src/export-html/build.mjs'], {
cwd: templatesDir,
encoding: 'utf8',
timeout: 120_000,
});
} finally {
writeFileSync(targetFile, original);
restoreDist();
}
}
it('fails the build when an import.meta read lands in the document entry', () => {
const result = runBuildWithProbe(
documentEntry,
'globalThis.__importMetaProbe = import.meta.url;',
);
const output = `${result.stdout}\n${result.stderr}`;
expect(result.status).not.toBe(0);
expect(output).toContain('unexpected import.meta use');
// The failing build must not leave the release-gated artifact wiped.
expect(
existsSync(path.join(exportDist, 'export-transcript-document.js')),
).toBe(true);
});
it('fails the build on a fourth import.meta read inside the prebuilt transcript bundle', () => {
const result = runBuildWithProbe(
prebuiltTranscript,
'globalThis.__importMetaProbe2 = import.meta.url;',
);
expect(result.status).not.toBe(0);
expect(
existsSync(path.join(exportDist, 'export-transcript-document.js')),
).toBe(true);
});
},
);