Merge pull request #5597 from deepseek-harness/inspector-devtools-source
build(inspector): compile DevTools locally from npm sources
This commit is contained in:
commit
7b658a9e94
30 files changed
+783
-868
No files matched your search
@@ -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
|
||||
|
||||
@@ -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(/<script type="importmap">([^<]+)<\/script>/u)?.[1]
|
||||
expect(JSON.parse(importMap!)).toMatchObject({ imports: { 'node:worker_threads': './node-runtime-unavailable.js' } })
|
||||
expect(markup).not.toContain('type="importmap"')
|
||||
expect(markup).not.toMatch(/script-src[^;]*'unsafe-inline'/u)
|
||||
expect(markup.indexOf('src="./connect.js"')).toBeLessThan(markup.indexOf('type="module"'))
|
||||
expect(markup.indexOf('type="importmap"')).toBeLessThan(markup.indexOf('type="module"'))
|
||||
expect(markup.indexOf('src="./connect.js"')).toBeLessThan(markup.indexOf('src="./entrypoints/devtools_app/devtools_app.js"'))
|
||||
const assets = [...markup.matchAll(/(?:src|href)="\.\/([^"\s]+\.(?:js|css))"/gu)].map(match => match[1]!)
|
||||
expect(assets.map(path => path.slice(path.lastIndexOf('.'))).sort()).toEqual(['.css', '.css', '.js', '.js'])
|
||||
for (const asset of assets) {
|
||||
@@ -48,10 +46,9 @@ it('serves the trimmed DevTools frontend under its own path without rebuilding D
|
||||
expect(response.headers()['content-type']).toMatch(asset.endsWith('.js') ? /javascript/u : /text\/css/u)
|
||||
expect((await response.body()).byteLength).toBeGreaterThan(0)
|
||||
}
|
||||
for (const locale of ['en-US', 'zh']) expect((await page.request.get(`${root}core/i18n/locales/${locale}.json`)).status()).toBe(200)
|
||||
expect((await page.request.get(`${root}core/i18n/locales/fr.json`)).status()).toBe(404)
|
||||
for (const locale of ['en-US', 'zh', 'fr']) expect((await page.request.get(`${root}core/i18n/locales/${locale}.json`)).status()).toBe(404)
|
||||
expect((await page.request.get(`${root}entrypoints/heap_snapshot_worker/heap_snapshot_worker-entrypoint.js`)).status()).toBe(200)
|
||||
expect((await page.request.get(`${root}entrypoints/lighthouse_worker/lighthouse_worker.js`)).status()).toBe(404)
|
||||
expect((await page.request.get(`${root}entrypoints/lighthouse_worker/lighthouse_worker.js`)).status()).toBe(200)
|
||||
expect((await page.request.get(`${root}missing.js`)).status()).toBe(404)
|
||||
expect((await page.request.get(`${root}%2e%2e%2findex.js`)).status()).toBe(404)
|
||||
const head = await page.request.head(`${root}devtools_app.html`)
|
||||
@@ -78,8 +75,8 @@ it('serves the trimmed DevTools frontend under its own path without rebuilding D
|
||||
try {
|
||||
await page.getByRole('tab', { name: 'Console', exact: true }).waitFor({ state: 'visible' })
|
||||
expect(await page.getByRole('tab', { name: 'Connection', exact: true }).count()).toBe(0)
|
||||
expect(await page.getByRole('tab', { name: 'Lighthouse', exact: true }).count()).toBe(0)
|
||||
await page.getByText('DevTools is now available in Chinese', { exact: true }).waitFor({ state: 'visible' })
|
||||
expect(await page.getByRole('tab', { name: 'Lighthouse', exact: true }).count()).toBe(1)
|
||||
expect(await page.getByText('DevTools is now available in Chinese', { exact: true }).count()).toBe(0)
|
||||
await page.goto(`${root}devtools_app.html?disableLocaleInfoBar=true`, { waitUntil: 'load' })
|
||||
await page.getByRole('tab', { name: 'Console', exact: true }).waitFor({ state: 'visible' })
|
||||
expect(await page.getByText('DevTools is now available in Chinese', { exact: true }).count()).toBe(0)
|
||||
@@ -92,11 +89,6 @@ it('serves the trimmed DevTools frontend under its own path without rebuilding D
|
||||
await prompt.fill('[process.release.name, 6 * 7].join(":")')
|
||||
await prompt.press('Enter')
|
||||
await page.getByText('node:42', { exact: false }).waitFor({ state: 'visible' })
|
||||
// Vite must not rewrite the import that executes inside the browser.
|
||||
expect(await page.evaluate(`import(new URL('core/i18n/i18n.js', location.href).href).then(({ i18n }) => ({
|
||||
locales: i18n.getAllSupportedDevToolsLocales(),
|
||||
fallback: i18n.lookupClosestSupportedDevToolsLocale('fr'),
|
||||
}))`)).toEqual({ locales: ['en-US', 'zh'], fallback: 'en-US' })
|
||||
expect(await page.evaluate(() => ({
|
||||
fontSize: getComputedStyle(document.body).fontSize,
|
||||
zoom: getComputedStyle(document.documentElement).zoom,
|
||||
|
||||
@@ -7,4 +7,5 @@
|
||||
- tab "Memory"
|
||||
- tab "Application"
|
||||
- tab "Security"
|
||||
- tab "Lighthouse"
|
||||
- tab "Recorder"
|
||||
@@ -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<string>((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<unknown>) => {
|
||||
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([])
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
|
||||
@@ -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` 被跳过而导致钩子缺失,请手动安装:
|
||||
|
||||
|
||||
+2
-2
@@ -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:*",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `[email protected]` 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.
|
||||
|
||||
<a id="runtime-layout"></a>
|
||||
## Runtime layout
|
||||
|
||||
@@ -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 描述本次下载的字节,并非提交到仓库的上游摘要锁。
|
||||
构建使用官方 `[email protected]` 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 检查;不兼容的源码变动会使构建失败。
|
||||
|
||||
<a id="runtime-layout"></a>
|
||||
## 运行时布局
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
/** The browser runtime does not access the imports owned by DevTools' Node runtime. */
|
||||
export {}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, ts.ArrayLiteralExpression>()
|
||||
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<string>()
|
||||
const nodeImports = new Set<string>()
|
||||
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<void> => {
|
||||
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}' `),
|
||||
'<script type="module"', `<script type="importmap">${imports}</script><script src="./connect.js"></script><script type="module"`,
|
||||
'module entry')
|
||||
await writeFile(htmlPath, preparedHtml)
|
||||
const htmlRecord = files.find(file => file.path === 'devtools_app.html')!
|
||||
htmlRecord.bytes = Buffer.byteLength(preparedHtml)
|
||||
htmlRecord.sha256 = createHash('sha256').update(preparedHtml).digest('hex')
|
||||
for (const asset of ['connect.js', 'node-runtime-unavailable.js', 'LICENSE']) {
|
||||
await cp(new URL(`../assets/devtools/${asset}`, import.meta.url), new URL(asset, temporary))
|
||||
const bytes = await readFile(new URL(asset, temporary))
|
||||
files.push({ path: asset, bytes: bytes.byteLength, sha256: createHash('sha256').update(bytes).digest('hex') })
|
||||
}
|
||||
await walk('')
|
||||
const bytes = files.reduce((total, file) => total + file.bytes, 0)
|
||||
await writeFile(new URL('resource-manifest.json', temporary), `${JSON.stringify({
|
||||
chromium: DEVTOOLS_CHROMIUM_VERSION, revision: DEVTOOLS_REVISION, source: DEVTOOLS_CDN.href,
|
||||
locales: DEVTOOLS_LOCALES, excludedFeatures: ['lighthouse'],
|
||||
await writeFile(join(temporary, 'resource-manifest.json'), `${JSON.stringify({
|
||||
source: 'chrome-devtools-frontend', version: DEVTOOLS_NPM_VERSION, revision: DEVTOOLS_SOURCE_REVISION,
|
||||
builder: 'vite',
|
||||
files: files.sort((left, right) => left.path.localeCompare(right.path)),
|
||||
}, null, 2)}\n`)
|
||||
const output = join(lib, 'devtools')
|
||||
await rm(output, { recursive: true, force: true })
|
||||
await rename(fileURLToPath(temporary), fileURLToPath(output))
|
||||
console.log(`DevTools output: ${files.length} files, ${bytes} bytes`)
|
||||
await rename(temporary, output)
|
||||
console.log(`DevTools npm ${DEVTOOLS_NPM_VERSION}: ${files.length} files, ${bytes} bytes`)
|
||||
return { files: files.length, bytes }
|
||||
} catch (error) {
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await buildDevtools()
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Immutable npm source discovery and generated frontend build inputs. */
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { DEVTOOLS_NPM_VERSION } from './source.ts'
|
||||
|
||||
/**
|
||||
* Resolve the installed, exact frontend source version without network access.
|
||||
* @returns The immutable npm package directory.
|
||||
*/
|
||||
export async function frontendSourceRoot(): Promise<string> {
|
||||
const file = fileURLToPath(import.meta.resolve('chrome-devtools-frontend/package.json'))
|
||||
const manifest: unknown = JSON.parse(await readFile(file, 'utf8'))
|
||||
if (typeof manifest !== 'object' || manifest === null || !('version' in manifest) || manifest.version !== DEVTOOLS_NPM_VERSION) {
|
||||
throw new Error(`Expected chrome-devtools-frontend ${DEVTOOLS_NPM_VERSION}`)
|
||||
}
|
||||
return dirname(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate icons in the flattened layout used by DevTools.
|
||||
* @param frontEnd - Installed frontend source directory.
|
||||
* @returns Original paths and destination filenames.
|
||||
*/
|
||||
export async function frontendImages(frontEnd: string): Promise<Array<{ source: string; name: string }>> {
|
||||
const files: Array<{ source: string; name: string }> = []
|
||||
for (const dir of [join(frontEnd, 'Images'), join(frontEnd, 'Images/src')]) {
|
||||
for (const file of await readdir(dir, { withFileTypes: true })) {
|
||||
if (file.isFile() && /\.(?:svg|png|avif|gif|webp|jpe?g)$/u.test(file.name)) {
|
||||
files.push({ source: join(dir, file.name), name: file.name })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (new Set(files.map(file => file.name)).size !== files.length) throw new Error('DevTools flattened image filenames collide')
|
||||
return files.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the stylesheet-backed icon module from the npm package's image files.
|
||||
* @param images - Flattened icon filenames.
|
||||
* @returns Browser module assigning the upstream image CSS variables.
|
||||
*/
|
||||
export function imagesModule(images: readonly { name: string }[]): string {
|
||||
return `const sheet = new CSSStyleSheet(); sheet.replaceSync(":root {}");
|
||||
const style = sheet.cssRules[0].style;
|
||||
${images.map(({ name }) => `style.setProperty(${JSON.stringify(`--image-file-${name.replace(/\.[^.]+$/u, '')}`)}, 'url("' + new URL(${JSON.stringify(`Images/${name}`)}, document.baseURI).href + '")');`).join('\n')}
|
||||
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];`
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/** Source-level adapters for the pinned npm frontend, without writing into node_modules. */
|
||||
import ts from 'typescript'
|
||||
|
||||
/** Fixed npm source snapshot on the Chromium 150 branch's ancestry. */
|
||||
export const DEVTOOLS_NPM_VERSION = '1.0.1638082'
|
||||
/** Upstream commit recorded by that npm release. */
|
||||
export const DEVTOOLS_SOURCE_REVISION = '0e1186138ed519d9659c1874bf6375eca4483c72'
|
||||
|
||||
interface Edit { start: number; end: number; text: string }
|
||||
|
||||
/**
|
||||
* Preserve original module-relative resource URLs after bundling into chunks.
|
||||
* @param path - Frontend-relative source path.
|
||||
* @param source - Original TypeScript or JavaScript.
|
||||
* @returns Source with URL expressions resolved against the frontend's deployed root.
|
||||
*/
|
||||
export function restoreModuleUrls(path: string, source: string): string {
|
||||
if (!source.includes('import.meta')) return source
|
||||
const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
||||
const base = '(typeof document !== "undefined" ? document.baseURI : new URL("../../", self.location.href).href)'
|
||||
const moduleUrl = `new URL(${JSON.stringify(path.replace(/\.ts$/u, '.js'))}, ${base}).href`
|
||||
const edits: Edit[] = []
|
||||
const isMeta = (node: ts.Node): boolean => ts.isMetaProperty(node) && node.keywordToken === ts.SyntaxKind.ImportKeyword
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
|
||||
&& isMeta(node.expression.expression) && node.expression.name.text === 'resolve') {
|
||||
if (node.arguments.length !== 1) throw new Error(`DevTools ${path}: import.meta.resolve must have one argument`)
|
||||
edits.push({ start: node.getStart(file), end: node.end, text: `new URL(${node.arguments[0]?.getText(file)}, ${moduleUrl}).href` })
|
||||
return
|
||||
}
|
||||
if (ts.isPropertyAccessExpression(node) && isMeta(node.expression) && node.name.text === 'url') {
|
||||
edits.push({ start: node.getStart(file), end: node.end, text: `(${moduleUrl})` })
|
||||
return
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(file)
|
||||
return applyEdits(source, edits)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the existing Host CPU profiler as Performance's recording target.
|
||||
* @param path - Frontend-relative source path.
|
||||
* @param source - Original module source.
|
||||
* @returns Source retaining the page target for Elements and the Host target for CPU recording.
|
||||
*/
|
||||
export function configureHostPerformanceSource(path: string, source: string): string {
|
||||
if (path !== 'panels/timeline/TimelinePanel.ts') return source
|
||||
return replaceOnce(replaceOnce(source,
|
||||
'#isNode = Root.Runtime.Runtime.isNode();', '#isNode = true;', path),
|
||||
'SDK.TargetManager.TargetManager.instance().targets().find(target => target.type() === SDK.Target.Type.NODE)',
|
||||
'SDK.TargetManager.TargetManager.instance().primaryPageTarget()', path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a stylesheet into the text module consumed by DevTools StyleSheet APIs.
|
||||
* @param source - Unmodified stylesheet text.
|
||||
* @returns An ESM default export containing the same text.
|
||||
*/
|
||||
export function cssTextModule(source: string): string { return `export default ${JSON.stringify(source)};` }
|
||||
|
||||
/**
|
||||
* Select the standalone browser branch of legacy CodeMirror UMD wrappers used by formatter Workers.
|
||||
* @param path - Frontend-relative module path.
|
||||
* @param source - Original JavaScript.
|
||||
* @returns Browser wrappers without Node require calls activated by bundler-local module variables.
|
||||
*/
|
||||
export function configureCodeMirrorBrowser(path: string, source: string): string {
|
||||
if (!path.startsWith('third_party/codemirror/package/') || !path.endsWith('.mjs')) return source
|
||||
return source.replaceAll('typeof exports == "object" && typeof module == "object"', 'false')
|
||||
}
|
||||
|
||||
function replaceOnce(source: string, before: string, after: string, path: string): string {
|
||||
if (source.split(before).length !== 2) throw new Error(`DevTools ${path}: expected source expression changed: ${before}`)
|
||||
return source.replace(before, after)
|
||||
}
|
||||
|
||||
function applyEdits(source: string, edits: Edit[]): string {
|
||||
for (const edit of edits.sort((left, right) => right.start - left.start)) {
|
||||
source = source.slice(0, edit.start) + edit.text + source.slice(edit.end)
|
||||
}
|
||||
return source
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/** Vite compilation of the immutable npm DevTools frontend and its runtime-fetched assets. */
|
||||
import { readFile, readdir, stat } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { build, type Plugin, type InlineConfig } from 'vite'
|
||||
import { frontendImages, frontendSourceRoot, imagesModule } from './inputs.ts'
|
||||
import { configureCodeMirrorBrowser, configureHostPerformanceSource, cssTextModule, restoreModuleUrls } from './source.ts'
|
||||
|
||||
const assets = fileURLToPath(new URL('../../assets/devtools/', import.meta.url))
|
||||
const CONNECT = join(assets, 'connect.js')
|
||||
const VIRTUAL = '\0dsh-devtools:'
|
||||
const EMPTY = `${VIRTUAL}empty`
|
||||
const ENTRIES = ['devtools_app', 'device_mode_emulation_frame', 'formatter_worker', 'heap_snapshot_worker', 'wasmparser_worker', 'lighthouse_worker'] as const
|
||||
|
||||
/**
|
||||
* Build the English-only frontend from installed npm sources and local integration assets.
|
||||
* @param output - Private output directory owned by the outer publication step.
|
||||
*/
|
||||
export async function buildFrontend(output: string): Promise<void> {
|
||||
const root = await frontendSourceRoot()
|
||||
const front = join(root, 'front_end')
|
||||
const images = await frontendImages(front)
|
||||
const generated = new Map<string, string>([
|
||||
['Images/Images.js', imagesModule(images)],
|
||||
// An empty dictionary makes upstream i18n use the English UIStrings embedded in each module.
|
||||
['core/i18n/locales.js', `export const LOCALES = ["en-US"], BUNDLED_LOCALES = LOCALES, DEFAULT_LOCALE = "en-US";
|
||||
export const LOCAL_FETCH_PATTERN = "data:application/json,{}", REMOTE_FETCH_PATTERN = LOCAL_FETCH_PATTERN;`],
|
||||
['panels/timeline/EasterEgg.js', 'export const SHOULD_SHOW_EASTER_EGG = false;'],
|
||||
])
|
||||
const sourcePlugin = (): Plugin => ({
|
||||
name: 'dsh-devtools-source', enforce: 'pre',
|
||||
async resolveId(request, importer) {
|
||||
if (request === 'puppeteer' || request === 'lighthouse' || request === 'node:util' || request.includes('/NodeWebSocketTransport.js')) return EMPTY
|
||||
if (!importer || (!request.startsWith('.') && !isAbsolute(request))) return null
|
||||
const file = resolve(importer === CONNECT ? front : dirname(importer.split('?')[0]!), request)
|
||||
const sourcePath = relative(front, file).split('\\').join('/')
|
||||
if (generated.has(sourcePath)) return `${VIRTUAL}${sourcePath}`
|
||||
if (sourcePath === 'core/platform/node/node.js') return join(front, 'core/platform/browser/browser.ts')
|
||||
if (file.endsWith('.css.js')) return `${VIRTUAL}css:${file.slice(0, -3)}.mjs`
|
||||
if (file.endsWith('.js')) {
|
||||
const tsFile = file.slice(0, -3) + '.ts'
|
||||
try { if ((await stat(tsFile)).isFile()) return tsFile } catch (error) {
|
||||
if (!isMissing(error)) throw error
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
async load(id) {
|
||||
if (id === EMPTY) return 'export {};'
|
||||
if (id.startsWith(`${VIRTUAL}css:`)) {
|
||||
const path = id.slice(`${VIRTUAL}css:`.length, -4)
|
||||
this.addWatchFile(path)
|
||||
return cssTextModule(await readFile(path, 'utf8'))
|
||||
}
|
||||
if (id.startsWith(VIRTUAL)) return generated.get(id.slice(VIRTUAL.length))
|
||||
return null
|
||||
},
|
||||
transform(code, id) {
|
||||
if (!id.replaceAll('\\', '/').startsWith(front.replaceAll('\\', '/') + '/') || !/\.(?:ts|m?js)$/u.test(id)) return null
|
||||
const path = relative(front, id).split('\\').join('/')
|
||||
return { code: restoreModuleUrls(path, configureCodeMirrorBrowser(path,
|
||||
configureHostPerformanceSource(path, code))), map: null }
|
||||
},
|
||||
})
|
||||
const base: InlineConfig = {
|
||||
configFile: false, root: front, logLevel: 'warn', publicDir: false,
|
||||
// Upstream modules use const-enum members before their declarations.
|
||||
oxc: { target: 'es2022', typescript: { optimizeConstEnums: true }, decorator: { legacy: true } },
|
||||
define: { 'process.env.NODE_ENV': JSON.stringify('production') },
|
||||
}
|
||||
const injected: Array<{ path: string; bytes: string }> = []
|
||||
for (const directory of ['panels/recorder/injected/injected', 'models/live-metrics/web-vitals-injected/web-vitals-injected']) {
|
||||
const result = await build({ ...base, plugins: [sourcePlugin()], build: {
|
||||
write: false, minify: 'oxc', target: 'es2022', sourcemap: false,
|
||||
lib: { entry: join(front, `${directory}.ts`), name: 'DevToolsInjected', formats: ['iife'] },
|
||||
} })
|
||||
const outputs = Array.isArray(result) ? result : [result]
|
||||
const chunks = outputs.flatMap(item => 'output' in item ? item.output.filter(file => file.type === 'chunk') : [])
|
||||
if (chunks.length !== 1) throw new Error(`DevTools injected script must produce one chunk: ${directory}`)
|
||||
injected.push({ path: `${directory}.generated.js`, bytes: chunks[0]!.code })
|
||||
}
|
||||
const entryFiles = Object.fromEntries(ENTRIES.map(name => [name, `entrypoints/${name}/${name}${name.endsWith('_worker') && name !== 'lighthouse_worker' ? '-entrypoint' : ''}.js`]))
|
||||
const input = Object.fromEntries(ENTRIES.map(name => [name, join(front, entryFiles[name]!.replace(/\.js$/u, '.ts'))]))
|
||||
input.connect = CONNECT
|
||||
await build({ ...base, plugins: [sourcePlugin(), {
|
||||
name: 'dsh-devtools-static-assets',
|
||||
async generateBundle() {
|
||||
const emit = (fileName: string, source: string | Uint8Array): void => { this.emitFile({ type: 'asset', fileName, source }) }
|
||||
for (const file of ['application_tokens.css', 'design_system_tokens.css']) emit(file, await readFile(join(front, file)))
|
||||
for (const image of images) emit(`Images/${image.name}`, await readFile(image.source))
|
||||
for (const dir of ['models/issues_manager/descriptions', 'panels/whats_new/resources', 'emulated_devices/optimized']) {
|
||||
for (const file of await filesUnder(join(front, dir))) emit(`${dir}/${file}`, await readFile(join(front, dir, file)))
|
||||
}
|
||||
for (const file of await filesUnder(join(front, 'third_party'))) {
|
||||
if (/(?:^|\/)(?:LICENSE|NOTICE|COPYING)(?:\.[^/]*)?$/u.test(file)) {
|
||||
emit(`third_party/${file}`, await readFile(join(front, 'third_party', file)))
|
||||
}
|
||||
}
|
||||
for (const asset of injected) emit(asset.path, asset.bytes)
|
||||
emit('LICENSE', await readFile(join(assets, 'LICENSE')))
|
||||
for (const name of ['devtools_app', 'device_mode_emulation_frame']) {
|
||||
const html = await readFile(join(front, 'entrypoint_template.html'), 'utf8')
|
||||
const source = html.replaceAll('%ENTRYPOINT_NAME%', name)
|
||||
.replace(' https://chrome-devtools-frontend.appspot.com', '')
|
||||
.replace(/<script type="module"/u, name === 'devtools_app' ? '<script type="module" src="./connect.js"></script><script type="module"' : '<script type="module"')
|
||||
if (source.includes('%ENTRYPOINT_NAME%')) throw new Error('DevTools HTML entry placeholder changed')
|
||||
emit(`${name}.html`, source)
|
||||
}
|
||||
},
|
||||
}], build: {
|
||||
outDir: output, emptyOutDir: true, minify: 'oxc', sourcemap: false, target: 'es2022', modulePreload: false,
|
||||
lib: { entry: input, formats: ['es'] },
|
||||
rolldownOptions: { output: {
|
||||
entryFileNames: chunk => chunk.name === 'connect' ? 'connect.js' : entryFiles[chunk.name]!,
|
||||
chunkFileNames: 'chunks/[name]-[hash].js',
|
||||
} },
|
||||
} })
|
||||
}
|
||||
|
||||
async function filesUnder(root: string, prefix = ''): Promise<string[]> {
|
||||
const files: string[] = []
|
||||
for (const item of await readdir(join(root, prefix), { withFileTypes: true })) {
|
||||
const path = prefix ? `${prefix}/${item.name}` : item.name
|
||||
if (item.isDirectory()) files.push(...await filesUnder(root, path))
|
||||
else if (item.isFile() && !/\.(?:ts|gn|md\.in)$/u.test(path) && path !== 'README.md') files.push(path)
|
||||
}
|
||||
return files.sort()
|
||||
}
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
/** Mirror a fixed Chromium DevTools resource graph without changing its directory layout. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
|
||||
/** DevTools revision shipped by Chrome 150.0.7871.186. */
|
||||
export const DEVTOOLS_REVISION = 'ec97cf3bbeea2cb623fbf97c4e3f22f5acb4d568'
|
||||
/** Chrome release supplying the pinned frontend. */
|
||||
export const DEVTOOLS_CHROMIUM_VERSION = '150.0.7871.186'
|
||||
/** Immutable upstream root used by the local frontend distribution. */
|
||||
export const DEVTOOLS_CDN = new URL(`https://chrome-devtools-frontend.appspot.com/serve_rev/@${DEVTOOLS_REVISION}/`)
|
||||
/** Reusable build cache; only a complete graph is copied into lib. */
|
||||
export const DEVTOOLS_CACHE = fileURLToPath(new URL(`../.cache/devtools/${DEVTOOLS_REVISION}/`, import.meta.url))
|
||||
|
||||
const ASSET_EXTENSION = /\.(?:js|css|json|wasm|svg|png|avif|webp|jpg|jpeg|gif|woff2?|ttf)(?:[?#].*)?$/iu
|
||||
|
||||
/** Literal CDN dependencies and references that require a runtime-specific resolver. */
|
||||
export interface DevtoolsDependencies {
|
||||
readonly urls: URL[]
|
||||
readonly externalModules: string[]
|
||||
readonly dynamicImports: string[]
|
||||
}
|
||||
|
||||
/** One verified resource in the complete, untrimmed input graph. */
|
||||
export interface DevtoolsResource {
|
||||
readonly path: string
|
||||
readonly sha256: string
|
||||
readonly bytes: number
|
||||
readonly dependencies: string[]
|
||||
readonly externalModules: string[]
|
||||
readonly dynamicImports: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve imports, CSS/HTML assets, and literal Worker/resource URLs inside one CDN resource.
|
||||
* @param source - Decoded JavaScript, CSS, or HTML.
|
||||
* @param file - Absolute URL of this resource.
|
||||
* @param root - Allowed version-specific CDN root.
|
||||
* @returns CDN resources plus explicitly recorded bare/Node imports and nonliteral imports.
|
||||
*/
|
||||
export function findDevtoolsDependencies(source: string, file: URL, root: URL): DevtoolsDependencies {
|
||||
const dependencies = new Map<string, URL>()
|
||||
const externalModules = new Set<string>()
|
||||
const dynamicImports = new Set<string>()
|
||||
const module = (reference: string): void => {
|
||||
if (reference.startsWith('node:') || (!reference.startsWith('.') && !reference.startsWith('/')
|
||||
&& !/^https?:/u.test(reference))) externalModules.add(reference)
|
||||
else add(reference, file, true)
|
||||
}
|
||||
const add = (reference: string, base = file, required = false): void => {
|
||||
if (reference.startsWith('node:') || reference.startsWith('data:') || reference.startsWith('blob:')
|
||||
|| reference.startsWith('#')) return
|
||||
if (!required && !ASSET_EXTENSION.test(reference)) return
|
||||
const url = new URL(reference, base)
|
||||
url.hash = ''
|
||||
if (url.origin !== root.origin || !url.pathname.startsWith(root.pathname)) {
|
||||
if (required) throw new Error(`DevTools dependency escapes the pinned revision: ${url.href}`)
|
||||
return
|
||||
}
|
||||
dependencies.set(url.href, url)
|
||||
}
|
||||
const css = (value: string): void => {
|
||||
for (const match of value.matchAll(/url\(\s*["']?([^\s"')]+)["']?\s*\)/giu)) add(match[1]!)
|
||||
for (const match of value.matchAll(/@import\s+["']([^"']+)["']/giu)) add(match[1]!, file, true)
|
||||
}
|
||||
if (file.pathname.endsWith('.html')) {
|
||||
for (const match of source.matchAll(/<(?:script|link|img)\b[^>]*?\b(?:src|href)=["']([^"']+)["']/giu)) {
|
||||
add(match[1]!, file, true)
|
||||
}
|
||||
css(source)
|
||||
} else if (file.pathname.endsWith('.css')) css(source)
|
||||
else if (file.pathname.endsWith('.js')) {
|
||||
const ast = ts.createSourceFile(file.pathname, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
|
||||
const localeLists = new Map<string, string[]>()
|
||||
let lighthouseLocaleTables = 0
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (file.pathname.endsWith('/third_party/lighthouse/lighthouse-dt-bundle.js') && ts.isArrayLiteralExpression(node)
|
||||
&& node.elements.every(ts.isStringLiteralLike)
|
||||
&& node.elements.filter(ts.isStringLiteralLike).some(element => element.text.endsWith('.ctc.json'))
|
||||
&& node.elements.filter(ts.isStringLiteralLike).every(element => element.text.endsWith('.json'))) {
|
||||
lighthouseLocaleTables++
|
||||
for (const element of node.elements.filter(ts.isStringLiteralLike)) {
|
||||
if (!element.text.endsWith('.ctc.json')) add(`./locales/${element.text}`, file, true)
|
||||
}
|
||||
}
|
||||
if (file.pathname.endsWith('/core/i18n/i18n.js') && ts.isVariableDeclaration(node)
|
||||
&& ts.isIdentifier(node.name) && node.initializer !== undefined && ts.isArrayLiteralExpression(node.initializer)
|
||||
&& node.initializer.elements.every(ts.isStringLiteralLike)) {
|
||||
localeLists.set(node.name.text, node.initializer.elements.filter(ts.isStringLiteralLike).map(element => element.text))
|
||||
}
|
||||
if (file.pathname.endsWith('/core/i18n/i18n.js') && ts.isNewExpression(node)
|
||||
&& ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'I18n'
|
||||
&& node.arguments?.[0] !== undefined && ts.isIdentifier(node.arguments[0])) {
|
||||
const locales = localeLists.get(node.arguments[0].text)
|
||||
if (locales === undefined) throw new Error('DevTools supported locales must be declared before the I18n constructor')
|
||||
for (const locale of locales) add(`./locales/${locale}.json`, file, true)
|
||||
}
|
||||
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node))
|
||||
&& node.moduleSpecifier !== undefined && ts.isStringLiteralLike(node.moduleSpecifier)) {
|
||||
module(node.moduleSpecifier.text)
|
||||
}
|
||||
if (ts.isCallExpression(node) && node.arguments[0] !== undefined && ts.isStringLiteralLike(node.arguments[0])) {
|
||||
if (node.expression.kind === ts.SyntaxKind.ImportKeyword) module(node.arguments[0].text)
|
||||
else if (node.expression.getText(ast) === 'import.meta.resolve' && !node.arguments[0].text.endsWith('.css')) {
|
||||
add(node.arguments[0].text, file, true)
|
||||
}
|
||||
}
|
||||
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword
|
||||
&& (node.arguments[0] === undefined || !ts.isStringLiteralLike(node.arguments[0]))) {
|
||||
dynamicImports.add(node.getText(ast))
|
||||
}
|
||||
if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'URL'
|
||||
&& node.arguments?.[0] !== undefined && ts.isStringLiteralLike(node.arguments[0])
|
||||
&& node.arguments[1]?.getText(ast) === 'import.meta.url') add(node.arguments[0].text)
|
||||
if (ts.isStringLiteralLike(node)) {
|
||||
if (node.text.startsWith('Images/')) add(node.text, root)
|
||||
if (file.pathname.endsWith('.css.js')) css(node.text)
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(ast)
|
||||
if (file.pathname.endsWith('/third_party/lighthouse/lighthouse-dt-bundle.js') && lighthouseLocaleTables !== 1) {
|
||||
throw new Error('DevTools Lighthouse bundle must declare one canonical locale filename table')
|
||||
}
|
||||
}
|
||||
return { urls: [...dependencies.values()], externalModules: [...externalModules], dynamicImports: [...dynamicImports] }
|
||||
}
|
||||
|
||||
function localPath(url: URL, root: URL, directory: string): string {
|
||||
const relative = decodeURIComponent(url.pathname.slice(root.pathname.length))
|
||||
if (relative.split('/').some(part => part === '..' || part === '.' || part.includes('\\'))) {
|
||||
throw new Error(`Invalid DevTools resource path: ${url.href}`)
|
||||
}
|
||||
const path = resolve(directory, relative)
|
||||
if (!path.startsWith(`${resolve(directory)}${sep}`)) throw new Error(`DevTools resource escapes the cache: ${url.href}`)
|
||||
return path
|
||||
}
|
||||
|
||||
async function fetchResource(url: URL): Promise<Uint8Array> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, { headers: { 'accept-encoding': 'identity' }, signal: AbortSignal.timeout(30_000) })
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel()
|
||||
if (attempt < 2 && (response.status === 429 || response.status >= 500)) continue
|
||||
throw new Error(`DevTools download failed: HTTP ${response.status} ${url.href}`)
|
||||
}
|
||||
return new Uint8Array(await response.arrayBuffer())
|
||||
} catch (error) {
|
||||
if (attempt < 2 && (error instanceof TypeError || (error instanceof Error && error.name === 'TimeoutError'))) continue
|
||||
throw new Error(`DevTools resource failed: ${url.href}`, { cause: error })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the fixed frontend and literal dependency graph into the reusable cache.
|
||||
* @returns Cache directory and complete graph, before product-specific pruning.
|
||||
*/
|
||||
export async function downloadDevtools(): Promise<{ directory: string; files: DevtoolsResource[] }> {
|
||||
const queue: URL[] = []
|
||||
const seen = new Set<string>()
|
||||
const files: DevtoolsResource[] = []
|
||||
const enqueue = (url: URL): void => {
|
||||
if (seen.has(url.href)) return
|
||||
seen.add(url.href)
|
||||
queue.push(url)
|
||||
}
|
||||
for (const path of ['devtools_app.html', 'core/i18n/locales/en-US.json', 'core/i18n/locales/zh.json']) {
|
||||
enqueue(new URL(path, DEVTOOLS_CDN))
|
||||
}
|
||||
while (queue.length > 0) {
|
||||
const batch = queue.splice(0, 6)
|
||||
const results = await Promise.allSettled(batch.map(async (url) => {
|
||||
const path = localPath(url, DEVTOOLS_CDN, DEVTOOLS_CACHE)
|
||||
let bytes: Uint8Array
|
||||
try {
|
||||
bytes = await readFile(path)
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error
|
||||
bytes = await fetchResource(url)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
const temporary = `${path}.${process.pid}.partial`
|
||||
await writeFile(temporary, bytes)
|
||||
await rename(temporary, path)
|
||||
}
|
||||
const relative = url.pathname.slice(DEVTOOLS_CDN.pathname.length)
|
||||
const dependencies = /\.(?:js|css|html)$/u.test(url.pathname)
|
||||
? findDevtoolsDependencies(new TextDecoder().decode(bytes), url, DEVTOOLS_CDN)
|
||||
: { urls: [], externalModules: [], dynamicImports: [] }
|
||||
files.push({ path: relative, sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.byteLength,
|
||||
dependencies: dependencies.urls.map(dependency => dependency.pathname.slice(DEVTOOLS_CDN.pathname.length)),
|
||||
externalModules: dependencies.externalModules, dynamicImports: dependencies.dynamicImports })
|
||||
for (const dependency of dependencies.urls) enqueue(dependency)
|
||||
}))
|
||||
const failures = results.filter(result => result.status === 'rejected')
|
||||
if (failures.length > 0) throw new AggregateError(failures.map(result => result.reason), 'DevTools resource graph is incomplete')
|
||||
console.log(`DevTools ${DEVTOOLS_REVISION.slice(0, 12)}: ${files.length} files, ${queue.length} pending`)
|
||||
}
|
||||
const groups = new Map<string, { path: string; files: number; bytes: number }>()
|
||||
for (const file of files) {
|
||||
const parts = file.path.split('/')
|
||||
const key = file.path.startsWith('core/i18n/locales/') ? 'core/i18n/locales' : parts.slice(0, parts.length > 2 ? 2 : 1).join('/')
|
||||
const group = groups.get(key) ?? { path: key, files: 0, bytes: 0 }
|
||||
group.files++
|
||||
group.bytes += file.bytes
|
||||
groups.set(key, group)
|
||||
}
|
||||
await writeFile(resolve(DEVTOOLS_CACHE, 'resource-manifest.json'), `${JSON.stringify({
|
||||
chromium: DEVTOOLS_CHROMIUM_VERSION, revision: DEVTOOLS_REVISION, baseUrl: DEVTOOLS_CDN.href,
|
||||
totalFiles: files.length, totalBytes: files.reduce((total, file) => total + file.bytes, 0),
|
||||
groups: [...groups.values()].sort((left, right) => right.bytes - left.bytes),
|
||||
files: files.sort((left, right) => left.path.localeCompare(right.path)),
|
||||
}, null, 2)}\n`)
|
||||
return { directory: DEVTOOLS_CACHE, files }
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await downloadDevtools()
|
||||
@@ -1,258 +0,0 @@
|
||||
/** Remove Lighthouse contributions from the pinned Chrome 150 frontend without changing other panels or AI tools. */
|
||||
import ts from 'typescript'
|
||||
|
||||
const ENTRY = 'entrypoints/devtools_app/devtools_app.js'
|
||||
const PANEL = 'panels/ai_assistance/ai_assistance.js'
|
||||
const MODEL = 'models/ai_assistance/ai_assistance.js'
|
||||
const IMAGES = 'Images/Images.js'
|
||||
const LIGHTHOUSE_IMPORT = /(?:^|\/)lighthouse\/(?:lighthouse|lighthouse-dt-bundle)\.js$/u
|
||||
|
||||
interface Edit { readonly start: number; readonly end: number; readonly text: string }
|
||||
|
||||
/**
|
||||
* Remove the pinned revision's Lighthouse panel, report UI, and AI audit commands.
|
||||
* @param path - CDN-root-relative JavaScript path, with either path separator.
|
||||
* @param source - Original resource bytes decoded as UTF-8; no file is read or written.
|
||||
* @returns Transformed source for the four owning modules; unrelated modules retain their exact bytes.
|
||||
* @throws When an expected contribution differs, a removal overlaps, or a removed binding remains referenced.
|
||||
*/
|
||||
export function pruneLighthouse(path: string, source: string): string {
|
||||
path = path.replaceAll('\\', '/')
|
||||
if (![ENTRY, PANEL, MODEL, IMAGES].includes(path)) return source
|
||||
const file = parse(path, source)
|
||||
const edits: Edit[] = []
|
||||
const counts = new Map<string, number>()
|
||||
const removedNames = new Set<string>()
|
||||
const removedPrivateNames = new Map<ts.ClassLikeDeclaration, Set<string>>()
|
||||
function fail(message: string): never { throw new Error(`pruneLighthouse: ${path}: ${message}`) }
|
||||
const count = (name: string): void => { counts.set(name, (counts.get(name) ?? 0) + 1) }
|
||||
const edit = (node: ts.Node, text: string, name: string): void => {
|
||||
edits.push({ start: node.getStart(file), end: node.end, text })
|
||||
count(name)
|
||||
}
|
||||
const remove = (node: ts.Node, name: string): void => { edit(node, '', name) }
|
||||
const removeItem = (node: ts.Node, items: readonly ts.Node[], name: string): void => {
|
||||
const index = items.indexOf(node)
|
||||
if (index < 0) fail(`cannot locate ${name} in its containing list`)
|
||||
if (items.length === 1) { remove(node, name); return }
|
||||
edits.push(index === 0
|
||||
? { start: node.getStart(file), end: items[1]!.getStart(file), text: '' }
|
||||
: { start: items[index - 1]!.end, end: node.end, text: '' })
|
||||
count(name)
|
||||
}
|
||||
const removeExpression = (node: ts.Expression, name: string): void => {
|
||||
const parent = node.parent
|
||||
if (ts.isExpressionStatement(parent)) { remove(parent, name); return }
|
||||
if (!ts.isBinaryExpression(parent) || parent.operatorToken.kind !== ts.SyntaxKind.CommaToken) {
|
||||
fail(`${name} is not an independent expression`)
|
||||
}
|
||||
edits.push(parent.left === node
|
||||
? { start: node.getStart(file), end: parent.right.getStart(file), text: '' }
|
||||
: { start: parent.left.end, end: node.end, text: '' })
|
||||
count(name)
|
||||
}
|
||||
const requireCount = (name: string, expected: number): void => {
|
||||
if ((counts.get(name) ?? 0) !== expected) fail(`expected ${expected} ${name}, found ${counts.get(name) ?? 0}`)
|
||||
}
|
||||
const nodes = descendants(file)
|
||||
const imports = file.statements.filter(ts.isImportDeclaration).filter(node =>
|
||||
ts.isStringLiteralLike(node.moduleSpecifier) && LIGHTHOUSE_IMPORT.test(node.moduleSpecifier.text))
|
||||
const namespaces = new Set<string>()
|
||||
for (const declaration of imports) {
|
||||
const binding = declaration.importClause?.namedBindings
|
||||
if (binding === undefined || !ts.isNamespaceImport(binding)) fail('Lighthouse import must use a namespace')
|
||||
namespaces.add(binding.name.text)
|
||||
removedNames.add(binding.name.text)
|
||||
remove(declaration, 'Lighthouse imports')
|
||||
}
|
||||
const ownsLighthouse = (node: ts.Node): boolean => descendants(node).some(child =>
|
||||
ts.isIdentifier(child) && namespaces.has(child.text))
|
||||
|
||||
if (path === IMAGES) {
|
||||
requireCount('Lighthouse imports', 0)
|
||||
for (const node of nodes) {
|
||||
if (ts.isCallExpression(node) && methodName(node) === 'setProperty'
|
||||
&& node.arguments[0] !== undefined && ts.isStringLiteralLike(node.arguments[0])
|
||||
&& node.arguments[0].text === '--image-file-lighthouse_logo' && descendants(node).some(child =>
|
||||
ts.isStringLiteralLike(child) && child.text.replace(/^\.\//u, '') === 'lighthouse_logo.svg')) {
|
||||
edit(node, 'void 0', 'Lighthouse logo registrations')
|
||||
}
|
||||
}
|
||||
requireCount('Lighthouse logo registrations', 1)
|
||||
} else if (path === ENTRY) {
|
||||
for (const node of nodes) {
|
||||
if (ts.isFunctionDeclaration(node) && descendants(node).some(isLighthouseDynamicImport)) {
|
||||
if (node.name === undefined) fail('Lighthouse loader must have a name')
|
||||
removedNames.add(node.name.text)
|
||||
remove(node, 'lazy loaders')
|
||||
}
|
||||
if (ts.isVariableStatement(node) && node.parent === file && descendants(node).some(child =>
|
||||
ts.isStringLiteralLike(child) && child.text === 'panels/lighthouse/lighthouse-meta.ts')) {
|
||||
for (const declaration of node.declarationList.declarations) {
|
||||
if (!ts.isIdentifier(declaration.name)) fail('Lighthouse metadata must use named declarations')
|
||||
removedNames.add(declaration.name.text)
|
||||
}
|
||||
remove(node, 'metadata declarations')
|
||||
}
|
||||
if (!ts.isExpressionStatement(node) || !ts.isCallExpression(node.expression)) continue
|
||||
const call = node.expression
|
||||
if (methodName(call) === 'registerViewExtension' && descendants(call).some(child =>
|
||||
ts.isPropertyAssignment(child) && child.name.getText(file) === 'id'
|
||||
&& ts.isStringLiteralLike(child.initializer) && child.initializer.text === 'lighthouse')) {
|
||||
remove(node, 'panel registrations')
|
||||
}
|
||||
if (methodName(call) === 'registerRevealer' && ownsLighthouse(call)) remove(node, 'report revealers')
|
||||
}
|
||||
for (const name of ['Lighthouse imports', 'lazy loaders', 'metadata declarations', 'panel registrations', 'report revealers']) {
|
||||
requireCount(name, 1)
|
||||
}
|
||||
} else if (path === MODEL) {
|
||||
requireCount('Lighthouse imports', 0)
|
||||
for (const node of nodes) {
|
||||
if (!ts.isCallExpression(node) || methodName(node) !== 'declareFunction') continue
|
||||
const name = node.arguments[0]
|
||||
if (name !== undefined && ts.isStringLiteralLike(name)
|
||||
&& ['runLighthouseAudits', 'runAccessibilityAudits', 'getLighthouseAudits'].includes(name.text)) {
|
||||
removeExpression(node, name.text)
|
||||
}
|
||||
}
|
||||
for (const name of ['runLighthouseAudits', 'runAccessibilityAudits', 'getLighthouseAudits']) requireCount(name, 1)
|
||||
} else {
|
||||
const renderer = nodes.filter(ts.isFunctionDeclaration).filter(node => ownsLighthouse(node))
|
||||
if (renderer.length !== 1 || renderer[0]!.name === undefined) fail('expected one Lighthouse report renderer')
|
||||
const rendererName = renderer[0]!.name!.text
|
||||
removedNames.add(rendererName)
|
||||
remove(renderer[0]!, 'report renderers')
|
||||
const recording = nodes.filter(ts.isMethodDeclaration).filter(node => descendants(node).some(child =>
|
||||
ts.isCallExpression(child) && methodName(child) === 'executeLighthouseRecording' && ownsLighthouse(child)))
|
||||
if (recording.length !== 1 || !ts.isPrivateIdentifier(recording[0]!.name)) fail('expected one private Lighthouse recording method')
|
||||
const recordMethod = recording[0]!
|
||||
const owner = recordMethod.parent
|
||||
if (!ts.isClassExpression(owner) && !ts.isClassDeclaration(owner)) fail('recording method has no owning class')
|
||||
const privateNames = new Set([recordMethod.name.getText(file)])
|
||||
removedPrivateNames.set(owner, privateNames)
|
||||
remove(recordMethod, 'recording methods')
|
||||
|
||||
const reportReads = nodes.filter(ts.isBinaryExpression).filter(node => node.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
||||
&& ts.isPropertyAccessExpression(node.left) && ts.isPrivateIdentifier(node.left.name)
|
||||
&& ownsLighthouse(node.right))
|
||||
if (reportReads.length !== 1) fail('expected one enclosing report-context assignment')
|
||||
const reportRead = reportReads[0]!
|
||||
if (!ts.isPropertyAccessExpression(reportRead.left) || !ts.isCallExpression(reportRead.right)
|
||||
|| !ts.isIdentifier(reportRead.right.expression)) fail('unexpected report-context assignment')
|
||||
const reportField = reportRead.left.name.text
|
||||
const contextFactory = reportRead.right.expression.text
|
||||
privateNames.add(reportField)
|
||||
removedNames.add(contextFactory)
|
||||
removeExpression(reportRead, 'report-context reads')
|
||||
|
||||
for (const node of nodes) {
|
||||
if (ts.isFunctionDeclaration(node) && node.name?.text === contextFactory) remove(node, 'context factories')
|
||||
if (ts.isCallExpression(node) && ['addFlavorChangeListener', 'removeFlavorChangeListener'].includes(methodName(node) ?? '')
|
||||
&& ownsLighthouse(node)) {
|
||||
const listener = node.arguments[1]
|
||||
if (listener === undefined || !ts.isPropertyAccessExpression(listener) || !ts.isPrivateIdentifier(listener.name)) {
|
||||
fail('Lighthouse context listener must be a private member')
|
||||
}
|
||||
privateNames.add(listener.name.text)
|
||||
removeExpression(node, 'report-context listeners')
|
||||
}
|
||||
if (ts.isPropertyAssignment(node) && node.name.getText(file) === 'lighthouseRecording') {
|
||||
if (!ts.isCallExpression(node.initializer) || !node.initializer.getText(file).includes(recordMethod.name.getText(file))) {
|
||||
fail('unexpected Lighthouse recording callback')
|
||||
}
|
||||
if (!ts.isObjectLiteralExpression(node.parent)) fail('recording callback has no options object')
|
||||
removeItem(node, node.parent.properties, 'recording callbacks')
|
||||
}
|
||||
if (ts.isCaseClause(node) && ts.isStringLiteralLike(node.expression) && node.expression.text === 'LIGHTHOUSE_REPORT'
|
||||
&& descendants(node).some(child => ts.isIdentifier(child) && child.text === rendererName)) {
|
||||
edit(node, 'case"LIGHTHOUSE_REPORT":break;', 'report widget branches')
|
||||
}
|
||||
if (ts.isCaseClause(node) && ts.isStringLiteralLike(node.expression) && node.expression.text === 'accessibility'
|
||||
&& descendants(node).some(child => ts.isPrivateIdentifier(child) && child.text === reportField)) {
|
||||
edit(node, 'case"accessibility":return null;', 'report-context branches')
|
||||
}
|
||||
if (ts.isConditionalExpression(node) && descendants(node.condition).some(child =>
|
||||
ts.isPropertyAccessExpression(child) && child.name.text === 'AccessibilityContext')) {
|
||||
edit(node, node.whenFalse.getText(file), 'report-context selections')
|
||||
}
|
||||
if (ts.isConditionalExpression(node) && descendants(node.condition).some(child =>
|
||||
ts.isPropertyAccessExpression(child) && child.name.text === 'devToolsAiAssistanceAccessibilityAgent')) {
|
||||
edit(node, node.whenFalse.getText(file), 'Lighthouse view selections')
|
||||
}
|
||||
if (ts.isVariableDeclaration(node) && node.initializer !== undefined && ts.isCallExpression(node.initializer)
|
||||
&& methodName(node.initializer) === 'isViewVisible' && node.initializer.arguments[0] !== undefined
|
||||
&& ts.isStringLiteralLike(node.initializer.arguments[0]) && node.initializer.arguments[0].text === 'lighthouse') {
|
||||
if (!ts.isVariableDeclarationList(node.parent)) fail('Lighthouse visibility has no declaration list')
|
||||
removeItem(node, node.parent.declarations, 'Lighthouse visibility reads')
|
||||
}
|
||||
}
|
||||
for (const member of owner.members) {
|
||||
if (ts.isPropertyDeclaration(member) && ts.isPrivateIdentifier(member.name) && privateNames.has(member.name.text)) {
|
||||
remove(member, 'report state members')
|
||||
}
|
||||
}
|
||||
for (const [name, expected] of [
|
||||
['Lighthouse imports', 2], ['context factories', 1], ['report-context listeners', 2], ['recording callbacks', 3],
|
||||
['report widget branches', 1], ['report-context branches', 1], ['report-context selections', 2],
|
||||
['Lighthouse view selections', 1], ['Lighthouse visibility reads', 1], ['report state members', 2],
|
||||
] as const) requireCount(name, expected)
|
||||
}
|
||||
|
||||
edits.sort((a, b) => a.start - b.start)
|
||||
let cursor = 0
|
||||
let result = ''
|
||||
for (const edit of edits) {
|
||||
if (edit.start < cursor) fail('Lighthouse removals overlap')
|
||||
result += source.slice(cursor, edit.start) + edit.text
|
||||
cursor = edit.end
|
||||
}
|
||||
result += source.slice(cursor)
|
||||
const output = parse(path, result)
|
||||
for (const node of descendants(output)) {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier) && LIGHTHOUSE_IMPORT.test(node.moduleSpecifier.text)
|
||||
|| isLighthouseDynamicImport(node)) fail('Lighthouse import remains after pruning')
|
||||
if (ts.isIdentifier(node) && removedNames.has(node.text)) fail(`removed binding ${node.text} remains referenced`)
|
||||
}
|
||||
// Private names belong to one class, so an unrelated AI class may reuse the same minified name.
|
||||
for (const [owner, names] of removedPrivateNames) {
|
||||
const ordinal = nodes.filter(isClass).filter(node => node.getStart(file) < owner.getStart(file)).length
|
||||
const rewritten = descendants(output).filter(isClass)[ordinal]
|
||||
if (rewritten === undefined) fail('AI class disappeared while pruning Lighthouse')
|
||||
for (const node of descendants(rewritten)) {
|
||||
if (ts.isPrivateIdentifier(node) && names.has(node.text)) fail(`removed private member ${node.text} remains referenced`)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function descendants(node: ts.Node): ts.Node[] {
|
||||
const result: ts.Node[] = []
|
||||
const visit = (child: ts.Node): void => { result.push(child); ts.forEachChild(child, visit) }
|
||||
visit(node)
|
||||
return result
|
||||
}
|
||||
|
||||
function methodName(node: ts.CallExpression): string | undefined {
|
||||
return ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : undefined
|
||||
}
|
||||
|
||||
function isLighthouseDynamicImport(node: ts.Node): boolean {
|
||||
return ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword
|
||||
&& node.arguments[0] !== undefined && ts.isStringLiteralLike(node.arguments[0])
|
||||
&& LIGHTHOUSE_IMPORT.test(node.arguments[0].text)
|
||||
}
|
||||
|
||||
function isClass(node: ts.Node): node is ts.ClassLikeDeclaration {
|
||||
return ts.isClassDeclaration(node) || ts.isClassExpression(node)
|
||||
}
|
||||
|
||||
function parse(path: string, source: string): ts.SourceFile {
|
||||
const result = ts.transpileModule(source, {
|
||||
fileName: path, reportDiagnostics: true,
|
||||
compilerOptions: { allowJs: true, target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext },
|
||||
})
|
||||
const error = result.diagnostics?.find(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error)
|
||||
if (error !== undefined) throw new Error(`pruneLighthouse: ${path}: ${ts.flattenDiagnosticMessageText(error.messageText, '\n')}`)
|
||||
return ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/** Dependency discovery for the immutable CDN mirror. */
|
||||
import { expect, it } from 'vitest'
|
||||
import { findDevtoolsDependencies } from '../scripts/download-devtools.ts'
|
||||
|
||||
const root = new URL('https://cdn.example/serve_rev/@revision/')
|
||||
const file = new URL('panels/example/module.js', root)
|
||||
const paths = (source: string, url = file) => findDevtoolsDependencies(source, url, root)
|
||||
.urls.map(dependency => dependency.pathname.slice(root.pathname.length)).sort()
|
||||
|
||||
it('follows static, exported, and literal dynamic imports together with Worker entrypoints and images', () => {
|
||||
expect(paths(`
|
||||
import './sibling.js';
|
||||
export * from '../../core/common/common.js';
|
||||
import('./lazy.js');
|
||||
import('node:worker_threads');
|
||||
const worker = import.meta.resolve('../../entrypoints/heap_snapshot_worker/heap_snapshot_worker-entrypoint.js');
|
||||
new URL('../../Images/icon.svg', import.meta.url);
|
||||
const image = 'Images/chromeLeft.avif';
|
||||
import('./lazy.js');
|
||||
`)).toEqual([
|
||||
'Images/chromeLeft.avif', 'Images/icon.svg', 'core/common/common.js',
|
||||
'entrypoints/heap_snapshot_worker/heap_snapshot_worker-entrypoint.js',
|
||||
'panels/example/lazy.js', 'panels/example/sibling.js',
|
||||
])
|
||||
})
|
||||
|
||||
it('follows HTML stylesheets and CSS assets without treating data URLs as downloads', () => {
|
||||
expect(paths('<script type="module" src="./main.js"></script><link href="./tokens.css" rel="stylesheet">',
|
||||
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',
|
||||
])
|
||||
})
|
||||
@@ -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<string> {
|
||||
const hash = createHash('sha256')
|
||||
const visit = async (path: string): Promise<void> => {
|
||||
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' })
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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<string, unknown> {
|
||||
const exports: Record<string, unknown> = {}
|
||||
// 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"); /* </script> */'],
|
||||
])('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: '<a title="test"/>', 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])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, string>
|
||||
files?: string[]
|
||||
devDependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
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'])(
|
||||
|
||||
Generated
+11
@@ -175,6 +175,9 @@ importers:
|
||||
typescript:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: 8.0.16
|
||||
version: 8.0.16(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
|
||||
vite-tsconfig-paths:
|
||||
specifier: ^6.1.1
|
||||
version: 6.1.1([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
@@ -8009,6 +8012,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
|
||||
@@ -18499,6 +18505,9 @@ packages:
|
||||
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KfbM5kZrgseWciU9Y6ic6hDtYWKY7SY6L8jkeq23KF5CgZ1cU7P+janhysDLN+XZr6U4Ro5V+2W7YOHjKW4PAw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RnzXoJiUQ44hpOihWk90uOhLD/CnwDkDy0ldHMZONJ2nYQ+dWN1fq1luHHqyd+7FuYnyIlCY6uTThbN5ut9kSQ==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
||||
@@ -25711,6 +25720,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string> {
|
||||
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 ? `
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user