From ad22e6c80fc8bccffb6ceb494080ab1526e17d38 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:51:21 +0800 Subject: [PATCH 1/3] build(inspector): compile DevTools from pinned npm sources --- THIRD_PARTY_NOTICES.md | 3 +- .../shared-inspector/page.expected.md | 1 + apps/web/tests/inspector-source-build.e2e.ts | 74 +++++ docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- package.json | 4 +- .../experimental/inspector/README.i18n.yaml | 4 +- packages/experimental/inspector/README.md | 6 +- packages/experimental/inspector/README.zh.md | 6 +- .../devtools/node-runtime-unavailable.js | 2 - packages/experimental/inspector/package.json | 1 + .../inspector/scripts/build-devtools.ts | 158 +++------- .../inspector/scripts/devtools/inputs.ts | 48 +++ .../inspector/scripts/devtools/source.ts | 83 +++++ .../inspector/scripts/devtools/vite.ts | 132 ++++++++ .../inspector/scripts/download-devtools.ts | 220 ------------- .../inspector/scripts/prune-lighthouse.ts | 258 --------------- .../tests/devtools-assets.host.spec.ts | 63 ---- .../inspector/tests/devtools-build.e2e.ts | 60 ++++ .../tests/devtools-locales.host.spec.ts | 22 -- .../tests/devtools-source.host.spec.ts | 293 ++++++++++++++++++ .../tests/prune-lighthouse.host.spec.ts | 145 --------- .../tests/workspace-prefetch.host.spec.ts | 17 +- pnpm-lock.yaml | 11 + scripts/gen-third-party-notices.spec.ts | 2 + scripts/gen-third-party-notices.ts | 6 +- 27 files changed, 774 insertions(+), 853 deletions(-) create mode 100644 apps/web/tests/inspector-source-build.e2e.ts delete mode 100644 packages/experimental/inspector/assets/devtools/node-runtime-unavailable.js create mode 100644 packages/experimental/inspector/scripts/devtools/inputs.ts create mode 100644 packages/experimental/inspector/scripts/devtools/source.ts create mode 100644 packages/experimental/inspector/scripts/devtools/vite.ts delete mode 100644 packages/experimental/inspector/scripts/download-devtools.ts delete mode 100644 packages/experimental/inspector/scripts/prune-lighthouse.ts delete mode 100644 packages/experimental/inspector/tests/devtools-assets.host.spec.ts create mode 100644 packages/experimental/inspector/tests/devtools-build.e2e.ts delete mode 100644 packages/experimental/inspector/tests/devtools-locales.host.spec.ts create mode 100644 packages/experimental/inspector/tests/devtools-source.host.spec.ts delete mode 100644 packages/experimental/inspector/tests/prune-lighthouse.host.spec.ts diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ac0cb99d37..386689f717 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -78,6 +78,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`big.js`](https://github.com/MikeMcl/big.js) | MIT | | [`buffer`](https://github.com/feross/buffer) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | +| [`chrome-devtools-frontend`](https://github.com/ChromeDevTools/devtools-frontend) | BSD-3-Clause | | [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) | Apache-2.0 | | [`clsx`](https://github.com/lukeed/clsx) | MIT | | [`commander`](https://github.com/tj/commander.js) | MIT | @@ -150,7 +151,7 @@ pnpm applies local patches to the following packages at install time, so shipped ## Chrome DevTools frontend -The optional experimental Inspector distributes a local copy of [Chrome 150.0.7871.186's frontend](https://chrome-devtools-frontend.appspot.com/serve_rev/@ec97cf3bbeea2cb623fbf97c4e3f22f5acb4d568/devtools_app.html). The Chromium [BSD-3-Clause license](packages/experimental/inspector/assets/devtools/LICENSE) is included with the frontend. Copied JavaScript retains its embedded license comments; the resource downloader does not collect separate upstream third-party license files. The Chromium root license does not replace those dependencies' licenses. +The optional experimental Inspector distributes a locally compiled copy of [chrome-devtools-frontend 1.0.1638082](https://www.npmjs.com/package/chrome-devtools-frontend/v/1.0.1638082), from upstream revision [0e1186138ed519d9659c1874bf6375eca4483c72](https://chromium.googlesource.com/devtools/devtools-frontend/+/0e1186138ed519d9659c1874bf6375eca4483c72). The build includes the Chromium [BSD-3-Clause license](packages/experimental/inspector/assets/devtools/LICENSE) and the third-party license and notice files supplied by the npm source. The Chromium root license does not replace those dependencies' licenses. ## Official Claude Code platform payloads diff --git a/apps/web/tests/expected/shared-inspector/page.expected.md b/apps/web/tests/expected/shared-inspector/page.expected.md index 9932a9dbc8..daacf175f0 100644 --- a/apps/web/tests/expected/shared-inspector/page.expected.md +++ b/apps/web/tests/expected/shared-inspector/page.expected.md @@ -7,4 +7,5 @@ - tab "Memory" - tab "Application" - tab "Security" + - tab "Lighthouse" - tab "Recorder" diff --git a/apps/web/tests/inspector-source-build.e2e.ts b/apps/web/tests/inspector-source-build.e2e.ts new file mode 100644 index 0000000000..0dad93374e --- /dev/null +++ b/apps/web/tests/inspector-source-build.e2e.ts @@ -0,0 +1,74 @@ +/** Built npm frontend assets work under a deployment prefix without remote resource requests. */ +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it, onTestFinished } from 'vitest' +import { launchWebScaffold } from './scaffold.ts' + +it.each([ + { locale: 'en-US', preference: 'en-US' }, + { locale: 'zh-CN', preference: 'zh' }, +])('loads English DevTools under a prefix with browser locale $locale and saved preference $preference', async (copy) => { + const scaffold = await launchWebScaffold({ + publicMount: { prefix: 'nested/devtools-test/' }, + profile: { packages: [{ dir: fileURLToPath(new URL('../../../packages/experimental/inspector-profile', import.meta.url)), enabled: true }], hmr: false }, + extraOverlayPath: [ + fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + fileURLToPath(new URL('./fixtures/shared-inspector.patch.yml', import.meta.url)), + ], + }) + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + const page = await browser.newPage({ locale: copy.locale, viewport: { width: 1440, height: 1000 } }) + const login = new URL(scaffold.authenticatedUrl) + login.hostname = '127.0.0.1' + const remote: string[] = [] + const failed: string[] = [] + page.on('pageerror', (error) => { failed.push(error.message) }) + page.on('response', (response) => { + if (response.url().includes('/inspector/devtools/') && response.status() >= 400) failed.push(`${response.status()} ${response.url()}`) + }) + await page.route('**/*', (route) => { + const url = new URL(route.request().url()) + if ((url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== login.origin) { + remote.push(url.href) + return route.abort() + } + return route.continue() + }) + await page.goto(login.href, { waitUntil: 'load' }) + await page.evaluate((locale) => { localStorage.setItem('language', JSON.stringify(locale)) }, copy.preference) + const base = new URL('./', page.url()) + const entry = new URL('inspector/devtools/devtools_app.html?disableLocaleInfoBar=true', base) + await page.goto(entry.href, { waitUntil: 'load' }) + try { await page.getByRole('tab', { name: 'Console', exact: true }).waitFor() } catch (error) { + throw new Error(JSON.stringify({ remote, failed, page: await page.locator('body').ariaSnapshot() }), { cause: error }) + } + await page.getByRole('tab', { name: 'Sources', exact: true }).click() + const formatted = await page.evaluate(async (url) => { + return await new Promise((resolve, reject) => { + const worker = new Worker(url, { type: 'module' }) + const timeout = setTimeout(() => { worker.terminate(); reject(new Error('Formatter worker did not settle')) }, 30000) + worker.onerror = (event) => { clearTimeout(timeout); worker.terminate(); reject(new Error(event.message)) } + worker.onmessage = (event: MessageEvent) => { + if (event.data === 'workerReady') { + worker.postMessage({ method: 'format', params: { mimeType: 'text/javascript', content: 'const value={answer:42};', indentString: ' ' } }) + } else { + clearTimeout(timeout) + worker.terminate() + const data = event.data + if (typeof data !== 'object' || data === null || !('content' in data) || typeof data.content !== 'string') { + reject(new Error('Formatter worker returned no text')) + } else resolve(data.content) + } + } + }) + }, new URL('inspector/devtools/entrypoints/formatter_worker/formatter_worker-entrypoint.js', base).href) + expect(formatted).toContain('answer: 42') + await page.getByRole('tab', { name: 'Memory', exact: true }).click() + await page.getByRole('button', { name: 'Take snapshot', exact: true }).click() + await page.getByRole('columnheader', { name: /Constructor/u }).waitFor() + await page.getByRole('table').first().getByRole('row').nth(1).waitFor() + expect(remote).toEqual([]) + expect(failed).toEqual([]) +}) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index de934fdf8b..0a4130454d 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -15,8 +15,8 @@ en: e86be579be6f1813 zh: 14f6a220337d0861 /development-guide/setup-tutorial/first-time-setup: - en: 0ef25e3ec65c176e - zh: 7a23ff7b283af7b3 + en: bb2bab0ef17bfc32 + zh: 8b7cf7e9e703e6af /development-guide/contributor-reference: en: 3221d1076554001f zh: 29f166be6a5c04d9 diff --git a/docs/development.md b/docs/development.md index 9f05c72e99..847f4235a9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -33,7 +33,7 @@ pnpm install The install also configures worktree-local Lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract. -The root workspace's `postinstall` also prefetches the pinned [DevTools frontend](../packages/experimental/inspector/README.md#use-this-package) from appspot. A cold cache needs network access; download failures stop installation. Run `pnpm run prefetch:devtools` to retry or prepare the cache before an offline build. Builds reuse the cache and download any missing resources. Published CLI and Inspector packages ship the built frontend and have no DevTools installation hook. +The [DevTools frontend](../packages/experimental/inspector/README.md#use-this-package) is compiled locally from a pinned npm source package and Vite. After dependencies are installed, its build needs no network access or browser installation. Workspace and published-package installation run no DevTools resource-download hook; published Inspector packages contain the built frontend. If the hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually: diff --git a/docs/development.zh.md b/docs/development.zh.md index e924cbad84..eb56870e25 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -35,7 +35,7 @@ pnpm install 安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md) 负责钩子路径的安全约定。 -根 workspace 的 `postinstall` 还会从 appspot 预取固定版本的 [DevTools 前端](../packages/experimental/inspector/README.zh.md#use-this-package)。冷缓存需要网络连接;下载失败会中止安装。运行 `pnpm run prefetch:devtools` 可重试下载,或在离线构建前准备缓存。构建复用缓存,并下载缺失的资源。发布的 CLI 与 Inspector 包携带已构建的前端,没有 DevTools 安装钩子。 +[DevTools 前端](../packages/experimental/inspector/README.zh.md#use-this-package)通过固定版本的 npm 源码包和 Vite 在本地编译。依赖安装完成后,构建不需要网络连接或安装浏览器。workspace 与发布包的安装过程都不运行 DevTools 资源下载 hook;发布的 Inspector 包包含已构建的前端。 如果依赖是从缓存恢复或 `postinstall` 被跳过而导致钩子缺失,请手动安装: diff --git a/package.json b/package.json index 8c08bb3851..a83261f697 100644 --- a/package.json +++ b/package.json @@ -205,8 +205,7 @@ "mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts", "start:web": "node --import tsx/esm apps/cli/src/bin.ts web", "dev:web": "tsx scripts/dev-web.ts --poll", - "prefetch:devtools": "tsx packages/experimental/inspector/scripts/download-devtools.ts", - "postinstall": "node scripts/install-lefthook.mjs && pnpm run prefetch:devtools" + "postinstall": "node scripts/install-lefthook.mjs" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:*", @@ -258,6 +257,7 @@ "tsdown": "^0.22.2", "tsx": "^4.22.4", "typescript": "^6.0.3", + "vite": "8.0.16", "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.1.8", "@deepseek-ai/dsh-skill-office": "workspace:*", diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml index bcf4d89a51..d958ef2afe 100644 --- a/packages/experimental/inspector/README.i18n.yaml +++ b/packages/experimental/inspector/README.i18n.yaml @@ -15,8 +15,8 @@ en: 331f844c32631a82 zh: a9bc365f7d61d9ed /deepseek-ai-dsh-experimental-inspector/use-this-package: - en: 85c5a2cf25ac85ce - zh: 15f5b05efeca4369 + en: 426fcb436dd12d08 + zh: 1eb502409031216e /deepseek-ai-dsh-experimental-inspector/runtime-layout: en: 30dad7b7fe3b3465 zh: 2bda395981802f59 diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md index 9848fb898b..032fcc25d8 100644 --- a/packages/experimental/inspector/README.md +++ b/packages/experimental/inspector/README.md @@ -39,11 +39,11 @@ When this component is enabled, **Ctrl/Cmd+Shift+.** toggles **NodeJS Inspector* The Host serves the mirrored DevTools frontend at the application-relative `inspector/devtools/devtools_app.html` path, using the existing Web login. The `inspector/devtools` prefix redirects to that entry; GET and HEAD serve files, missing resources return 404, and other methods return 405. Its `cdp` WebSocket route authenticates the same browser session and forwards only to this Inspector's Worker page target. The bottom panel supplies its claimed `clientSourceId` to the entry and automatic WebSocket endpoint; the selection follows that logical Client across reconnects. An absent selector includes all Clients, an unavailable selected Client leaves Host visible, and empty or repeated selectors reject the upgrade with 400. Explicit `ws` or `wss` query parameters override the automatic endpoint, including its selection. -The build uses Chrome 150.0.7871.186's official appspot CDN frontend, pinned by `DEVTOOLS_REVISION` in [the downloader](scripts/download-devtools.ts). The root workspace's `pnpm install` prefetches its complete input graph (about 93.62 MiB); `pnpm run prefetch:devtools` also runs this step independently. The script follows static and literal dynamic imports, Worker URLs, CSS, images, and upstream locale tables, preserving directories in `.cache/devtools`. Its input manifest records sizes, hashes, dependency edges, and unresolved runtime imports. A complete cache needs no network; missing resources are downloaded during prefetch or build, and download failures fail that command. Published packages carry no download lifecycle hook. The generated hashes describe downloaded bytes, not a committed upstream digest lock. +The build compiles the official `chrome-devtools-frontend@1.0.1638082` TypeScript sources with the root workspace's Vite `8.0.16`. This npm snapshot is an ancestor of the Chromium 150 release branch, not an exact copy of its later stable release. The [source configuration](scripts/devtools/source.ts) and package manifest fix the version. Normal npm installation supplies the source and toolchain; building needs no appspot access, browser download, browser launch, or PAK extraction. Neither workspace installation nor published-package installation runs a DevTools download hook. -The [distribution build](scripts/build-devtools.ts) removes Lighthouse and its DevTools AI audit/report integrations, then recalculates the resource graph before copying reachable files to `lib/devtools`. Other panels remain included. The language list and distributed locale files contain only `en-US` and `zh`; unsupported language preferences resolve to English. Memory's parser Workers retain their relative imports; Performance uses the frontend's CPU-profile recorder for the Host target. The package publishes these local resources, their integrity manifest, and the Chromium license without an npm frontend dependency or runtime CDN requests. +The [distribution build](scripts/build-devtools.ts) emits ESM entries, shared chunks, explicit Workers, injected scripts, and runtime assets into `lib/devtools`. It preserves original module-relative resource URLs, including deployment prefixes. CSS text modules, image variables, and minimal English locale constants replace GN-generated inputs without modifying npm sources. The frontend uses English UIStrings directly from upstream modules, with no translation files or dictionary generator. Other language preferences resolve to English. All upstream panels, including Lighthouse, remain included. Memory retains its parser Worker; Performance records Host CPU. Published packages contain the built assets, integrity manifest, and Chromium license, not the build-time npm source or Vite dependencies. -The entry maps unused Node-only imports to an empty browser module with an exact CSP hash, then loads the same-origin connection bootstrap before DevTools starts. The bottom panel adds `disableLocaleInfoBar=true`; this bootstrap sets DevTools' own `disable-locale-info-bar` preference without changing its language. +The frontend uses browser runtime adapters and excludes Node-only transports at build time, without an import map. Its same-origin connection bootstrap precedes the application entry. The bottom panel adds `disableLocaleInfoBar=true`; the bootstrap sets DevTools' own `disable-locale-info-bar` preference without changing its language. Updating the frontend requires changing the npm pin and source configuration together, then rerunning the panel and Worker checks; incompatible source edits fail the build. ## Runtime layout diff --git a/packages/experimental/inspector/README.zh.md b/packages/experimental/inspector/README.zh.md index c7d98a6acb..2115c6de3f 100644 --- a/packages/experimental/inspector/README.zh.md +++ b/packages/experimental/inspector/README.zh.md @@ -39,11 +39,11 @@ kind: "package-reference" Host 在相对于应用根目录的 `inspector/devtools/devtools_app.html` 路径提供镜像后的 DevTools 前端,沿用现有 Web 登录鉴权。`inspector/devtools` 前缀重定向到该入口;GET 和 HEAD 提供文件,缺失资源返回 404,其他方法返回 405。其 `cdp` WebSocket 路由鉴权同一浏览器会话,只转发到当前 Inspector 的 Worker page target。底部面板将已声明的页面 `clientSourceId` 传给入口及自动 WebSocket 端点,重连时继续选择同一逻辑 Client。没有选择参数时包含全部 Client;指定 Client 不在线时保留 Host;空值或重复参数以 400 拒绝升级。显式 `ws` 或 `wss` query 参数覆盖自动端点及其选择范围。 -构建使用 Chrome 150.0.7871.186 的官方 appspot CDN 前端,由[下载脚本](scripts/download-devtools.ts)中的 `DEVTOOLS_REVISION` 固定版本。根 workspace 的 `pnpm install` 预取完整输入图(约 93.62 MiB);`pnpm run prefetch:devtools` 也可独立运行这一步。脚本递归跟踪静态及字面量动态 import、Worker URL、CSS、图片和上游语言清单,在 `.cache/devtools` 中保留目录结构。输入清单记录体积、hash、依赖边和未静态解析的运行时 import。完整缓存不需要网络;预取或构建会下载缺失资源,下载失败会使对应命令失败。发布包不携带下载生命周期钩子。生成的 hash 描述本次下载的字节,并非提交到仓库的上游摘要锁。 +构建使用官方 `chrome-devtools-frontend@1.0.1638082` TypeScript 源码及根 workspace 的 Vite `8.0.16`。该 npm 快照是 Chromium 150 发布分支的祖先,不是其后续稳定版的逐字节副本。[源码配置](scripts/devtools/source.ts)与包清单共同固定版本。普通 npm 安装提供源码和工具链;构建不需要 appspot、下载或启动浏览器,也不需要提取 PAK。workspace 安装和发布包安装都不运行 DevTools 下载 hook。 -[分发构建](scripts/build-devtools.ts)移除 Lighthouse 及其 DevTools AI 审计、报告集成,再重新计算资源图,将可达文件复制到 `lib/devtools`。其他面板继续保留。语言列表和发布的语言文件仅包含 `en-US` 与 `zh`,不支持的语言偏好会解析为英语。Memory 的解析 Worker 保持相对 import;Performance 使用前端已有的 CPU profile 录制器记录 Host target。本包发布这些本地资源、完整性清单和 Chromium 许可证,不依赖 npm 前端包或运行时 CDN 请求。 +[分发构建](scripts/build-devtools.ts)将 ESM 入口、共享 chunk、显式 Worker、注入脚本与运行时资源输出到 `lib/devtools`,保留原始模块相对资源 URL,包括部署子路径。CSS 文本模块、图片变量和最小英文 locale 常量替代 GN 生成输入,不修改 npm 源码。前端直接使用上游模块内的英文 UIStrings,不包含翻译文件或词条生成器,其他语言偏好回退英文。所有上游面板均保留,包括 Lighthouse。Memory 带有解析 Worker,Performance 记录 Host CPU。发布包包含构建资源、完整性清单和 Chromium license,不包含构建用 npm 源码或 Vite 依赖。 -入口将未使用的 Node 专用导入映射为空浏览器模块,添加精确 CSP hash,再在 DevTools 启动前加载同源连接 bootstrap。底部面板添加 `disableLocaleInfoBar=true`;bootstrap 设置 DevTools 自身的 `disable-locale-info-bar` 偏好,不改变其语言。 +前端在构建时选择浏览器运行时适配器、排除 Node 专用传输,不使用 import map。同源连接 bootstrap 先于应用入口执行。底部面板添加 `disableLocaleInfoBar=true`,bootstrap 设置 DevTools 自己的 `disable-locale-info-bar` 偏好,不改变语言。升级前端时,需要一起调整 npm 版本和源码配置,然后重跑面板和 Worker 检查;不兼容的源码变动会使构建失败。 ## 运行时布局 diff --git a/packages/experimental/inspector/assets/devtools/node-runtime-unavailable.js b/packages/experimental/inspector/assets/devtools/node-runtime-unavailable.js deleted file mode 100644 index 84a5c05fa9..0000000000 --- a/packages/experimental/inspector/assets/devtools/node-runtime-unavailable.js +++ /dev/null @@ -1,2 +0,0 @@ -/** The browser runtime does not access the imports owned by DevTools' Node runtime. */ -export {} diff --git a/packages/experimental/inspector/package.json b/packages/experimental/inspector/package.json index 7b991b7d19..760276dbae 100644 --- a/packages/experimental/inspector/package.json +++ b/packages/experimental/inspector/package.json @@ -62,6 +62,7 @@ "@deepseek-ai/dsh-host-webserver": "workspace:*" }, "devDependencies": { + "chrome-devtools-frontend": "1.0.1638082", "@types/serve-static": "^2.2.0", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@testing-library/react": "^16.1.0", diff --git a/packages/experimental/inspector/scripts/build-devtools.ts b/packages/experimental/inspector/scripts/build-devtools.ts index a1faff4ecd..7b9a7da914 100644 --- a/packages/experimental/inspector/scripts/build-devtools.ts +++ b/packages/experimental/inspector/scripts/build-devtools.ts @@ -1,140 +1,54 @@ -/** Copy the reachable, product-configured portion of the immutable DevTools CDN mirror. */ +/** Compile the fixed npm DevTools sources locally and publish the complete static frontend. */ import { createHash } from 'node:crypto' -import { cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import ts from 'typescript' -import { DEVTOOLS_CDN, DEVTOOLS_CHROMIUM_VERSION, DEVTOOLS_REVISION, downloadDevtools, findDevtoolsDependencies } from './download-devtools.ts' -import { pruneLighthouse } from './prune-lighthouse.ts' +import { buildFrontend } from './devtools/vite.ts' +import { DEVTOOLS_NPM_VERSION, DEVTOOLS_SOURCE_REVISION } from './devtools/source.ts' -const DEVTOOLS_LOCALES = ['en-US', 'zh'] as const +let pending: Promise<{ files: number; bytes: number }> | undefined /** - * Keep the language menu, locale fallback, and resource graph on the same supported locales. - * @param source - Original core/i18n module from the pinned CDN revision. - * @returns Module with its supported-locale array restricted to the distributed languages. - * @throws If the pinned input omits a configured locale or has no unique supported-locale array. - */ -export function configureDevtoolsLocales(source: string): string { - const file = ts.createSourceFile('i18n.js', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS) - const arrays = new Map() - const selected: ts.ArrayLiteralExpression[] = [] - const visit = (node: ts.Node): void => { - if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) - && node.initializer !== undefined && ts.isArrayLiteralExpression(node.initializer)) { - arrays.set(node.name.text, node.initializer) - } - if (ts.isNewExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'I18n' - && node.arguments?.[0] !== undefined && ts.isIdentifier(node.arguments[0])) { - const array = arrays.get(node.arguments[0].text) - if (array === undefined || !array.elements.every(ts.isStringLiteralLike)) { - throw new Error('Pinned DevTools supported locales must be a literal string array') - } - const available = array.elements.filter(ts.isStringLiteralLike).map(element => element.text) - if (DEVTOOLS_LOCALES.some(locale => !available.includes(locale))) { - throw new Error('Pinned DevTools must include every distributed locale') - } - selected.push(array) - } - ts.forEachChild(node, visit) - } - visit(file) - if (selected.length !== 1) throw new Error('Pinned DevTools must declare one supported-locale array') - const array = selected[0]! - return source.slice(0, array.getStart(file)) + JSON.stringify(DEVTOOLS_LOCALES) + source.slice(array.end) -} - -function replaceOne(source: string, before: string, after: string, subject: string): string { - if (source.split(before).length !== 2) throw new Error(`Pinned DevTools ${subject} must occur exactly once`) - return source.replace(before, after) -} - -/** - * Apply Host CPU-profiling mode without changing the Elements target or other panels. - * @param source - Pinned Timeline module. - * @returns Timeline code recording the existing Host CPUProfilerModel. - */ -export function configureHostPerformance(source: string): string { - return replaceOne(replaceOne(source, - '#o=rn.Runtime.Runtime.isNode();', '#o=true;', 'Performance mode'), - 'D.TargetManager.TargetManager.instance().targets().find(t=>t.type()===D.Target.Type.NODE)', - 'D.TargetManager.TargetManager.instance().primaryPageTarget()', 'Performance target') -} - -/** - * Build lib/devtools from the complete input graph, preserving relative module/Worker URLs. + * Build lib/devtools without network access, retaining relative Worker and asset URLs. * @returns Published file count and bytes, excluding the distribution manifest. */ -export async function buildDevtools(): Promise<{ files: number; bytes: number }> { - const input = await downloadDevtools() - const indexed = new Map(input.files.map(file => [file.path, file])) - const output = new URL('../lib/devtools/', import.meta.url) - const temporary = new URL(`../lib/devtools.build-${process.pid}/`, import.meta.url) - await rm(temporary, { recursive: true, force: true }) - await mkdir(temporary, { recursive: true }) - const queue = ['devtools_app.html'] - const visited = new Set() - const nodeImports = new Set() - const files: { path: string; bytes: number; sha256: string }[] = [] +export function buildDevtools(): Promise<{ files: number; bytes: number }> { + pending ??= compile().finally(() => { pending = undefined }) + return pending +} + +async function compile(): Promise<{ files: number; bytes: number }> { + const lib = fileURLToPath(new URL('../lib/', import.meta.url)) + await mkdir(lib, { recursive: true }) + const temporary = await mkdtemp(join(lib, 'devtools.build-')) try { - while (queue.length > 0) { - const path = queue.pop()! - if (visited.has(path)) continue - visited.add(path) - const descriptor = indexed.get(path) - if (descriptor === undefined) throw new Error(`DevTools dependency was not indexed: ${path}`) - const original = await readFile(join(input.directory, path)) - if (createHash('sha256').update(original).digest('hex') !== descriptor.sha256) { - throw new Error(`DevTools cached resource changed: ${path}`) + await buildFrontend(temporary) + const files: Array<{ path: string; bytes: number; sha256: string }> = [] + const walk = async (directory: string): Promise => { + for (const entry of await readdir(join(temporary, directory), { withFileTypes: true })) { + const path = directory ? `${directory}/${entry.name}` : entry.name + if (entry.isDirectory()) await walk(path) + else { + const bytes = await readFile(join(temporary, path)) + files.push({ path, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }) + } } - let bytes: Uint8Array = original - if (/\.(?:html|css|js)$/u.test(path)) { - let source = pruneLighthouse(path, original.toString('utf8')) - if (path === 'panels/timeline/timeline.js') source = configureHostPerformance(source) - if (path === 'core/i18n/i18n.js') source = configureDevtoolsLocales(source) - const dependencies = findDevtoolsDependencies(source, new URL(path, DEVTOOLS_CDN), DEVTOOLS_CDN) - for (const dependency of dependencies.urls) queue.push(dependency.pathname.slice(DEVTOOLS_CDN.pathname.length)) - for (const specifier of dependencies.externalModules) if (specifier.startsWith('node:')) nodeImports.add(specifier) - bytes = new TextEncoder().encode(source) - } - const target = new URL(path, temporary) - await mkdir(new URL('./', target), { recursive: true }) - await writeFile(target, bytes) - files.push({ path, bytes: bytes.byteLength, sha256: createHash('sha256').update(bytes).digest('hex') }) - } - if (files.some(file => /(?:^|\/)lighthouse(?:\/|_)/u.test(file.path))) { - throw new Error('Lighthouse remains reachable in the trimmed DevTools distribution') - } - const htmlPath = new URL('devtools_app.html', temporary) - const html = await readFile(htmlPath, 'utf8') - const imports = JSON.stringify({ imports: Object.fromEntries([...nodeImports].sort() - .map(specifier => [specifier, './node-runtime-unavailable.js'])) }) - const hash = createHash('sha256').update(imports).digest('base64') - if (!/script-src\s+/u.test(html)) throw new Error('DevTools entry must declare script-src') - const preparedHtml = replaceOne(html.replace(/script-src\s+/u, `script-src 'sha256-${hash}' `), - '', - new URL('devtools_app.html', root))).toEqual(['main.js', 'tokens.css']) - expect(paths('@import "./more.css"; body { background:url(../Images/icon.svg); src:url(data:font/woff2;base64,AAAA) }', - new URL('styles/main.css', root))).toEqual(['Images/icon.svg', 'styles/more.css']) -}) - -it('discovers assets in emitted CSS modules but skips their diagnostic source URL', () => { - expect(paths('const cssContent = "body { background:url(../../Images/icon.svg) }"; import.meta.resolve("./widget.css");', - new URL('ui/example/widget.css.js', root))).toEqual(['Images/icon.svg']) -}) - -it('rejects module dependencies outside the pinned revision', () => { - expect(() => paths('import "../../../other-revision/module.js"')).toThrow('escapes the pinned revision') - expect(() => paths('import "https://other.example/module.js"')).toThrow('escapes the pinned revision') -}) - -it('enumerates every locale supplied to the upstream I18n constructor before any pruning', () => { - expect(paths('const available = ["en-US", "zh", "fr"]; const i18n = new Library.I18n.I18n(available, "en-US");', - new URL('core/i18n/i18n.js', root))).toEqual([ - 'core/i18n/locales/en-US.json', 'core/i18n/locales/fr.json', 'core/i18n/locales/zh.json', - ]) -}) - -it('records bare and variable imports instead of inventing relative CDN paths for them', () => { - const result = findDevtoolsDependencies('import("puppeteer"); import("node:fs"); import(workerEntry);', file, root) - expect(result.urls).toEqual([]) - expect(result.externalModules).toEqual(['puppeteer', 'node:fs']) - expect(result.dynamicImports).toEqual(['import(workerEntry)']) -}) - -it('follows the Lighthouse locale table with its upstream source-catalog exclusion', () => { - expect(paths('const locales = ["en-US.json", "en-US.ctc.json", "fr.json"].filter(name => !name.endsWith(".ctc.json"));', - new URL('third_party/lighthouse/lighthouse-dt-bundle.js', root))).toEqual([ - 'third_party/lighthouse/locales/en-US.json', 'third_party/lighthouse/locales/fr.json', - ]) -}) diff --git a/packages/experimental/inspector/tests/devtools-build.e2e.ts b/packages/experimental/inspector/tests/devtools-build.e2e.ts new file mode 100644 index 0000000000..62f8cd18e9 --- /dev/null +++ b/packages/experimental/inspector/tests/devtools-build.e2e.ts @@ -0,0 +1,60 @@ +/** Offline source compilation preserves npm inputs and includes the browser's graph-external resources. */ +import { createHash } from 'node:crypto' +import http from 'node:http' +import https from 'node:https' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished, vi } from 'vitest' +import { buildFrontend } from '../scripts/devtools/vite.ts' +import { frontendSourceRoot } from '../scripts/devtools/inputs.ts' + +async function sourceHash(root: string): Promise { + const hash = createHash('sha256') + const visit = async (path: string): Promise => { + const entries = await readdir(join(root, path), { withFileTypes: true }) + for (const item of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const name = path ? `${path}/${item.name}` : item.name + hash.update(name) + if (item.isDirectory()) await visit(name) + else if (item.isFile()) hash.update(await readFile(join(root, name))) + } + } + await visit('') + return hash.digest('hex') +} + +it('builds from fixed installed sources with network APIs blocked and includes Memory and injected entries', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-devtools-build-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + const source = await frontendSourceRoot() + const before = await sourceHash(source) + const deny = (): never => { throw new Error('DevTools build attempted network access') } + const guards = [ + vi.spyOn(globalThis, 'fetch').mockImplementation(deny), + vi.spyOn(http, 'request').mockImplementation(deny), + vi.spyOn(http, 'get').mockImplementation(deny), + vi.spyOn(https, 'request').mockImplementation(deny), + vi.spyOn(https, 'get').mockImplementation(deny), + ] + onTestFinished(() => { for (const guard of guards) guard.mockRestore() }) + await buildFrontend(root) + for (const guard of guards) expect(guard).not.toHaveBeenCalled() + expect(await sourceHash(source)).toBe(before) + for (const path of [ + 'devtools_app.html', 'connect.js', 'entrypoints/devtools_app/devtools_app.js', + 'entrypoints/heap_snapshot_worker/heap_snapshot_worker-entrypoint.js', + 'entrypoints/formatter_worker/formatter_worker-entrypoint.js', + 'entrypoints/wasmparser_worker/wasmparser_worker-entrypoint.js', + 'entrypoints/lighthouse_worker/lighthouse_worker.js', + 'panels/recorder/injected/injected.generated.js', + 'models/live-metrics/web-vitals-injected/web-vitals-injected.generated.js', + 'Images/chromeLeft.avif', 'Images/lighthouse_logo.svg', + 'LICENSE', 'third_party/codemirror/package/LICENSE', 'third_party/wasmparser/package/LICENSE', + ]) expect((await readFile(join(root, path))).length, path).toBeGreaterThan(0) + const html = await readFile(join(root, 'devtools_app.html'), 'utf8') + expect(html).not.toContain('chrome-devtools-frontend.appspot.com') + expect(html).not.toContain('importmap') + expect(html.indexOf('./connect.js')).toBeLessThan(html.indexOf('./entrypoints/devtools_app/devtools_app.js')) + await expect(readdir(join(root, 'core/i18n/locales'))).rejects.toMatchObject({ code: 'ENOENT' }) +}) diff --git a/packages/experimental/inspector/tests/devtools-locales.host.spec.ts b/packages/experimental/inspector/tests/devtools-locales.host.spec.ts deleted file mode 100644 index 897dc3a58d..0000000000 --- a/packages/experimental/inspector/tests/devtools-locales.host.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** Locale pruning changes the upstream menu source and dependency graph together. */ -import { expect, it } from 'vitest' -import { configureDevtoolsLocales } from '../scripts/build-devtools.ts' -import { findDevtoolsDependencies } from '../scripts/download-devtools.ts' - -it('keeps Chinese and English in both the I18n instance and its resource dependencies', () => { - const source = 'const locales=["fr","en-US","zh","de"]; const bundled=["en-US","zh"]; new Lib.I18n.I18n(locales,"en-US");' - const result = configureDevtoolsLocales(source) - expect(result).toBe('const locales=["en-US","zh"]; const bundled=["en-US","zh"]; new Lib.I18n.I18n(locales,"en-US");') - const root = new URL('https://cdn.example/serve_rev/@revision/') - expect(findDevtoolsDependencies(result, new URL('core/i18n/i18n.js', root), root).urls.map(url => url.href)).toEqual([ - new URL('core/i18n/locales/en-US.json', root).href, - new URL('core/i18n/locales/zh.json', root).href, - ]) -}) - -it('rejects an upstream locale declaration that no longer matches the distribution', () => { - expect(() => configureDevtoolsLocales('const locales=["en-US"]; new Lib.I18n.I18n(locales,"en-US");')) - .toThrow('every distributed locale') - expect(() => configureDevtoolsLocales('new Lib.I18n.I18n(loadLocales(),"en-US");')) - .toThrow('one supported-locale array') -}) diff --git a/packages/experimental/inspector/tests/devtools-source.host.spec.ts b/packages/experimental/inspector/tests/devtools-source.host.spec.ts new file mode 100644 index 0000000000..3dfe4cd6c6 --- /dev/null +++ b/packages/experimental/inspector/tests/devtools-source.host.spec.ts @@ -0,0 +1,293 @@ +/** Source adapters evaluated in per-test VMs; npm fixtures are read-only and no browser globals are shared. */ +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' +import { imagesModule } from '../scripts/devtools/inputs.ts' +import { + configureCodeMirrorBrowser, + configureHostPerformanceSource, + cssTextModule, + restoreModuleUrls, +} from '../scripts/devtools/source.ts' + +const timelinePath = 'panels/timeline/TimelinePanel.ts' +const frontend = new URL('../node_modules/chrome-devtools-frontend/front_end/', import.meta.url) +const nodeMode = '#isNode = Root.Runtime.Runtime.isNode();' +const nodeTarget = 'SDK.TargetManager.TargetManager.instance().targets().find(target => target.type() === SDK.Target.Type.NODE)' +const primaryTarget = 'SDK.TargetManager.TargetManager.instance().primaryPageTarget()' + +function npmSource(path: string): string { + return readFileSync(new URL(path, frontend), 'utf8') +} + +function evaluateModule(source: string): Record { + const exports: Record = {} + // CommonJS emission evaluates these import-free generated modules without Node's experimental VM module flag. + const emitted = ts.transpileModule(source, { + fileName: 'generated.ts', reportDiagnostics: true, + compilerOptions: { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.CommonJS }, + }) + expect(emitted.diagnostics?.filter(item => item.category === ts.DiagnosticCategory.Error)).toEqual([]) + runInNewContext(emitted.outputText, { exports }) + return exports +} + +describe('restoreModuleUrls', () => { + it.each([ + 'const answer = 42;\r\n', + '// import.meta.url and import.meta.resolve("./icon.svg")\r\n', + '/* import.meta.url\nimport.meta.resolve() */\n', + 'const message = "import.meta.url";\n', + 'const message = `import.meta.resolve("./icon.svg")`;\n', + 'const pattern = /import.meta.url/;\n', + 'const metadata = import.meta.env; const url = other.meta.url;\n', + ])('preserves non-URL syntax byte-for-byte: %s', (source) => { + expect(restoreModuleUrls(timelinePath, source)).toBe(source) + }) + + it.each([ + ['https://inspector.test/devtools_app.html', 'https://inspector.test/'], + ['https://inspector.test/team/alice/inspector/devtools/devtools_app.html?ws=host#console', + 'https://inspector.test/team/alice/inspector/devtools/'], + ['https://inspector.test/nested/devtools/', 'https://inspector.test/nested/devtools/'], + ])('resolves window resources from document.baseURI %s', (baseURI, root) => { + const source = `({ + module: import.meta.url, + sibling: import.meta.resolve('./TimelineLoader.js?cache=1#loader'), + image: new URL('../../Images/file name.svg', import.meta.url).href, + absolute: import.meta.resolve('https://assets.test/icon.svg'), + })` + const result: unknown = runInNewContext(restoreModuleUrls(timelinePath, source), { + URL, document: { baseURI }, + }) + expect(result).toEqual({ + module: `${root}panels/timeline/TimelinePanel.js`, + sibling: `${root}panels/timeline/TimelineLoader.js?cache=1#loader`, + image: `${root}Images/file%20name.svg`, + absolute: 'https://assets.test/icon.svg', + }) + }) + + it.each([ + ['https://inspector.test/entrypoints/heap_snapshot_worker/heap_snapshot_worker.js', 'https://inspector.test/'], + ['https://inspector.test/team/alice/devtools/entrypoints/heap_snapshot_worker/heap_snapshot_worker.js?worker=1#ready', + 'https://inspector.test/team/alice/devtools/'], + ])('uses the frontend root two levels above the worker %s', (href, root) => { + const source = '({module: import.meta.url, dependency: import.meta.resolve(\'./HeapSnapshot.js\')})' + const result: unknown = runInNewContext(restoreModuleUrls('models/heap_snapshot/HeapSnapshotWorker.ts', source), { + URL, self: { location: { href } }, + }) + expect(result).toEqual({ + module: `${root}models/heap_snapshot/HeapSnapshotWorker.js`, + dependency: `${root}models/heap_snapshot/HeapSnapshot.js`, + }) + }) + + it('preserves surrounding strings and comments while rewriting multiple expressions', () => { + const prefix = '// import.meta.resolve() stays a comment\r\nconst text = "import.meta.url";\r\n' + const suffix = '\r\n// import.meta.url stays a comment\r\n' + const source = prefix + '[text, import.meta.url, import.meta.resolve("./next.js")];' + suffix + const result = restoreModuleUrls('core/common/example.js', source) + expect(result.startsWith(prefix)).toBe(true) + expect(result.endsWith(suffix)).toBe(true) + expect(runInNewContext(result, { URL, document: { baseURI: 'https://inspector.test/devtools/app.html' } })).toEqual([ + 'import.meta.url', 'https://inspector.test/devtools/core/common/example.js', + 'https://inspector.test/devtools/core/common/next.js', + ]) + }) + + it('evaluates a dynamic resolve argument once and accepts a trailing comma', () => { + const requests: string[] = [] + const source = 'import.meta.resolve(resource(),)' + const result: unknown = runInNewContext(restoreModuleUrls(timelinePath, source), { + URL, document: { baseURI: 'https://inspector.test/sub/app.html' }, + resource: () => { requests.push('resource'); return './snapshot.json' }, + }) + expect(result).toBe('https://inspector.test/sub/panels/timeline/snapshot.json') + expect(requests).toEqual(['resource']) + }) + + it.each(['', '"./asset.svg", "https://other.test/"', '"a", "b", "c"'])( + 'rejects import.meta.resolve with unsupported arity: (%s)', (argumentsText) => { + expect(() => restoreModuleUrls(timelinePath, `import.meta.resolve(${argumentsText})`)).toThrow( + `DevTools ${timelinePath}: import.meta.resolve must have one argument`, + ) + }, + ) +}) + +describe('generated CSS text modules', () => { + it.each([ + ['empty', ''], + ['quotes and slashes', '.title::after { content: "double"; other: \'single\'; path: "C:\\icons\\file"; }'], + ['backticks and interpolation', '/* `template` ${mustNotRun()} */\n.title { content: "${value}"; }'], + ['line endings', '\uFEFF:root {\r\n --label: "中文 🚀";\r\n}\n'], + ['control characters', '/* \u0000\t\u2028\u2029 */'], + ['script-like text', '\"; throw new Error("must stay text"); /* */'], + ])('preserves CSS text through module evaluation: %s', (_label, source) => { + expect(evaluateModule(cssTextModule(source))).toEqual({ default: source }) + }) +}) + +describe('configureHostPerformanceSource', () => { + it('changes only the recording-mode field and target expression in the installed npm TimelinePanel', () => { + const source = npmSource(timelinePath) + expect(source.split(nodeMode)).toHaveLength(2) + expect(source.split(nodeTarget)).toHaveLength(2) + expect(configureHostPerformanceSource(timelinePath, source)).toBe( + source.replace(nodeMode, '#isNode = true;').replace(nodeTarget, primaryTarget), + ) + expect(npmSource(timelinePath)).toBe(source) + }) + + it('selects the primary target for CPU recording independently of Node target discovery', () => { + const source = `class Panel { + ${nodeMode} + record() { return { isNode: this.#isNode, target: ${nodeTarget} }; } + } + new Panel().record();` + const target = { name: 'Host CPU profiler target' } + const result: unknown = runInNewContext(configureHostPerformanceSource(timelinePath, source), { + SDK: { TargetManager: { TargetManager: { instance: () => ({ primaryPageTarget: () => target }) } } }, + }) + expect(result).toEqual({ isNode: true, target }) + }) + + it.each(['panels/timeline/TimelineController.ts', 'panels/elements/ElementsPanel.ts', 'core/root/Runtime.ts'])( + 'preserves unrelated npm source %s byte-for-byte', (path) => { + const source = npmSource(path) + expect(configureHostPerformanceSource(path, source)).toBe(source) + }, + ) + + it('does not match other paths even when they contain the target expressions', () => { + const source = `\uFEFF${nodeMode}\r\n${nodeTarget}\r\n` + for (const path of ['panels/timeline/TimelinePanel.js', `other/${timelinePath}`, `${timelinePath}?raw`]) { + expect(configureHostPerformanceSource(path, source)).toBe(source) + } + }) + + it.each([ + ['missing Node mode', nodeMode, '#isNode = false;'], + ['duplicate Node mode', nodeMode, `${nodeMode}\n${nodeMode}`], + ['changed target', nodeTarget, 'SDK.TargetManager.TargetManager.instance().rootTarget()'], + ['duplicate target', nodeTarget, `(${nodeTarget}, ${nodeTarget})`], + ])('rejects %s in the npm source', (_label, expression, replacement) => { + const source = npmSource(timelinePath) + const changed = source.replace(expression, replacement) + expect(changed).not.toBe(source) + expect(() => configureHostPerformanceSource(timelinePath, changed)).toThrow( + `DevTools ${timelinePath}: expected source expression changed: ${expression}`, + ) + }) +}) + +describe('configureCodeMirrorBrowser', () => { + const prefix = 'third_party/codemirror/package/' + const guard = 'typeof exports == "object" && typeof module == "object"' + const wrapper = `(function(mod) { + if (${guard}) mod(require('../../lib/codemirror')); + else mod(CodeMirror); + })(function(cm) { cm.loaded = true; });` + + it('uses the browser CodeMirror with CJS globals present without calling require', () => { + const requests: string[] = [] + const CodeMirror = { loaded: false } + const sandbox = { + exports: {}, module: { exports: {} }, CodeMirror, + require: (specifier: string) => { requests.push(specifier); throw new Error('CJS branch selected') }, + } + expect(() => { runInNewContext(wrapper, sandbox) }).toThrow('CJS branch selected') + expect(requests).toEqual(['../../lib/codemirror']) + expect(CodeMirror.loaded).toBe(false) + + const result = configureCodeMirrorBrowser(`${prefix}mode/javascript/javascript.mjs`, wrapper) + runInNewContext(result, sandbox) + expect(CodeMirror.loaded).toBe(true) + expect(requests).toEqual(['../../lib/codemirror']) + const withoutRequire = { exports: {}, module: { exports: {} }, CodeMirror: { loaded: false } } + runInNewContext(result, withoutRequire) + expect(withoutRequire.CodeMirror.loaded).toBe(true) + }) + + it('preserves other paths, extensions and nonmatching guards byte-for-byte', () => { + const source = `\uFEFF${wrapper}\r\n` + for (const path of [ + 'third_party/other/package/mode.mjs', 'third_party/codemirror/package-other/mode.mjs', + 'panels/codemirror/mode.mjs', `${prefix}mode.js`, `${prefix}mode.ts`, `${prefix}mode.cjs`, `${prefix}mode.mjs?raw`, + ]) { + expect(configureCodeMirrorBrowser(path, source)).toBe(source) + } + const differentGuard = source.replace(guard, 'typeof exports === "object" && typeof module === "object"') + expect(configureCodeMirrorBrowser(`${prefix}mode.mjs`, differentGuard)).toBe(differentGuard) + }) + + it('recognizes the npm runmode and mode guards and tokenizes in the browser branch', () => { + const sources = [ + 'addon/runmode/runmode-standalone.mjs', 'mode/javascript/javascript.mjs', 'mode/css/css.mjs', 'mode/xml/xml.mjs', + ].map((relative) => { + const path = `${prefix}${relative}` + const source = npmSource(path) + expect(source).toContain(guard) + const result = configureCodeMirrorBrowser(path, source) + expect(result).toBe(source.replaceAll(guard, 'false')) + expect(result).not.toContain(guard) + return result + }) + const samples = [ + { mode: 'javascript', text: 'var answer = 42;', style: 'keyword' }, + { mode: 'css', text: 'body { color: red; }', style: 'property' }, + { mode: 'xml', text: '', style: 'tag' }, + ] + const tokens: Array<{ mode: string; text: string; style: string | null | undefined }> = [] + const requests: string[] = [] + runInNewContext(sources.join('\n;\n') + ` + for (const sample of ${JSON.stringify(samples)}) { + CodeMirror.runMode(sample.text, sample.mode, (text, style) => capture(sample.mode, text, style)); + }`, { + exports: {}, module: { exports: {} }, + require: (specifier: string) => { requests.push(specifier); throw new Error('Unexpected CodeMirror require') }, + capture: (mode: string, text: string, style: string | null | undefined) => { tokens.push({ mode, text, style }) }, + }) + expect(requests).toEqual([]) + for (const sample of samples) { + const result = tokens.filter(token => token.mode === sample.mode) + expect(result.map(token => token.text).join('')).toBe(sample.text) + expect(result.map(token => token.style)).toContain(sample.style) + } + }) +}) + +describe('imagesModule', () => { + it.each([ + { images: [] }, + { images: [{ name: 'nodeIcon.avif' }, { name: 'folder-open.svg' }, { name: 'icon.with.dots.svg' }, { name: 'file name.svg' }] }, + ])('registers flattened images relative to the page and retains adopted sheets: $images', ({ images }) => { + const registrations: Array<[string, string]> = [] + const rules: string[] = [] + class Sheet { + readonly cssRules = [{ style: { setProperty: (name: string, value: string) => { registrations.push([name, value]) } } }] + replaceSync(value: string): void { rules.push(value) } + } + const previous = { name: 'existing stylesheet' } + const adopted: object[] = [previous] + const document = { + baseURI: 'https://inspector.test/team/alice/devtools/devtools_app.html?ws=host#console', + adoptedStyleSheets: adopted, + } + runInNewContext(imagesModule(images), { URL, CSSStyleSheet: Sheet, document }) + expect(rules).toEqual([':root {}']) + expect(registrations).toEqual(images.length === 0 ? [] : [ + ['--image-file-nodeIcon', 'url("https://inspector.test/team/alice/devtools/Images/nodeIcon.avif")'], + ['--image-file-folder-open', 'url("https://inspector.test/team/alice/devtools/Images/folder-open.svg")'], + ['--image-file-icon.with.dots', 'url("https://inspector.test/team/alice/devtools/Images/icon.with.dots.svg")'], + ['--image-file-file name', 'url("https://inspector.test/team/alice/devtools/Images/file%20name.svg")'], + ]) + expect(document.adoptedStyleSheets).toHaveLength(2) + expect(document.adoptedStyleSheets[0]).toBe(previous) + expect(document.adoptedStyleSheets[1]).toBeInstanceOf(Sheet) + expect(document.adoptedStyleSheets).not.toBe(adopted) + expect(adopted).toEqual([previous]) + }) +}) diff --git a/packages/experimental/inspector/tests/prune-lighthouse.host.spec.ts b/packages/experimental/inspector/tests/prune-lighthouse.host.spec.ts deleted file mode 100644 index 1bc38d51b3..0000000000 --- a/packages/experimental/inspector/tests/prune-lighthouse.host.spec.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** Pure Lighthouse pruning fixtures retain adjacent panels, AI capabilities, and image registrations. */ -import ts from 'typescript' -import { describe, expect, it } from 'vitest' -import { pruneLighthouse } from '../scripts/prune-lighthouse.ts' - -const entryPath = 'entrypoints/devtools_app/devtools_app.js' -const panelPath = 'panels/ai_assistance/ai_assistance.js' -const modelPath = 'models/ai_assistance/ai_assistance.js' -const entry = ` -import * as Lighthouse from "../../panels/lighthouse/lighthouse.js"; -import * as Views from "../../ui/legacy/legacy.js"; -const strings = { title: "Lighthouse" }, messages = Locale.registerUIStrings("panels/lighthouse/lighthouse-meta.ts", strings), cached = undefined; -async function loadLighthouse() { return cached || await import("../../panels/lighthouse/lighthouse.js"); } -Views.registerViewExtension({ id: "elements", title: "Elements" }); -Views.registerViewExtension({ id: "lighthouse", async loadView() { return (await loadLighthouse()).LighthousePanel; } }); -Core.registerRevealer({ contextTypes() { return [Lighthouse.LighthousePanel.ActiveLighthouseReport]; }, async loadRevealer() { return (await loadLighthouse()).ReportRevealer; } }); -Views.registerViewExtension({ id: "timeline", title: "Performance" }); -` - -const panel = ` -import * as Reports from "../lighthouse/lighthouse.js"; -import * as Rendering from "../lighthouse/lighthouse.js"; -import * as AI from "../../models/ai_assistance/ai_assistance.js"; -function reportContext(report) { return report ? new AI.AccessibilityAgent.AccessibilityContext(report.report) : null; } -async function renderReport(widget) { return Rendering.LighthouseReportRenderer.renderLighthouseScores(widget.report); } -async function renderWidget(widget) { let rendered = null; switch (widget.name) { -case "LIGHTHOUSE_REPORT": rendered = await renderReport(widget); break; -case "OTHER": rendered = "other-widget"; break; -} return rendered; } -function icon(context) { return context instanceof AI.AccessibilityAgent.AccessibilityContext ? "Lighthouse" : "other-icon"; } -class Panel { - #report = null; - #onReport = event => { this.#report = reportContext(event.data); }; - async #record(params) { return await Reports.LighthousePanel.executeLighthouseRecording(params); } - wasShown() { before(), this.#report = reportContext(Context.flavor(Reports.LighthousePanel.ActiveLighthouseReport)), Context.addFlavorChangeListener(Reports.LighthousePanel.ActiveLighthouseReport, this.#onReport), after(); } - willHide() { before(), Context.removeFlavorChangeListener(Reports.LighthousePanel.ActiveLighthouseReport, this.#onReport), after(); } - first() { return new AI.AiConversation.AiConversation({ lighthouseRecording: this.#record.bind(this), keep: "first" }); } - second() { return new AI.AiConversation.AiConversation({ keep: "second", lighthouseRecording: this.#record.bind(this), other: true }); } - third() { return new AI.AiConversation.AiConversation({ keep: "third", lighthouseRecording: this.#record.bind(this) }); } - context(type) { switch (type) { case "accessibility": return this.#report; case "styling": return "styling-context"; } } - setContext = value => value instanceof AI.AccessibilityAgent.AccessibilityContext ? this.#report = value : useOtherContext(value); - view() { let elements = Views.isViewVisible("elements"), lighthouse = Views.isViewVisible("lighthouse"), storage = Views.isViewVisible("resources"); - return elements ? "styling" : lighthouse && config.devToolsAiAssistanceAccessibilityAgent?.enabled ? "accessibility" : storage ? "storage" : "none"; - } -} -class Unrelated { #report = "unrelated"; read() { return this.#report; } } -` - -const model = ` -class SelectionAgent { constructor() { - this.declareFunction("listNetworkRequests", {}), this.declareFunction("runLighthouseAudits", {}), this.declareFunction("inspectDom", {}); -} } -class AccessibilityAgent { configure() { - this.declareFunction("executeJavaScript", {}), this.declareFunction("runAccessibilityAudits", {}), this.declareFunction("getLighthouseAudits", {}), this.declareFunction("getStyles", {}); -} } -` - -function imports(source: string): string[] { - const file = ts.createSourceFile('fixture.js', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS) - const result: string[] = [] - const visit = (node: ts.Node): void => { - if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) result.push(node.moduleSpecifier.text) - if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword - && node.arguments[0] !== undefined && ts.isStringLiteralLike(node.arguments[0])) result.push(node.arguments[0].text) - ts.forEachChild(node, visit) - } - visit(file) - return result -} - -describe('pruneLighthouse', () => { - it('removes the panel, revealer, lazy loader and private metadata while retaining adjacent panels', () => { - const result = pruneLighthouse(entryPath, entry) - expect(imports(result)).toEqual(['../../ui/legacy/legacy.js']) - expect(result).toContain('Views.registerViewExtension({ id: "elements", title: "Elements" });') - expect(result).toContain('Views.registerViewExtension({ id: "timeline", title: "Performance" });') - expect(result).not.toMatch(/Lighthouse|loadLighthouse|registerRevealer|registerUIStrings|cached/) - }) - - it('removes only report context, recording and rendering from the AI panel', () => { - const result = pruneLighthouse(panelPath, panel) - expect(imports(result)).toEqual(['../../models/ai_assistance/ai_assistance.js']) - expect(result).not.toMatch(/Reports|Rendering|renderReport|reportContext|lighthouseRecording|#record|#onReport/) - expect(result).toContain('wasShown() { before(), after(); }') - expect(result).toContain('willHide() { before(), after(); }') - expect(result).toContain('case"LIGHTHOUSE_REPORT":break;') - expect(result).toContain('case "OTHER": rendered = "other-widget"; break;') - expect(result).toContain('case"accessibility":return null;') - expect(result).toContain('case "styling": return "styling-context";') - expect(result).toContain('setContext = value => useOtherContext(value);') - expect(result).toContain('return "other-icon";') - expect(result).toContain('{ keep: "first" }') - expect(result).toContain('{ keep: "second", other: true }') - expect(result).toContain('{ keep: "third" }') - expect(result).toContain('storage ? "storage" : "none"') - expect(result).toContain('class Unrelated { #report = "unrelated"; read() { return this.#report; } }') - }) - - it('removes Lighthouse model tool declarations without dropping other agent tools', () => { - const result = pruneLighthouse(modelPath, model) - expect(result).not.toMatch(/runLighthouseAudits|runAccessibilityAudits|getLighthouseAudits/) - for (const tool of ['listNetworkRequests', 'inspectDom', 'executeJavaScript', 'getStyles']) { - expect(result).toContain(`this.declareFunction("${tool}", {})`) - } - expect(result).toContain('class SelectionAgent') - expect(result).toContain('class AccessibilityAgent') - }) - - it.each(['./lighthouse_logo.svg', 'lighthouse_logo.svg'])('replaces only the %s image call and preserves the comma chain', (url) => { - const source = `style.setProperty("--before", "before.svg"),style.setProperty("--image-file-lighthouse_logo", new URL(new URL("${url}", import.meta.url).href)),style.setProperty("--after", "after.svg");` - expect(pruneLighthouse('Images/Images.js', source)) - .toBe('style.setProperty("--before", "before.svg"),void 0,style.setProperty("--after", "after.svg");') - }) - - it('keeps unowned modules and i18n strings byte-identical', () => { - const source = '{"showLighthouse":"Show Lighthouse","language":"all locales retained"}\n' - expect(pruneLighthouse('core/i18n/locales/en-US.json', source)).toBe(source) - expect(pruneLighthouse('panels/elements/elements.js', 'const Lighthouse = "unrelated";')).toBe('const Lighthouse = "unrelated";') - expect(pruneLighthouse(entryPath.replaceAll('/', '\\'), entry)).toBe(pruneLighthouse(entryPath, entry)) - }) - - it('rejects missing or duplicate pinned contributions', () => { - expect(() => pruneLighthouse(entryPath, entry.replace('id: "lighthouse"', 'id: "renamed"'))).toThrow('expected 1 panel registrations, found 0') - expect(() => pruneLighthouse(modelPath, model.replace('"runLighthouseAudits"', '"renamed"'))).toThrow('expected 1 runLighthouseAudits, found 0') - const logo = 'style.setProperty("--image-file-lighthouse_logo", new URL("./lighthouse_logo.svg", import.meta.url));' - expect(() => pruneLighthouse('Images/Images.js', logo + logo)).toThrow('expected 1 Lighthouse logo registrations, found 2') - expect(() => pruneLighthouse(panelPath, panel.replace('async #record', 'async record'))).toThrow('private Lighthouse recording method') - }) - - it('rejects extra references instead of removing their unrelated consumers', () => { - expect(() => pruneLighthouse(entryPath, entry + '\nuseElsewhere(Lighthouse);')).toThrow('removed binding Lighthouse remains referenced') - expect(() => pruneLighthouse(entryPath, entry + '\nuseElsewhere(loadLighthouse);')).toThrow('removed binding loadLighthouse remains referenced') - expect(() => pruneLighthouse(panelPath, panel.replace('first() {', 'extra() { return this.#report; } first() {'))) - .toThrow('removed private member #report remains referenced') - expect(() => pruneLighthouse(panelPath, panel + '\nimport("../lighthouse/lighthouse.js");')) - .toThrow('Lighthouse import remains after pruning') - }) - - it('rejects unexpected import forms, syntax, and effect-bearing tool expressions', () => { - expect(() => pruneLighthouse(entryPath, entry.replace('import * as Lighthouse', 'import Lighthouse'))).toThrow('Lighthouse import must use a namespace') - expect(() => pruneLighthouse(entryPath, 'function {')).toThrow('pruneLighthouse:') - expect(() => pruneLighthouse(modelPath, model.replace('this.declareFunction("runLighthouseAudits", {})', 'wrap(this.declareFunction("runLighthouseAudits", {}))'))) - .toThrow('runLighthouseAudits is not an independent expression') - }) -}) diff --git a/packages/experimental/inspector/tests/workspace-prefetch.host.spec.ts b/packages/experimental/inspector/tests/workspace-prefetch.host.spec.ts index 2f925f7406..04021ff4bf 100644 --- a/packages/experimental/inspector/tests/workspace-prefetch.host.spec.ts +++ b/packages/experimental/inspector/tests/workspace-prefetch.host.spec.ts @@ -1,20 +1,29 @@ -/** DevTools prefetch belongs to contributor setup, not published package installation. */ +/** DevTools sources are fixed npm inputs; package installation starts no external resource download. */ import { readFileSync } from 'node:fs' import { expect, it } from 'vitest' interface Manifest { scripts?: Record files?: string[] + devDependencies?: Record } function manifest(path: string): Manifest { return JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8')) as Manifest } -it('prefetches DevTools from the root workspace after installing its existing Git hooks', () => { +it('installs workspace Git hooks without prefetching DevTools', () => { const root = manifest('../../../../package.json') - expect(root.scripts?.postinstall).toBe('node scripts/install-lefthook.mjs && pnpm run prefetch:devtools') - expect(root.scripts?.['prefetch:devtools']).toBe('tsx packages/experimental/inspector/scripts/download-devtools.ts') + expect(root.scripts?.postinstall).toBe('node scripts/install-lefthook.mjs') + expect(root.scripts?.['prefetch:devtools']).toBeUndefined() +}) + +it('pins the npm source and uses workspace Vite without a webpack dependency', () => { + const dependencies = manifest('../package.json').devDependencies + expect(dependencies?.['chrome-devtools-frontend']).toBe('1.0.1638082') + expect(dependencies?.vite).toBeUndefined() + expect(manifest('../../../../package.json').devDependencies?.vite).toBe('8.0.16') + expect(Object.keys(dependencies ?? {}).some(name => name.includes('webpack'))).toBe(false) }) it.each(['../../../../apps/cli/package.json', '../../inspector-profile/package.json', '../package.json'])( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65ebac6b6a..c76fee46ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,6 +175,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + vite: + specifier: 8.0.16 + version: 8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) vite-tsconfig-paths: specifier: ^6.1.1 version: 6.1.1(supports-color@9.4.0)(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -8006,6 +8009,9 @@ importers: '@types/ws': specifier: ^8.18.1 version: 8.18.1 + chrome-devtools-frontend: + specifier: 1.0.1638082 + version: 1.0.1638082 playwright: specifier: ^1.49.0 version: 1.61.1 @@ -18496,6 +18502,9 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + chrome-devtools-frontend@1.0.1638082: + resolution: {integrity: sha512-KfbM5kZrgseWciU9Y6ic6hDtYWKY7SY6L8jkeq23KF5CgZ1cU7P+janhysDLN+XZr6U4Ro5V+2W7YOHjKW4PAw==} + chrome-devtools-mcp@1.9.0: resolution: {integrity: sha512-RnzXoJiUQ44hpOihWk90uOhLD/CnwDkDy0ldHMZONJ2nYQ+dWN1fq1luHHqyd+7FuYnyIlCY6uTThbN5ut9kSQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} @@ -25707,6 +25716,8 @@ snapshots: chownr@3.0.0: {} + chrome-devtools-frontend@1.0.1638082: {} + chrome-devtools-mcp@1.9.0: {} chromium-pickle-js@0.2.0: {} diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 8527a0d588..c8f81c6864 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -36,6 +36,8 @@ describe('THIRD_PARTY_NOTICES.md', () => { expect(generated).toContain(`| [\`numpy\`](https://github.com/numpy/numpy) | ${primaryRuntimeLock.pythonPackages.numpy} | BSD-3-Clause |`) expect(generated).toContain('## LibreOffice conversion kit') expect(generated).toContain('Recipients must have access to those corresponding sources and notices.') + expect(generated.split('## Development-only npm dependencies')[0]).toContain('| [`chrome-devtools-frontend`]') + expect(generated).toContain('third-party license and notice files supplied by the npm source') expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated) }) }) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index e872aac220..a51817acc8 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -15,7 +15,7 @@ import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutB import parseSpdx from 'spdx-expression-parse' import primaryRuntimeLock from './primary-runtime/lock.json' with { type: 'json' } import { browserBundledExternals } from './browser-bundled-externals.ts' -import { DEVTOOLS_CDN, DEVTOOLS_CHROMIUM_VERSION } from '../packages/experimental/inspector/scripts/download-devtools.ts' +import { DEVTOOLS_NPM_VERSION, DEVTOOLS_SOURCE_REVISION } from '../packages/experimental/inspector/scripts/devtools/source.ts' const root = resolve(import.meta.dirname, '..') const OUT = 'THIRD_PARTY_NOTICES.md' @@ -744,6 +744,8 @@ ${rows.join('\n')} */ export async function render(): Promise { const browser = await browserBundledExternals(root) + // The Inspector's Host build hook compiles this standalone frontend outside the Client bundle graph. + browser.add('chrome-devtools-frontend') // The linked-manifest cache is keyed by name only, so it must not outlive // the manifests map it was resolved from; render() owns that single load. workspaceLinkedManifestCache.clear() @@ -797,7 +799,7 @@ ${patchedLines.join('\n')} ## Chrome DevTools frontend -The optional experimental Inspector distributes a local copy of [Chrome ${DEVTOOLS_CHROMIUM_VERSION}'s frontend](${DEVTOOLS_CDN.href}devtools_app.html). The Chromium [BSD-3-Clause license](packages/experimental/inspector/assets/devtools/LICENSE) is included with the frontend. Copied JavaScript retains its embedded license comments; the resource downloader does not collect separate upstream third-party license files. The Chromium root license does not replace those dependencies' licenses. +The optional experimental Inspector distributes a locally compiled copy of [chrome-devtools-frontend ${DEVTOOLS_NPM_VERSION}](https://www.npmjs.com/package/chrome-devtools-frontend/v/${DEVTOOLS_NPM_VERSION}), from upstream revision [${DEVTOOLS_SOURCE_REVISION}](https://chromium.googlesource.com/devtools/devtools-frontend/+/${DEVTOOLS_SOURCE_REVISION}). The build includes the Chromium [BSD-3-Clause license](packages/experimental/inspector/assets/devtools/LICENSE) and the third-party license and notice files supplied by the npm source. The Chromium root license does not replace those dependencies' licenses. ${renderClaudeDistribution(claudeDistribution)} ${kitRuntime ? ` From 29ca976eafd9e8fd7ed9f3937be2d25792873ab4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:59:02 +0800 Subject: [PATCH 2/3] test(web): keep DevTools source build e2e in the Host program --- apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index ec6859c87d..5b5e00638b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -73,6 +73,7 @@ "tests/shared-inspector.e2e.ts", "tests/devtools-frontend.e2e.ts", "tests/inspector-panels.e2e.ts", + "tests/inspector-source-build.e2e.ts", "tests/plugin-install-cancel.e2e.ts", "tests/plugin-install-approve.e2e.ts", "tests/plugin-install-registry.e2e.ts", diff --git a/tsconfig.host.json b/tsconfig.host.json index 31586f034a..59400c0e35 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -68,6 +68,7 @@ "apps/web/tests/layout-bottom.e2e.ts", "apps/web/tests/devtools-frontend.e2e.ts", "apps/web/tests/inspector-panels.e2e.ts", + "apps/web/tests/inspector-source-build.e2e.ts", "apps/web/tests/plugin-install-cancel.e2e.ts", "apps/web/tests/plugin-install-approve.e2e.ts", "apps/web/tests/plugin-install-registry.e2e.ts", From 9ec1a7ded9d90202e90cf2a1a613e64dc536f838 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:04:46 +0800 Subject: [PATCH 3/3] test(inspector): align frontend smoke with the source build --- apps/web/tests/devtools-frontend.e2e.ts | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/apps/web/tests/devtools-frontend.e2e.ts b/apps/web/tests/devtools-frontend.e2e.ts index 0ca535ab4e..e80fd0a8d7 100644 --- a/apps/web/tests/devtools-frontend.e2e.ts +++ b/apps/web/tests/devtools-frontend.e2e.ts @@ -5,7 +5,7 @@ import { chromium, type Browser } from 'playwright' import { expect, it, onTestFinished } from 'vitest' import { launchWebScaffold, type WebScaffold } from './scaffold.ts' -it('serves the trimmed DevTools frontend under its own path without rebuilding DevTools', async () => { +it('serves the built DevTools frontend under its own path without rebuilding DevTools', async () => { const resources: { scaffold?: WebScaffold; browser?: Browser } = {} onTestFinished(async () => { try { await resources.browser?.close() } finally { await resources.scaffold?.close() } @@ -35,11 +35,9 @@ it('serves the trimmed DevTools frontend under its own path without rebuilding D expect(html.headers()['content-type']).toContain('text/html') expect(html.headers()['x-content-type-options']).toBe('nosniff') const markup = await html.text() - const importMap = markup.match(/