mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* feat(web-shell): Add workspace file browser and previews * fix(web-shell): Restore source preview CI fixtures and notices * fix(web-shell): Address file pane review blockers * fix(web-shell): Resolve review regressions and strengthen file checks --------- Co-authored-by: 钉萁 <[email protected]>
675 lines
26 KiB
TypeScript
675 lines
26 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import postcss, { type Rule } from 'postcss';
|
|
import { FILES_MESSAGES_EN } from './components/artifacts/files-messages';
|
|
import { LIVE_MESSAGES_EN } from './live/messages';
|
|
import { COLLAB_MESSAGES_EN } from './components/workspace-agents/messages';
|
|
|
|
const DIST_DIR = resolve(__dirname, '../dist');
|
|
const DIST_PATH = resolve(DIST_DIR, 'index.js');
|
|
const TRANSCRIPT_DIST_PATH = resolve(DIST_DIR, 'transcript.js');
|
|
const SERVICE_WORKER_DIST_PATH = resolve(DIST_DIR, 'sw.js');
|
|
|
|
function readBundle(): string {
|
|
return readFileSync(DIST_PATH, 'utf8');
|
|
}
|
|
|
|
function readTranscriptBundle(): string {
|
|
return readFileSync(TRANSCRIPT_DIST_PATH, 'utf8');
|
|
}
|
|
|
|
function readPackageJavascript(): string {
|
|
return readdirSync(DIST_DIR)
|
|
.filter((fileName) => fileName.endsWith('.js'))
|
|
.map((fileName) => readFileSync(resolve(DIST_DIR, fileName), 'utf8'))
|
|
.join('\n');
|
|
}
|
|
|
|
function readInjectedCss(bundle = readBundle()): string {
|
|
const match = bundle.match(/^const __qwenWebShellCss=("(?:[^"\\]|\\.)*");/);
|
|
if (!match?.[1]) throw new Error('Injected component CSS not found');
|
|
return JSON.parse(match[1]) as string;
|
|
}
|
|
|
|
function enclosingLayer(rule: Rule): string | undefined {
|
|
let parent = rule.parent;
|
|
while (parent) {
|
|
if (parent.type === 'atrule' && parent.name.toLowerCase() === 'layer') {
|
|
return parent.params;
|
|
}
|
|
parent = parent.parent;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
describe('build artifact — package boundary', () => {
|
|
it.each(['.', 'assets'])(
|
|
'ships the spreadsheet worker in a lazy chunk in dist/%s',
|
|
(directory) => {
|
|
const chunkDir = resolve(DIST_DIR, directory);
|
|
const files = readdirSync(chunkDir).filter((name) =>
|
|
name.endsWith('.js'),
|
|
);
|
|
const previewName = files.find((name) =>
|
|
/^SpreadsheetPreview-.*\.js$/.test(name),
|
|
);
|
|
expect(previewName).toBeDefined();
|
|
const preview = readFileSync(resolve(chunkDir, previewName!), 'utf8');
|
|
expect(preview).toContain('data-web-shell-excel-preview');
|
|
expect(preview).toContain('new Worker(');
|
|
expect(preview).toContain('createObjectURL');
|
|
const otherChunks = files
|
|
.filter((name) => name !== previewName)
|
|
.map((name) => readFileSync(resolve(chunkDir, name), 'utf8'))
|
|
.join('\n');
|
|
expect(otherChunks.includes(`import("./${previewName}")`)).toBe(true);
|
|
expect(
|
|
/(?:from|import)\s*["']\.\/SpreadsheetPreview-[^"']+\.js["']/.test(
|
|
otherChunks,
|
|
),
|
|
).toBe(false);
|
|
expect(otherChunks.includes('data-web-shell-excel-preview')).toBe(false);
|
|
const workerCode = preview.match(/["']([A-Za-z0-9+/=]{1000,})["']/)?.[1];
|
|
expect(workerCode).toBeDefined();
|
|
const decodedWorker = Buffer.from(workerCode!, 'base64').toString();
|
|
// Parser code must stay inside the worker, even if the UI remains lazy.
|
|
for (const marker of [
|
|
'Worksheet index is outside the preview range.',
|
|
'Cannot merge already merged cells',
|
|
]) {
|
|
expect(
|
|
decodedWorker.includes(marker),
|
|
`Worker contains ${marker}`,
|
|
).toBe(true);
|
|
expect(
|
|
preview.includes(marker),
|
|
`Parser leaked into preview: ${marker}`,
|
|
).toBe(false);
|
|
expect(
|
|
otherChunks.includes(marker),
|
|
`Parser leaked outside worker: ${marker}`,
|
|
).toBe(false);
|
|
}
|
|
expect(otherChunks.includes(workerCode!)).toBe(false);
|
|
// Embedded hosts must not resolve external ExcelJS/SSF packages themselves.
|
|
expect(
|
|
/(?:from|import)\s*["'](?:exceljs|ssf)["']/.test(
|
|
`${preview}\n${otherChunks}\n${decodedWorker}`,
|
|
),
|
|
).toBe(false);
|
|
},
|
|
);
|
|
|
|
it('does not depend on @qwen-code/webui', () => {
|
|
const bundle = readPackageJavascript();
|
|
expect(bundle).not.toContain('@qwen-code/webui');
|
|
});
|
|
|
|
it('keeps standalone service-worker registration out of embedded bundles', () => {
|
|
expect(readPackageJavascript()).not.toContain(
|
|
'service worker registration failed',
|
|
);
|
|
});
|
|
|
|
it('owns the DaemonSessionProvider source code', () => {
|
|
const bundle = readPackageJavascript();
|
|
expect(bundle).toContain(
|
|
'useDaemonSessionNotices must be used within DaemonSessionProvider',
|
|
);
|
|
});
|
|
|
|
it('externalizes react and react-dom', () => {
|
|
const bundle = readPackageJavascript();
|
|
expect(bundle).toContain('from "react"');
|
|
expect(bundle).toContain('from "react/jsx-runtime"');
|
|
expect(bundle).not.toContain('react/jsx-dev-runtime');
|
|
expect(bundle).not.toContain('jsxDEV');
|
|
expect(bundle).not.toContain('fileName:');
|
|
});
|
|
|
|
it('externalizes @qwen-code/sdk subpaths', () => {
|
|
const bundle = readPackageJavascript();
|
|
// Should not contain raw SDK implementation
|
|
expect(bundle).not.toMatch(/DaemonSessionClient\s*\{/);
|
|
});
|
|
|
|
it('bundles Pierre highlighter dependencies instead of resolving the host Shiki major', () => {
|
|
const notices = readFileSync(resolve(DIST_DIR, '../NOTICES.txt'), 'utf8');
|
|
for (const name of ['diffs', 'trees', 'theming', 'theme']) {
|
|
expect(notices).toContain(`@pierre/${name}@`);
|
|
}
|
|
const treeAttribution = notices
|
|
.split('@pierre/trees@')[1]!
|
|
.split(
|
|
'============================================================',
|
|
)[0]!;
|
|
// This is the bundled tree's NOTICE credit, not a separate dependency entry.
|
|
expect(treeAttribution).toContain('Original license for');
|
|
expect(treeAttribution).toContain('headless-tree/core');
|
|
expect(notices).toContain('shiki@4.');
|
|
expect(readPackageJavascript()).not.toMatch(
|
|
/(?:from\s*|import\s*\()\s*["']@pierre\//,
|
|
);
|
|
const previews = readdirSync(DIST_DIR).filter((name) =>
|
|
/^PierreFilePreview-.*\.js$/.test(name),
|
|
);
|
|
expect(previews).toHaveLength(1);
|
|
const preview = readFileSync(resolve(DIST_DIR, previews[0]!), 'utf8');
|
|
expect(preview).not.toMatch(/from\s+["']shiki["']/);
|
|
expect(preview).toContain('createJavaScriptRegexEngine');
|
|
const hostHighlighter = readdirSync(DIST_DIR).find((name) =>
|
|
/^codeHighlighter-.*\.js$/.test(name),
|
|
);
|
|
expect(hostHighlighter).toBeDefined();
|
|
expect(readFileSync(resolve(DIST_DIR, hostHighlighter!), 'utf8')).toContain(
|
|
'from "shiki"',
|
|
);
|
|
});
|
|
|
|
it('scopes every component CSS rule to a WebShell root', () => {
|
|
const unscoped: string[] = [];
|
|
postcss.parse(readInjectedCss()).walkRules((rule) => {
|
|
let parent = rule.parent;
|
|
while (parent) {
|
|
if (
|
|
parent.type === 'atrule' &&
|
|
parent.name.toLowerCase().endsWith('keyframes')
|
|
) {
|
|
return;
|
|
}
|
|
parent = parent.parent;
|
|
}
|
|
if (
|
|
!rule.selector.includes('[data-web-shell-root]') &&
|
|
!rule.selector.includes('[data-web-shell-portal-root]')
|
|
) {
|
|
unscoped.push(rule.selector);
|
|
}
|
|
});
|
|
expect(unscoped).toEqual([]);
|
|
});
|
|
|
|
it('applies Tailwind theme variables to WebShell roots', () => {
|
|
const themeRules: string[] = [];
|
|
postcss.parse(readInjectedCss()).walkRules((rule) => {
|
|
if (
|
|
rule.nodes.some(
|
|
(node) => node.type === 'decl' && node.prop === '--spacing',
|
|
)
|
|
) {
|
|
themeRules.push(rule.selector);
|
|
}
|
|
});
|
|
|
|
expect(themeRules).toContain(
|
|
':is([data-web-shell-root]:where([data-web-shell-shadcn]), [data-web-shell-portal-root]:where([data-web-shell-shadcn]))',
|
|
);
|
|
expect(themeRules).not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining(':root'),
|
|
expect.stringContaining(':host'),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('keeps scrollbar styles available inside component and portal roots', () => {
|
|
let scrollbarRootRule: Rule | undefined;
|
|
postcss.parse(readInjectedCss()).walkRules((rule) => {
|
|
if (
|
|
rule.nodes.some(
|
|
(node) =>
|
|
node.type === 'decl' &&
|
|
node.prop === 'scrollbar-color' &&
|
|
node.value === 'var(--scrollbar-thumb) var(--scrollbar-track)',
|
|
)
|
|
) {
|
|
scrollbarRootRule = rule;
|
|
}
|
|
});
|
|
|
|
expect(scrollbarRootRule?.selector).toContain(
|
|
':is([data-web-shell-root]:where([data-web-shell-shadcn]),[data-web-shell-portal-root]:where([data-web-shell-shadcn]))',
|
|
);
|
|
});
|
|
|
|
it('keeps component resets and utilities above plain host selectors', () => {
|
|
const root = postcss.parse(readInjectedCss());
|
|
const rules: Rule[] = [];
|
|
root.walkRules((rule) => rules.push(rule));
|
|
|
|
const headingIndex = rules.findIndex(
|
|
(rule) =>
|
|
rule.selector.includes(':where(h1,h2,h3,h4,h5,h6)') &&
|
|
rule.nodes.some(
|
|
(node) =>
|
|
node.type === 'decl' &&
|
|
node.prop === 'font' &&
|
|
node.value === 'inherit',
|
|
),
|
|
);
|
|
const formControlIndex = rules.findIndex(
|
|
(rule) =>
|
|
rule.selector.includes(
|
|
':where(button,input,optgroup,select,textarea)',
|
|
) &&
|
|
rule.nodes.some(
|
|
(node) =>
|
|
node.type === 'decl' &&
|
|
node.prop === 'font' &&
|
|
node.value === 'inherit',
|
|
),
|
|
);
|
|
const utilityIndex = rules.findIndex((rule) =>
|
|
rule.selector.includes('.px-2\\.5'),
|
|
);
|
|
const cssModuleIndex = rules.findIndex((rule) =>
|
|
rule.nodes.some(
|
|
(node) => node.type === 'decl' && node.prop === '--chat-content-width',
|
|
),
|
|
);
|
|
|
|
expect(headingIndex).toBeGreaterThanOrEqual(0);
|
|
expect(formControlIndex).toBeGreaterThanOrEqual(0);
|
|
expect(utilityIndex).toBeGreaterThan(headingIndex);
|
|
expect(utilityIndex).toBeGreaterThan(formControlIndex);
|
|
expect(cssModuleIndex).toBeGreaterThan(utilityIndex);
|
|
|
|
const headingRule = rules[headingIndex]!;
|
|
const formControlRule = rules[formControlIndex]!;
|
|
const utilityRule = rules[utilityIndex]!;
|
|
const cssModuleRule = rules[cssModuleIndex]!;
|
|
|
|
expect(enclosingLayer(headingRule)).toBeUndefined();
|
|
expect(enclosingLayer(formControlRule)).toBeUndefined();
|
|
expect(enclosingLayer(utilityRule)).toBeUndefined();
|
|
expect(headingRule.selector).toContain(
|
|
':is([data-web-shell-root]:where([data-web-shell-shadcn]),[data-web-shell-portal-root]:where([data-web-shell-shadcn]))',
|
|
);
|
|
expect(cssModuleRule.selector).toContain(
|
|
':where([data-web-shell-root][data-web-shell-shadcn]',
|
|
);
|
|
expect(cssModuleRule.selector).not.toContain(':is([data-web-shell-root]');
|
|
|
|
const conflictingLayers: string[] = [];
|
|
root.walkAtRules('layer', (atRule) => {
|
|
if (
|
|
['theme', 'base', 'components', 'utilities'].includes(atRule.params)
|
|
) {
|
|
conflictingLayers.push(atRule.params);
|
|
}
|
|
});
|
|
expect(conflictingLayers).toEqual([]);
|
|
});
|
|
|
|
it('removes the transcript width cap from fullscreen panels', () => {
|
|
let fullscreenRule: Rule | undefined;
|
|
postcss.parse(readInjectedCss()).walkRules((rule) => {
|
|
if (
|
|
rule.selector.includes('panelFullscreen') &&
|
|
rule.nodes.some(
|
|
(node) =>
|
|
node.type === 'decl' &&
|
|
node.prop === '--chat-content-width' &&
|
|
node.value === '100%',
|
|
)
|
|
) {
|
|
fullscreenRule = rule;
|
|
}
|
|
});
|
|
|
|
expect(fullscreenRule).toBeDefined();
|
|
expect(fullscreenRule?.selector).toMatch(
|
|
/\._panelFullscreen_[A-Za-z0-9_]+$/,
|
|
);
|
|
expect(fullscreenRule?.selector).not.toContain('panelDrawer');
|
|
expect(fullscreenRule?.selector).not.toContain(':not(');
|
|
});
|
|
|
|
it('prefixes global CSS registrations and animations', () => {
|
|
const unscoped: string[] = [];
|
|
postcss.parse(readInjectedCss()).walkAtRules((atRule) => {
|
|
const name = atRule.name.toLowerCase();
|
|
if (
|
|
name.endsWith('keyframes') &&
|
|
!atRule.params.startsWith('qwen-web-shell-')
|
|
) {
|
|
unscoped.push(`@${atRule.name} ${atRule.params}`);
|
|
}
|
|
if (
|
|
name === 'property' &&
|
|
!atRule.params.startsWith('--qwen-web-shell-')
|
|
) {
|
|
unscoped.push(`@property ${atRule.params}`);
|
|
}
|
|
});
|
|
expect(unscoped).toEqual([]);
|
|
});
|
|
|
|
it('ships the ::selection highlight for message content in the lib bundle (#8214)', () => {
|
|
// The defensive ::selection rule must reach embedded deployments -
|
|
// i.e. it must be in the component-scoped CSS injected into dist/index.js,
|
|
// not only the standalone app's standalone.css. Asserting the rule is
|
|
// present and scoped under the WebShell root pins the lib-bundle fix.
|
|
let matched: Rule | undefined;
|
|
postcss.parse(readInjectedCss()).walkRules((rule) => {
|
|
// Match the effect (selectable rows get a ::selection rule scoped to
|
|
// the WebShell root), not the exact notation - a maintainer changing
|
|
// `background` to `background-color` (the CSS Pseudo-Elements-4 name)
|
|
// should not break this pin while the e2e one stays green.
|
|
if (
|
|
rule.selector.includes('[data-user-selectable]') &&
|
|
rule.selector.includes('::selection')
|
|
) {
|
|
matched = rule;
|
|
}
|
|
});
|
|
expect(
|
|
matched,
|
|
'::selection rule for [data-user-selectable] missing from lib bundle',
|
|
).toBeDefined();
|
|
expect(matched?.selector).toContain('[data-web-shell-root]');
|
|
expect(
|
|
matched?.nodes.some(
|
|
(n) =>
|
|
n.type === 'decl' &&
|
|
(n.prop === 'background' || n.prop === 'background-color'),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('ships self-contained KaTeX styles and fonts for embedded transcripts', () => {
|
|
const css = readInjectedCss();
|
|
const root = postcss.parse(css);
|
|
let mathmlRule: Rule | undefined;
|
|
let hasInlineFont = false;
|
|
|
|
root.walkRules((rule) => {
|
|
if (rule.selector.includes('.katex-mathml')) {
|
|
mathmlRule = rule;
|
|
}
|
|
});
|
|
root.walkAtRules('font-face', (atRule) => {
|
|
if (
|
|
atRule.nodes?.some(
|
|
(node) =>
|
|
node.type === 'decl' &&
|
|
node.prop === 'src' &&
|
|
node.value.includes('data:font/woff2;base64,'),
|
|
)
|
|
) {
|
|
hasInlineFont = true;
|
|
}
|
|
});
|
|
|
|
expect(mathmlRule?.selector).toContain('[data-web-shell-root]');
|
|
expect(hasInlineFont).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('build artifact — Live Voice capture worklet', () => {
|
|
// audioWorklet.addModule() is subject to the Web Shell CSP (`script-src
|
|
// 'self'`, no `data:` or `blob:`). At ~2 KB the worklet is under Vite's
|
|
// inline limit, and an inlined module fails to load — silently, because the
|
|
// client then falls back to the deprecated main-thread capture node. No
|
|
// unit test can see that; only the emitted files can.
|
|
const ASSETS_DIR = resolve(DIST_DIR, 'assets');
|
|
|
|
it('ships the worklet as a same-origin file in the app build', () => {
|
|
const emitted = readdirSync(ASSETS_DIR).filter((fileName) =>
|
|
/^capture-worklet-.+\.js$/.test(fileName),
|
|
);
|
|
expect(emitted).toHaveLength(1);
|
|
const source = readFileSync(resolve(ASSETS_DIR, emitted[0]!), 'utf8');
|
|
expect(source).toContain("registerProcessor('qwen-live-capture'");
|
|
// Loaded as a classic module by the audio thread: nothing to resolve.
|
|
expect(source).not.toMatch(/^\s*import\s/m);
|
|
});
|
|
|
|
it('references that file from the app bundle, not an inlined data: URL', () => {
|
|
const appJavascript = readdirSync(ASSETS_DIR)
|
|
.filter((fileName) => fileName.endsWith('.js'))
|
|
.map((fileName) => readFileSync(resolve(ASSETS_DIR, fileName), 'utf8'))
|
|
.join('\n');
|
|
expect(appJavascript).toMatch(/assets\/capture-worklet-[^"'`]+\.js/);
|
|
expect(appJavascript).not.toContain(
|
|
// base64 of the worklet's licence header, i.e. the module inlined
|
|
'data:text/javascript;base64,LyoqCiAqIEBsaWNlbnNl',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('build artifact — standalone PWA', () => {
|
|
it('emits a classic root service worker tied to the package version', () => {
|
|
const worker = readFileSync(SERVICE_WORKER_DIST_PATH, 'utf8');
|
|
const packageJson = JSON.parse(
|
|
readFileSync(resolve(__dirname, '../package.json'), 'utf8'),
|
|
) as { version: string };
|
|
|
|
expect(worker).not.toMatch(/^\s*(?:import|export)\b/m);
|
|
expect(worker).not.toContain('import.meta');
|
|
expect(worker).toContain('qwen-code-shell-v1-');
|
|
expect(worker).toContain(JSON.stringify(packageJson.version));
|
|
});
|
|
});
|
|
|
|
describe('build artifact — transcript entry (#11031)', () => {
|
|
// `@qwen-code/web-shell/transcript` exists so the self-contained
|
|
// `/export html` document renderer can bundle the read-only transcript
|
|
// without the interactive shell. Nothing here is enforced by tree shaking:
|
|
// the package root injects its stylesheet as a top-level side effect, which
|
|
// no bundler can drop, so the boundary has to be a real entry point.
|
|
it('does not pull the editor stack into the transcript entry', () => {
|
|
// Import specifiers of externals survive minification verbatim, so their
|
|
// absence is a reliable signal that the module never entered the graph.
|
|
// The signal lives in the JS only: injectCssModules (vite.lib.config.ts)
|
|
// prepends the stylesheet as a single-line `__qwenWebShellCss` constant,
|
|
// and Tailwind v4 compiles classes from every scanned source file rather
|
|
// than this entry's graph, so the CSS carries e.g. drawer.tsx's
|
|
// `data-[vaul-drawer-direction=…]` selectors even though no transcript JS
|
|
// imports vaul. Guard the JS remainder; if the injection shape changes
|
|
// the replace() is a no-op and these checks fail loudly, not falsely.
|
|
const js = readTranscriptBundle().replace(
|
|
/^const __qwenWebShellCss=[^\n]*\n/,
|
|
'',
|
|
);
|
|
expect(js).not.toContain('@codemirror/');
|
|
expect(js).not.toContain('"codemirror"');
|
|
expect(js).not.toContain('vaul');
|
|
});
|
|
|
|
it('keeps the transcript entry a fraction of the interactive entry', () => {
|
|
// The daemon hook runtime is NOT absent from this entry — MessageList
|
|
// renders McpStatusMessage, TasksStatusMessage and the artifact turn
|
|
// outputs, and those call the strict useDaemonActions /
|
|
// useDaemonWorkspace hooks, so the provider guards ship. Asserting their
|
|
// absence would assert something this entry does not deliver (see the
|
|
// docblock in client/transcript.ts and #11100).
|
|
//
|
|
// What the entry does deliver is a bounded payload, so bound it. The JS
|
|
// remainder measured 1,298,657 UTF-16 code units on main at fa4a4c92 and
|
|
// 1,300,341 here after adding author attribution for multi-agent
|
|
// transcripts. O3 result summaries bring it to 1,306,052, and
|
|
// session-agent replies (status, steps, token usage, squad labels) to
|
|
// 1,315,297 at e951f96; that rendering is intentional transcript UI.
|
|
// main grew the same entry in parallel — 1,309,207 at 1aba19c878, plus
|
|
// 3,033 for table fullscreen and Shadow DOM keyboard handling, reaching
|
|
// 1,312,240. Merging the two measures 1,320,366 in CI at d4304811. The W2
|
|
// managed cwd slice (#13760) adds its strings to the shared i18n table
|
|
// every entry ships — no interactive cwd code enters this graph — and
|
|
// measured 1,323,120 before main's host-status copy. After rebasing onto
|
|
// c34b8423, main measures 1,321,586 and this branch 1,324,022; substituting
|
|
// main's i18n table yields an identical JS remainder. The selection-copy
|
|
// menu strings (#13833) land in that same shared table — the menu itself
|
|
// stays stubbed out of this entry — and measure 1,325,251. The
|
|
// action-reminder help copy (#13832) entered that same table only after
|
|
// the 1,325,251 measurement was taken, so the entry was already past the
|
|
// 1,326,500 ceiling at that re-measure's own merge commit (#13964); the
|
|
// Managed task panel's copy (#13936) stays stubbed out of this entry.
|
|
// At 008c0de7 the remainder measures 1,327,531. The read-only Excel
|
|
// preview puts its ten excel.* strings into that same shared table in
|
|
// both locales; its parser, worker and markup stay inside the lazily
|
|
// imported SpreadsheetPreview chunk, so only the copy enters this graph.
|
|
// That is the +2,000 this branch carried over the 1,325,000 budget, so
|
|
// the merged ceiling is the two budgets added — 1,331,500 — rather than a
|
|
// fresh measurement of a merged build. The ceiling leaves a small margin
|
|
// around that intentional UI; re-measure and lower it if the entry gets
|
|
// leaner.
|
|
const js = readTranscriptBundle().replace(
|
|
/^const __qwenWebShellCss=[^\n]*\n/,
|
|
'',
|
|
);
|
|
expect(js.length).toBeLessThan(1_331_500);
|
|
});
|
|
|
|
it('keeps selection copying and its converter out of the transcript entry', () => {
|
|
const js = readTranscriptBundle().replace(
|
|
/^const __qwenWebShellCss=[^\n]*\n/,
|
|
'',
|
|
);
|
|
expect(js).not.toContain('data-selection-copy-math');
|
|
expect(js).not.toContain('selectionCopyListWrapper');
|
|
});
|
|
|
|
it('excludes Files-only messages from the transcript entry', () => {
|
|
const bundle = readTranscriptBundle();
|
|
for (const key of [
|
|
'files.description',
|
|
'files.reloadUnavailable',
|
|
'files.sourceTooLarge',
|
|
] as const)
|
|
expect(bundle).not.toContain(FILES_MESSAGES_EN[key]);
|
|
});
|
|
|
|
it('carries no Live Voice strings and looks none up', () => {
|
|
// vite.lib.config.ts swaps client/live/messages.ts for an empty stub in
|
|
// this build, which keeps ~200 entries out of every exported document. It
|
|
// is only safe while nothing in the bundle asks for one: a lookup here
|
|
// would render as its key name. Key literals survive minification, so
|
|
// their absence shows that no Live surface entered the graph — the app
|
|
// itself may use them anywhere (the composer's add menu does).
|
|
const js = readTranscriptBundle().replace(
|
|
/^const __qwenWebShellCss=[^\n]*\n/,
|
|
'',
|
|
);
|
|
const keys = Object.keys(LIVE_MESSAGES_EN);
|
|
expect(keys.length).toBeGreaterThan(50);
|
|
const present = keys.filter(
|
|
(key) => js.includes(`"${key}"`) || js.includes(`'${key}'`),
|
|
);
|
|
expect(present).toEqual([]);
|
|
expect(js).not.toContain('Talk in this browser');
|
|
});
|
|
|
|
it('keeps dictation strings in the interactive entry only', () => {
|
|
const js = readTranscriptBundle().replace(
|
|
/^const __qwenWebShellCss=[^\n]*\n/,
|
|
'',
|
|
);
|
|
expect(js).not.toMatch(/["'`]voice\./);
|
|
const interactive = readPackageJavascript();
|
|
expect(interactive).toContain('voice.error.noModel');
|
|
expect(interactive).toContain('Dismiss voice message');
|
|
expect(interactive).toContain('当前工作区未配置语音模型。');
|
|
});
|
|
|
|
it('carries no collaboration strings and looks none up', () => {
|
|
// Same arrangement as Live Voice: the collaboration dictionary is stubbed
|
|
// in this build, which is only safe while no transcript surface asks for
|
|
// one of its keys.
|
|
const js = readTranscriptBundle().replace(
|
|
/^const __qwenWebShellCss=[^\n]*\n/,
|
|
'',
|
|
);
|
|
const keys = Object.keys(COLLAB_MESSAGES_EN);
|
|
expect(keys.length).toBeGreaterThan(50);
|
|
expect(
|
|
keys.filter((key) => js.includes(`"${key}"`) || js.includes(`'${key}'`)),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('carries no Managed task-panel strings or lookups', () => {
|
|
const js = readTranscriptBundle().replace(
|
|
/^const __qwenWebShellCss=[^\n]*\n/,
|
|
'',
|
|
);
|
|
expect(js.includes('managed.tasks.')).toBe(false);
|
|
});
|
|
|
|
it('carries no settings strings and looks none up', () => {
|
|
// Same contract as the Live Voice stub above, for
|
|
// client/settings/messages.ts: a settings.* lookup here would render as
|
|
// its key name, and a settings.* entry left in i18n.tsx would ship in
|
|
// every exported document.
|
|
const js = readTranscriptBundle().replace(
|
|
/^const __qwenWebShellCss=[^\n]*\n/,
|
|
'',
|
|
);
|
|
expect(js).not.toMatch(/["'`]settings\./);
|
|
});
|
|
|
|
it('keeps trajectory filter strings in the interactive entry only', () => {
|
|
const js = readTranscriptBundle().replace(
|
|
/^const __qwenWebShellCss=[^\n]*\n/,
|
|
'',
|
|
);
|
|
expect(js).not.toMatch(/["'`]trajectory\.(filter|window)\./);
|
|
expect(readPackageJavascript()).toContain('trajectory.window.older');
|
|
expect(readPackageJavascript()).toContain('trajectory.filter.search');
|
|
expect(readPackageJavascript()).toContain('Search loaded records');
|
|
});
|
|
|
|
it('still carries what a transcript actually renders', () => {
|
|
const bundle = readTranscriptBundle();
|
|
expect(bundle).toContain('react-markdown');
|
|
expect(bundle).toContain('WebShellTranscript');
|
|
});
|
|
|
|
it('injects its stylesheet under its own entry key', () => {
|
|
// Separate rollup runs produce different stylesheets per entry, so the
|
|
// injection guard is keyed per entry — a shared key would let whichever
|
|
// entry loaded first suppress the other's rules.
|
|
expect(readBundle()).toContain('data-qwen-web-shell-entry="index"');
|
|
expect(readTranscriptBundle()).toContain(
|
|
'data-qwen-web-shell-entry="transcript"',
|
|
);
|
|
// Both keep the shared marker that shadow-root style adoption reads.
|
|
expect(readTranscriptBundle()).toContain(
|
|
's.dataset.qwenWebShell="component"',
|
|
);
|
|
});
|
|
|
|
it('keeps KaTeX border overrides after Tailwind preflight', () => {
|
|
for (const bundle of [readBundle(), readTranscriptBundle()]) {
|
|
const rules: Rule[] = [];
|
|
postcss
|
|
.parse(readInjectedCss(bundle))
|
|
.walkRules((rule) => rules.push(rule));
|
|
const preflightIndex = rules.findIndex(
|
|
(rule) =>
|
|
rule.selector.includes('[data-web-shell-shadcn]') &&
|
|
rule.selector.includes('::backdrop') &&
|
|
rule.nodes.some(
|
|
(node) =>
|
|
node.type === 'decl' &&
|
|
node.prop === 'border-color' &&
|
|
node.value === 'var(--border)',
|
|
),
|
|
);
|
|
const katexIndex = rules.findIndex(
|
|
(rule) =>
|
|
rule.selector.includes('.katex *') &&
|
|
rule.nodes.some(
|
|
(node) =>
|
|
node.type === 'decl' &&
|
|
node.prop === 'border-color' &&
|
|
node.value === 'currentColor',
|
|
),
|
|
);
|
|
|
|
expect(preflightIndex).toBeGreaterThanOrEqual(0);
|
|
expect(katexIndex).toBeGreaterThan(preflightIndex);
|
|
}
|
|
});
|
|
});
|