Files
qwen-code/packages/web-shell/client/components/RootErrorFallback.test.tsx
Shaojin WenandQwen-Coder f6540d1994 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 <[email protected]>

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

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

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-09-10 06:33:33 +00:00

171 lines
5.5 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { ErrorBoundary } from './ErrorBoundary';
import { RootErrorFallback } from './RootErrorFallback';
import type { WebShellLanguage } from '../i18n';
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
function mount(node: React.ReactNode): { container: HTMLElement; root: Root } {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(node));
const entry = { container, root };
mounted.push(entry);
return entry;
}
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
// 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,
}: {
children: React.ReactNode;
language?: WebShellLanguage;
}) {
return (
<ErrorBoundary
label="web-shell-root"
fallback={(error, reset) => (
<RootErrorFallback error={error} onRetry={reset} language={language} />
)}
>
{children}
</ErrorBoundary>
);
}
describe('RootErrorFallback', () => {
it('renders English copy by default', () => {
const { container } = mount(
<RootErrorFallback error={new Error('boom detail')} onRetry={() => {}} />,
);
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Something went wrong');
expect(alert?.textContent).toContain('Try again');
});
it('shows the raw error message only in dev, never in production', () => {
vi.stubEnv('DEV', true);
const dev = mount(
<RootErrorFallback error={new Error('boom detail')} onRetry={() => {}} />,
);
expect(dev.container.textContent).toContain('boom detail');
vi.stubEnv('DEV', false);
const prod = mount(
<RootErrorFallback error={new Error('boom detail')} onRetry={() => {}} />,
);
expect(prod.container.textContent).not.toContain('boom detail');
// The user-facing copy still renders in production.
expect(prod.container.textContent).toContain('Something went wrong');
expect(prod.container.textContent).toContain('Try again');
});
it('renders zh-CN copy when language is zh-CN', () => {
const { container } = mount(
<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', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
function BrokenApp(): React.ReactElement {
throw new Error('app render exploded');
}
const { container } = mount(
<RootBoundary>
<BrokenApp />
</RootBoundary>,
);
const alert = container.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
expect(alert?.textContent).toContain('Something went wrong');
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()
// re-renders the children; flipping this before the click lets the now-
// healthy App mount.
let shouldThrow = true;
function FlakyApp(): React.ReactElement {
if (shouldThrow) throw new Error('transient');
return <div data-testid="app">recovered</div>;
}
const { container } = mount(
<RootBoundary>
<FlakyApp />
</RootBoundary>,
);
expect(container.querySelector('[role="alert"]')).not.toBeNull();
expect(container.querySelector('[data-testid="app"]')).toBeNull();
shouldThrow = false;
const retryButton = container.querySelector('button');
expect(retryButton).not.toBeNull();
act(() => {
retryButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(container.querySelector('[data-testid="app"]')?.textContent).toBe(
'recovered',
);
expect(container.querySelector('[role="alert"]')).toBeNull();
});
});