diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 64f59017c9..3602ce87cb 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -78,6 +78,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`big.js`](https://github.com/MikeMcl/big.js) | MIT |
| [`buffer`](https://github.com/feross/buffer) | MIT |
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
+| [`chrome-devtools-frontend`](https://github.com/ChromeDevTools/devtools-frontend) | BSD-3-Clause |
| [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) | Apache-2.0 |
| [`clsx`](https://github.com/lukeed/clsx) | MIT |
| [`commander`](https://github.com/tj/commander.js) | MIT |
@@ -150,7 +151,7 @@ pnpm applies local patches to the following packages at install time, so shipped
## Chrome DevTools frontend
-The optional experimental Inspector distributes a local copy of [Chrome 150.0.7871.186's frontend](https://chrome-devtools-frontend.appspot.com/serve_rev/@ec97cf3bbeea2cb623fbf97c4e3f22f5acb4d568/devtools_app.html). The Chromium [BSD-3-Clause license](packages/experimental/inspector/assets/devtools/LICENSE) is included with the frontend. Copied JavaScript retains its embedded license comments; the resource downloader does not collect separate upstream third-party license files. The Chromium root license does not replace those dependencies' licenses.
+The optional experimental Inspector distributes a locally compiled copy of [chrome-devtools-frontend 1.0.1638082](https://www.npmjs.com/package/chrome-devtools-frontend/v/1.0.1638082), from upstream revision [0e1186138ed519d9659c1874bf6375eca4483c72](https://chromium.googlesource.com/devtools/devtools-frontend/+/0e1186138ed519d9659c1874bf6375eca4483c72). The build includes the Chromium [BSD-3-Clause license](packages/experimental/inspector/assets/devtools/LICENSE) and the third-party license and notice files supplied by the npm source. The Chromium root license does not replace those dependencies' licenses.
## Official Claude Code platform payloads
diff --git a/apps/web/tests/devtools-frontend.e2e.ts b/apps/web/tests/devtools-frontend.e2e.ts
index 0ca535ab4e..e80fd0a8d7 100644
--- a/apps/web/tests/devtools-frontend.e2e.ts
+++ b/apps/web/tests/devtools-frontend.e2e.ts
@@ -5,7 +5,7 @@ import { chromium, type Browser } from 'playwright'
import { expect, it, onTestFinished } from 'vitest'
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
-it('serves the trimmed DevTools frontend under its own path without rebuilding DevTools', async () => {
+it('serves the built DevTools frontend under its own path without rebuilding DevTools', async () => {
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
onTestFinished(async () => {
try { await resources.browser?.close() } finally { await resources.scaffold?.close() }
@@ -35,11 +35,9 @@ it('serves the trimmed DevTools frontend under its own path without rebuilding D
expect(html.headers()['content-type']).toContain('text/html')
expect(html.headers()['x-content-type-options']).toBe('nosniff')
const markup = await html.text()
- const importMap = markup.match(/',
- new URL('devtools_app.html', root))).toEqual(['main.js', 'tokens.css'])
- expect(paths('@import "./more.css"; body { background:url(../Images/icon.svg); src:url(data:font/woff2;base64,AAAA) }',
- new URL('styles/main.css', root))).toEqual(['Images/icon.svg', 'styles/more.css'])
-})
-
-it('discovers assets in emitted CSS modules but skips their diagnostic source URL', () => {
- expect(paths('const cssContent = "body { background:url(../../Images/icon.svg) }"; import.meta.resolve("./widget.css");',
- new URL('ui/example/widget.css.js', root))).toEqual(['Images/icon.svg'])
-})
-
-it('rejects module dependencies outside the pinned revision', () => {
- expect(() => paths('import "../../../other-revision/module.js"')).toThrow('escapes the pinned revision')
- expect(() => paths('import "https://other.example/module.js"')).toThrow('escapes the pinned revision')
-})
-
-it('enumerates every locale supplied to the upstream I18n constructor before any pruning', () => {
- expect(paths('const available = ["en-US", "zh", "fr"]; const i18n = new Library.I18n.I18n(available, "en-US");',
- new URL('core/i18n/i18n.js', root))).toEqual([
- 'core/i18n/locales/en-US.json', 'core/i18n/locales/fr.json', 'core/i18n/locales/zh.json',
- ])
-})
-
-it('records bare and variable imports instead of inventing relative CDN paths for them', () => {
- const result = findDevtoolsDependencies('import("puppeteer"); import("node:fs"); import(workerEntry);', file, root)
- expect(result.urls).toEqual([])
- expect(result.externalModules).toEqual(['puppeteer', 'node:fs'])
- expect(result.dynamicImports).toEqual(['import(workerEntry)'])
-})
-
-it('follows the Lighthouse locale table with its upstream source-catalog exclusion', () => {
- expect(paths('const locales = ["en-US.json", "en-US.ctc.json", "fr.json"].filter(name => !name.endsWith(".ctc.json"));',
- new URL('third_party/lighthouse/lighthouse-dt-bundle.js', root))).toEqual([
- 'third_party/lighthouse/locales/en-US.json', 'third_party/lighthouse/locales/fr.json',
- ])
-})
diff --git a/packages/experimental/inspector/tests/devtools-build.e2e.ts b/packages/experimental/inspector/tests/devtools-build.e2e.ts
new file mode 100644
index 0000000000..62f8cd18e9
--- /dev/null
+++ b/packages/experimental/inspector/tests/devtools-build.e2e.ts
@@ -0,0 +1,60 @@
+/** Offline source compilation preserves npm inputs and includes the browser's graph-external resources. */
+import { createHash } from 'node:crypto'
+import http from 'node:http'
+import https from 'node:https'
+import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { expect, it, onTestFinished, vi } from 'vitest'
+import { buildFrontend } from '../scripts/devtools/vite.ts'
+import { frontendSourceRoot } from '../scripts/devtools/inputs.ts'
+
+async function sourceHash(root: string): Promise {
+ const hash = createHash('sha256')
+ const visit = async (path: string): Promise => {
+ const entries = await readdir(join(root, path), { withFileTypes: true })
+ for (const item of entries.sort((left, right) => left.name.localeCompare(right.name))) {
+ const name = path ? `${path}/${item.name}` : item.name
+ hash.update(name)
+ if (item.isDirectory()) await visit(name)
+ else if (item.isFile()) hash.update(await readFile(join(root, name)))
+ }
+ }
+ await visit('')
+ return hash.digest('hex')
+}
+
+it('builds from fixed installed sources with network APIs blocked and includes Memory and injected entries', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'dsh-devtools-build-'))
+ onTestFinished(() => rm(root, { recursive: true, force: true }))
+ const source = await frontendSourceRoot()
+ const before = await sourceHash(source)
+ const deny = (): never => { throw new Error('DevTools build attempted network access') }
+ const guards = [
+ vi.spyOn(globalThis, 'fetch').mockImplementation(deny),
+ vi.spyOn(http, 'request').mockImplementation(deny),
+ vi.spyOn(http, 'get').mockImplementation(deny),
+ vi.spyOn(https, 'request').mockImplementation(deny),
+ vi.spyOn(https, 'get').mockImplementation(deny),
+ ]
+ onTestFinished(() => { for (const guard of guards) guard.mockRestore() })
+ await buildFrontend(root)
+ for (const guard of guards) expect(guard).not.toHaveBeenCalled()
+ expect(await sourceHash(source)).toBe(before)
+ for (const path of [
+ 'devtools_app.html', 'connect.js', 'entrypoints/devtools_app/devtools_app.js',
+ 'entrypoints/heap_snapshot_worker/heap_snapshot_worker-entrypoint.js',
+ 'entrypoints/formatter_worker/formatter_worker-entrypoint.js',
+ 'entrypoints/wasmparser_worker/wasmparser_worker-entrypoint.js',
+ 'entrypoints/lighthouse_worker/lighthouse_worker.js',
+ 'panels/recorder/injected/injected.generated.js',
+ 'models/live-metrics/web-vitals-injected/web-vitals-injected.generated.js',
+ 'Images/chromeLeft.avif', 'Images/lighthouse_logo.svg',
+ 'LICENSE', 'third_party/codemirror/package/LICENSE', 'third_party/wasmparser/package/LICENSE',
+ ]) expect((await readFile(join(root, path))).length, path).toBeGreaterThan(0)
+ const html = await readFile(join(root, 'devtools_app.html'), 'utf8')
+ expect(html).not.toContain('chrome-devtools-frontend.appspot.com')
+ expect(html).not.toContain('importmap')
+ expect(html.indexOf('./connect.js')).toBeLessThan(html.indexOf('./entrypoints/devtools_app/devtools_app.js'))
+ await expect(readdir(join(root, 'core/i18n/locales'))).rejects.toMatchObject({ code: 'ENOENT' })
+})
diff --git a/packages/experimental/inspector/tests/devtools-locales.host.spec.ts b/packages/experimental/inspector/tests/devtools-locales.host.spec.ts
deleted file mode 100644
index 897dc3a58d..0000000000
--- a/packages/experimental/inspector/tests/devtools-locales.host.spec.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/** Locale pruning changes the upstream menu source and dependency graph together. */
-import { expect, it } from 'vitest'
-import { configureDevtoolsLocales } from '../scripts/build-devtools.ts'
-import { findDevtoolsDependencies } from '../scripts/download-devtools.ts'
-
-it('keeps Chinese and English in both the I18n instance and its resource dependencies', () => {
- const source = 'const locales=["fr","en-US","zh","de"]; const bundled=["en-US","zh"]; new Lib.I18n.I18n(locales,"en-US");'
- const result = configureDevtoolsLocales(source)
- expect(result).toBe('const locales=["en-US","zh"]; const bundled=["en-US","zh"]; new Lib.I18n.I18n(locales,"en-US");')
- const root = new URL('https://cdn.example/serve_rev/@revision/')
- expect(findDevtoolsDependencies(result, new URL('core/i18n/i18n.js', root), root).urls.map(url => url.href)).toEqual([
- new URL('core/i18n/locales/en-US.json', root).href,
- new URL('core/i18n/locales/zh.json', root).href,
- ])
-})
-
-it('rejects an upstream locale declaration that no longer matches the distribution', () => {
- expect(() => configureDevtoolsLocales('const locales=["en-US"]; new Lib.I18n.I18n(locales,"en-US");'))
- .toThrow('every distributed locale')
- expect(() => configureDevtoolsLocales('new Lib.I18n.I18n(loadLocales(),"en-US");'))
- .toThrow('one supported-locale array')
-})
diff --git a/packages/experimental/inspector/tests/devtools-source.host.spec.ts b/packages/experimental/inspector/tests/devtools-source.host.spec.ts
new file mode 100644
index 0000000000..3dfe4cd6c6
--- /dev/null
+++ b/packages/experimental/inspector/tests/devtools-source.host.spec.ts
@@ -0,0 +1,293 @@
+/** Source adapters evaluated in per-test VMs; npm fixtures are read-only and no browser globals are shared. */
+import { readFileSync } from 'node:fs'
+import { runInNewContext } from 'node:vm'
+import ts from 'typescript'
+import { describe, expect, it } from 'vitest'
+import { imagesModule } from '../scripts/devtools/inputs.ts'
+import {
+ configureCodeMirrorBrowser,
+ configureHostPerformanceSource,
+ cssTextModule,
+ restoreModuleUrls,
+} from '../scripts/devtools/source.ts'
+
+const timelinePath = 'panels/timeline/TimelinePanel.ts'
+const frontend = new URL('../node_modules/chrome-devtools-frontend/front_end/', import.meta.url)
+const nodeMode = '#isNode = Root.Runtime.Runtime.isNode();'
+const nodeTarget = 'SDK.TargetManager.TargetManager.instance().targets().find(target => target.type() === SDK.Target.Type.NODE)'
+const primaryTarget = 'SDK.TargetManager.TargetManager.instance().primaryPageTarget()'
+
+function npmSource(path: string): string {
+ return readFileSync(new URL(path, frontend), 'utf8')
+}
+
+function evaluateModule(source: string): Record {
+ const exports: Record = {}
+ // CommonJS emission evaluates these import-free generated modules without Node's experimental VM module flag.
+ const emitted = ts.transpileModule(source, {
+ fileName: 'generated.ts', reportDiagnostics: true,
+ compilerOptions: { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.CommonJS },
+ })
+ expect(emitted.diagnostics?.filter(item => item.category === ts.DiagnosticCategory.Error)).toEqual([])
+ runInNewContext(emitted.outputText, { exports })
+ return exports
+}
+
+describe('restoreModuleUrls', () => {
+ it.each([
+ 'const answer = 42;\r\n',
+ '// import.meta.url and import.meta.resolve("./icon.svg")\r\n',
+ '/* import.meta.url\nimport.meta.resolve() */\n',
+ 'const message = "import.meta.url";\n',
+ 'const message = `import.meta.resolve("./icon.svg")`;\n',
+ 'const pattern = /import.meta.url/;\n',
+ 'const metadata = import.meta.env; const url = other.meta.url;\n',
+ ])('preserves non-URL syntax byte-for-byte: %s', (source) => {
+ expect(restoreModuleUrls(timelinePath, source)).toBe(source)
+ })
+
+ it.each([
+ ['https://inspector.test/devtools_app.html', 'https://inspector.test/'],
+ ['https://inspector.test/team/alice/inspector/devtools/devtools_app.html?ws=host#console',
+ 'https://inspector.test/team/alice/inspector/devtools/'],
+ ['https://inspector.test/nested/devtools/', 'https://inspector.test/nested/devtools/'],
+ ])('resolves window resources from document.baseURI %s', (baseURI, root) => {
+ const source = `({
+ module: import.meta.url,
+ sibling: import.meta.resolve('./TimelineLoader.js?cache=1#loader'),
+ image: new URL('../../Images/file name.svg', import.meta.url).href,
+ absolute: import.meta.resolve('https://assets.test/icon.svg'),
+ })`
+ const result: unknown = runInNewContext(restoreModuleUrls(timelinePath, source), {
+ URL, document: { baseURI },
+ })
+ expect(result).toEqual({
+ module: `${root}panels/timeline/TimelinePanel.js`,
+ sibling: `${root}panels/timeline/TimelineLoader.js?cache=1#loader`,
+ image: `${root}Images/file%20name.svg`,
+ absolute: 'https://assets.test/icon.svg',
+ })
+ })
+
+ it.each([
+ ['https://inspector.test/entrypoints/heap_snapshot_worker/heap_snapshot_worker.js', 'https://inspector.test/'],
+ ['https://inspector.test/team/alice/devtools/entrypoints/heap_snapshot_worker/heap_snapshot_worker.js?worker=1#ready',
+ 'https://inspector.test/team/alice/devtools/'],
+ ])('uses the frontend root two levels above the worker %s', (href, root) => {
+ const source = '({module: import.meta.url, dependency: import.meta.resolve(\'./HeapSnapshot.js\')})'
+ const result: unknown = runInNewContext(restoreModuleUrls('models/heap_snapshot/HeapSnapshotWorker.ts', source), {
+ URL, self: { location: { href } },
+ })
+ expect(result).toEqual({
+ module: `${root}models/heap_snapshot/HeapSnapshotWorker.js`,
+ dependency: `${root}models/heap_snapshot/HeapSnapshot.js`,
+ })
+ })
+
+ it('preserves surrounding strings and comments while rewriting multiple expressions', () => {
+ const prefix = '// import.meta.resolve() stays a comment\r\nconst text = "import.meta.url";\r\n'
+ const suffix = '\r\n// import.meta.url stays a comment\r\n'
+ const source = prefix + '[text, import.meta.url, import.meta.resolve("./next.js")];' + suffix
+ const result = restoreModuleUrls('core/common/example.js', source)
+ expect(result.startsWith(prefix)).toBe(true)
+ expect(result.endsWith(suffix)).toBe(true)
+ expect(runInNewContext(result, { URL, document: { baseURI: 'https://inspector.test/devtools/app.html' } })).toEqual([
+ 'import.meta.url', 'https://inspector.test/devtools/core/common/example.js',
+ 'https://inspector.test/devtools/core/common/next.js',
+ ])
+ })
+
+ it('evaluates a dynamic resolve argument once and accepts a trailing comma', () => {
+ const requests: string[] = []
+ const source = 'import.meta.resolve(resource(),)'
+ const result: unknown = runInNewContext(restoreModuleUrls(timelinePath, source), {
+ URL, document: { baseURI: 'https://inspector.test/sub/app.html' },
+ resource: () => { requests.push('resource'); return './snapshot.json' },
+ })
+ expect(result).toBe('https://inspector.test/sub/panels/timeline/snapshot.json')
+ expect(requests).toEqual(['resource'])
+ })
+
+ it.each(['', '"./asset.svg", "https://other.test/"', '"a", "b", "c"'])(
+ 'rejects import.meta.resolve with unsupported arity: (%s)', (argumentsText) => {
+ expect(() => restoreModuleUrls(timelinePath, `import.meta.resolve(${argumentsText})`)).toThrow(
+ `DevTools ${timelinePath}: import.meta.resolve must have one argument`,
+ )
+ },
+ )
+})
+
+describe('generated CSS text modules', () => {
+ it.each([
+ ['empty', ''],
+ ['quotes and slashes', '.title::after { content: "double"; other: \'single\'; path: "C:\\icons\\file"; }'],
+ ['backticks and interpolation', '/* `template` ${mustNotRun()} */\n.title { content: "${value}"; }'],
+ ['line endings', '\uFEFF:root {\r\n --label: "中文 🚀";\r\n}\n'],
+ ['control characters', '/* \u0000\t\u2028\u2029 */'],
+ ['script-like text', '\"; throw new Error("must stay text"); /* */'],
+ ])('preserves CSS text through module evaluation: %s', (_label, source) => {
+ expect(evaluateModule(cssTextModule(source))).toEqual({ default: source })
+ })
+})
+
+describe('configureHostPerformanceSource', () => {
+ it('changes only the recording-mode field and target expression in the installed npm TimelinePanel', () => {
+ const source = npmSource(timelinePath)
+ expect(source.split(nodeMode)).toHaveLength(2)
+ expect(source.split(nodeTarget)).toHaveLength(2)
+ expect(configureHostPerformanceSource(timelinePath, source)).toBe(
+ source.replace(nodeMode, '#isNode = true;').replace(nodeTarget, primaryTarget),
+ )
+ expect(npmSource(timelinePath)).toBe(source)
+ })
+
+ it('selects the primary target for CPU recording independently of Node target discovery', () => {
+ const source = `class Panel {
+ ${nodeMode}
+ record() { return { isNode: this.#isNode, target: ${nodeTarget} }; }
+ }
+ new Panel().record();`
+ const target = { name: 'Host CPU profiler target' }
+ const result: unknown = runInNewContext(configureHostPerformanceSource(timelinePath, source), {
+ SDK: { TargetManager: { TargetManager: { instance: () => ({ primaryPageTarget: () => target }) } } },
+ })
+ expect(result).toEqual({ isNode: true, target })
+ })
+
+ it.each(['panels/timeline/TimelineController.ts', 'panels/elements/ElementsPanel.ts', 'core/root/Runtime.ts'])(
+ 'preserves unrelated npm source %s byte-for-byte', (path) => {
+ const source = npmSource(path)
+ expect(configureHostPerformanceSource(path, source)).toBe(source)
+ },
+ )
+
+ it('does not match other paths even when they contain the target expressions', () => {
+ const source = `\uFEFF${nodeMode}\r\n${nodeTarget}\r\n`
+ for (const path of ['panels/timeline/TimelinePanel.js', `other/${timelinePath}`, `${timelinePath}?raw`]) {
+ expect(configureHostPerformanceSource(path, source)).toBe(source)
+ }
+ })
+
+ it.each([
+ ['missing Node mode', nodeMode, '#isNode = false;'],
+ ['duplicate Node mode', nodeMode, `${nodeMode}\n${nodeMode}`],
+ ['changed target', nodeTarget, 'SDK.TargetManager.TargetManager.instance().rootTarget()'],
+ ['duplicate target', nodeTarget, `(${nodeTarget}, ${nodeTarget})`],
+ ])('rejects %s in the npm source', (_label, expression, replacement) => {
+ const source = npmSource(timelinePath)
+ const changed = source.replace(expression, replacement)
+ expect(changed).not.toBe(source)
+ expect(() => configureHostPerformanceSource(timelinePath, changed)).toThrow(
+ `DevTools ${timelinePath}: expected source expression changed: ${expression}`,
+ )
+ })
+})
+
+describe('configureCodeMirrorBrowser', () => {
+ const prefix = 'third_party/codemirror/package/'
+ const guard = 'typeof exports == "object" && typeof module == "object"'
+ const wrapper = `(function(mod) {
+ if (${guard}) mod(require('../../lib/codemirror'));
+ else mod(CodeMirror);
+ })(function(cm) { cm.loaded = true; });`
+
+ it('uses the browser CodeMirror with CJS globals present without calling require', () => {
+ const requests: string[] = []
+ const CodeMirror = { loaded: false }
+ const sandbox = {
+ exports: {}, module: { exports: {} }, CodeMirror,
+ require: (specifier: string) => { requests.push(specifier); throw new Error('CJS branch selected') },
+ }
+ expect(() => { runInNewContext(wrapper, sandbox) }).toThrow('CJS branch selected')
+ expect(requests).toEqual(['../../lib/codemirror'])
+ expect(CodeMirror.loaded).toBe(false)
+
+ const result = configureCodeMirrorBrowser(`${prefix}mode/javascript/javascript.mjs`, wrapper)
+ runInNewContext(result, sandbox)
+ expect(CodeMirror.loaded).toBe(true)
+ expect(requests).toEqual(['../../lib/codemirror'])
+ const withoutRequire = { exports: {}, module: { exports: {} }, CodeMirror: { loaded: false } }
+ runInNewContext(result, withoutRequire)
+ expect(withoutRequire.CodeMirror.loaded).toBe(true)
+ })
+
+ it('preserves other paths, extensions and nonmatching guards byte-for-byte', () => {
+ const source = `\uFEFF${wrapper}\r\n`
+ for (const path of [
+ 'third_party/other/package/mode.mjs', 'third_party/codemirror/package-other/mode.mjs',
+ 'panels/codemirror/mode.mjs', `${prefix}mode.js`, `${prefix}mode.ts`, `${prefix}mode.cjs`, `${prefix}mode.mjs?raw`,
+ ]) {
+ expect(configureCodeMirrorBrowser(path, source)).toBe(source)
+ }
+ const differentGuard = source.replace(guard, 'typeof exports === "object" && typeof module === "object"')
+ expect(configureCodeMirrorBrowser(`${prefix}mode.mjs`, differentGuard)).toBe(differentGuard)
+ })
+
+ it('recognizes the npm runmode and mode guards and tokenizes in the browser branch', () => {
+ const sources = [
+ 'addon/runmode/runmode-standalone.mjs', 'mode/javascript/javascript.mjs', 'mode/css/css.mjs', 'mode/xml/xml.mjs',
+ ].map((relative) => {
+ const path = `${prefix}${relative}`
+ const source = npmSource(path)
+ expect(source).toContain(guard)
+ const result = configureCodeMirrorBrowser(path, source)
+ expect(result).toBe(source.replaceAll(guard, 'false'))
+ expect(result).not.toContain(guard)
+ return result
+ })
+ const samples = [
+ { mode: 'javascript', text: 'var answer = 42;', style: 'keyword' },
+ { mode: 'css', text: 'body { color: red; }', style: 'property' },
+ { mode: 'xml', text: '', style: 'tag' },
+ ]
+ const tokens: Array<{ mode: string; text: string; style: string | null | undefined }> = []
+ const requests: string[] = []
+ runInNewContext(sources.join('\n;\n') + `
+ for (const sample of ${JSON.stringify(samples)}) {
+ CodeMirror.runMode(sample.text, sample.mode, (text, style) => capture(sample.mode, text, style));
+ }`, {
+ exports: {}, module: { exports: {} },
+ require: (specifier: string) => { requests.push(specifier); throw new Error('Unexpected CodeMirror require') },
+ capture: (mode: string, text: string, style: string | null | undefined) => { tokens.push({ mode, text, style }) },
+ })
+ expect(requests).toEqual([])
+ for (const sample of samples) {
+ const result = tokens.filter(token => token.mode === sample.mode)
+ expect(result.map(token => token.text).join('')).toBe(sample.text)
+ expect(result.map(token => token.style)).toContain(sample.style)
+ }
+ })
+})
+
+describe('imagesModule', () => {
+ it.each([
+ { images: [] },
+ { images: [{ name: 'nodeIcon.avif' }, { name: 'folder-open.svg' }, { name: 'icon.with.dots.svg' }, { name: 'file name.svg' }] },
+ ])('registers flattened images relative to the page and retains adopted sheets: $images', ({ images }) => {
+ const registrations: Array<[string, string]> = []
+ const rules: string[] = []
+ class Sheet {
+ readonly cssRules = [{ style: { setProperty: (name: string, value: string) => { registrations.push([name, value]) } } }]
+ replaceSync(value: string): void { rules.push(value) }
+ }
+ const previous = { name: 'existing stylesheet' }
+ const adopted: object[] = [previous]
+ const document = {
+ baseURI: 'https://inspector.test/team/alice/devtools/devtools_app.html?ws=host#console',
+ adoptedStyleSheets: adopted,
+ }
+ runInNewContext(imagesModule(images), { URL, CSSStyleSheet: Sheet, document })
+ expect(rules).toEqual([':root {}'])
+ expect(registrations).toEqual(images.length === 0 ? [] : [
+ ['--image-file-nodeIcon', 'url("https://inspector.test/team/alice/devtools/Images/nodeIcon.avif")'],
+ ['--image-file-folder-open', 'url("https://inspector.test/team/alice/devtools/Images/folder-open.svg")'],
+ ['--image-file-icon.with.dots', 'url("https://inspector.test/team/alice/devtools/Images/icon.with.dots.svg")'],
+ ['--image-file-file name', 'url("https://inspector.test/team/alice/devtools/Images/file%20name.svg")'],
+ ])
+ expect(document.adoptedStyleSheets).toHaveLength(2)
+ expect(document.adoptedStyleSheets[0]).toBe(previous)
+ expect(document.adoptedStyleSheets[1]).toBeInstanceOf(Sheet)
+ expect(document.adoptedStyleSheets).not.toBe(adopted)
+ expect(adopted).toEqual([previous])
+ })
+})
diff --git a/packages/experimental/inspector/tests/prune-lighthouse.host.spec.ts b/packages/experimental/inspector/tests/prune-lighthouse.host.spec.ts
deleted file mode 100644
index 1bc38d51b3..0000000000
--- a/packages/experimental/inspector/tests/prune-lighthouse.host.spec.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-/** Pure Lighthouse pruning fixtures retain adjacent panels, AI capabilities, and image registrations. */
-import ts from 'typescript'
-import { describe, expect, it } from 'vitest'
-import { pruneLighthouse } from '../scripts/prune-lighthouse.ts'
-
-const entryPath = 'entrypoints/devtools_app/devtools_app.js'
-const panelPath = 'panels/ai_assistance/ai_assistance.js'
-const modelPath = 'models/ai_assistance/ai_assistance.js'
-const entry = `
-import * as Lighthouse from "../../panels/lighthouse/lighthouse.js";
-import * as Views from "../../ui/legacy/legacy.js";
-const strings = { title: "Lighthouse" }, messages = Locale.registerUIStrings("panels/lighthouse/lighthouse-meta.ts", strings), cached = undefined;
-async function loadLighthouse() { return cached || await import("../../panels/lighthouse/lighthouse.js"); }
-Views.registerViewExtension({ id: "elements", title: "Elements" });
-Views.registerViewExtension({ id: "lighthouse", async loadView() { return (await loadLighthouse()).LighthousePanel; } });
-Core.registerRevealer({ contextTypes() { return [Lighthouse.LighthousePanel.ActiveLighthouseReport]; }, async loadRevealer() { return (await loadLighthouse()).ReportRevealer; } });
-Views.registerViewExtension({ id: "timeline", title: "Performance" });
-`
-
-const panel = `
-import * as Reports from "../lighthouse/lighthouse.js";
-import * as Rendering from "../lighthouse/lighthouse.js";
-import * as AI from "../../models/ai_assistance/ai_assistance.js";
-function reportContext(report) { return report ? new AI.AccessibilityAgent.AccessibilityContext(report.report) : null; }
-async function renderReport(widget) { return Rendering.LighthouseReportRenderer.renderLighthouseScores(widget.report); }
-async function renderWidget(widget) { let rendered = null; switch (widget.name) {
-case "LIGHTHOUSE_REPORT": rendered = await renderReport(widget); break;
-case "OTHER": rendered = "other-widget"; break;
-} return rendered; }
-function icon(context) { return context instanceof AI.AccessibilityAgent.AccessibilityContext ? "Lighthouse" : "other-icon"; }
-class Panel {
- #report = null;
- #onReport = event => { this.#report = reportContext(event.data); };
- async #record(params) { return await Reports.LighthousePanel.executeLighthouseRecording(params); }
- wasShown() { before(), this.#report = reportContext(Context.flavor(Reports.LighthousePanel.ActiveLighthouseReport)), Context.addFlavorChangeListener(Reports.LighthousePanel.ActiveLighthouseReport, this.#onReport), after(); }
- willHide() { before(), Context.removeFlavorChangeListener(Reports.LighthousePanel.ActiveLighthouseReport, this.#onReport), after(); }
- first() { return new AI.AiConversation.AiConversation({ lighthouseRecording: this.#record.bind(this), keep: "first" }); }
- second() { return new AI.AiConversation.AiConversation({ keep: "second", lighthouseRecording: this.#record.bind(this), other: true }); }
- third() { return new AI.AiConversation.AiConversation({ keep: "third", lighthouseRecording: this.#record.bind(this) }); }
- context(type) { switch (type) { case "accessibility": return this.#report; case "styling": return "styling-context"; } }
- setContext = value => value instanceof AI.AccessibilityAgent.AccessibilityContext ? this.#report = value : useOtherContext(value);
- view() { let elements = Views.isViewVisible("elements"), lighthouse = Views.isViewVisible("lighthouse"), storage = Views.isViewVisible("resources");
- return elements ? "styling" : lighthouse && config.devToolsAiAssistanceAccessibilityAgent?.enabled ? "accessibility" : storage ? "storage" : "none";
- }
-}
-class Unrelated { #report = "unrelated"; read() { return this.#report; } }
-`
-
-const model = `
-class SelectionAgent { constructor() {
- this.declareFunction("listNetworkRequests", {}), this.declareFunction("runLighthouseAudits", {}), this.declareFunction("inspectDom", {});
-} }
-class AccessibilityAgent { configure() {
- this.declareFunction("executeJavaScript", {}), this.declareFunction("runAccessibilityAudits", {}), this.declareFunction("getLighthouseAudits", {}), this.declareFunction("getStyles", {});
-} }
-`
-
-function imports(source: string): string[] {
- const file = ts.createSourceFile('fixture.js', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
- const result: string[] = []
- const visit = (node: ts.Node): void => {
- if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) result.push(node.moduleSpecifier.text)
- if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword
- && node.arguments[0] !== undefined && ts.isStringLiteralLike(node.arguments[0])) result.push(node.arguments[0].text)
- ts.forEachChild(node, visit)
- }
- visit(file)
- return result
-}
-
-describe('pruneLighthouse', () => {
- it('removes the panel, revealer, lazy loader and private metadata while retaining adjacent panels', () => {
- const result = pruneLighthouse(entryPath, entry)
- expect(imports(result)).toEqual(['../../ui/legacy/legacy.js'])
- expect(result).toContain('Views.registerViewExtension({ id: "elements", title: "Elements" });')
- expect(result).toContain('Views.registerViewExtension({ id: "timeline", title: "Performance" });')
- expect(result).not.toMatch(/Lighthouse|loadLighthouse|registerRevealer|registerUIStrings|cached/)
- })
-
- it('removes only report context, recording and rendering from the AI panel', () => {
- const result = pruneLighthouse(panelPath, panel)
- expect(imports(result)).toEqual(['../../models/ai_assistance/ai_assistance.js'])
- expect(result).not.toMatch(/Reports|Rendering|renderReport|reportContext|lighthouseRecording|#record|#onReport/)
- expect(result).toContain('wasShown() { before(), after(); }')
- expect(result).toContain('willHide() { before(), after(); }')
- expect(result).toContain('case"LIGHTHOUSE_REPORT":break;')
- expect(result).toContain('case "OTHER": rendered = "other-widget"; break;')
- expect(result).toContain('case"accessibility":return null;')
- expect(result).toContain('case "styling": return "styling-context";')
- expect(result).toContain('setContext = value => useOtherContext(value);')
- expect(result).toContain('return "other-icon";')
- expect(result).toContain('{ keep: "first" }')
- expect(result).toContain('{ keep: "second", other: true }')
- expect(result).toContain('{ keep: "third" }')
- expect(result).toContain('storage ? "storage" : "none"')
- expect(result).toContain('class Unrelated { #report = "unrelated"; read() { return this.#report; } }')
- })
-
- it('removes Lighthouse model tool declarations without dropping other agent tools', () => {
- const result = pruneLighthouse(modelPath, model)
- expect(result).not.toMatch(/runLighthouseAudits|runAccessibilityAudits|getLighthouseAudits/)
- for (const tool of ['listNetworkRequests', 'inspectDom', 'executeJavaScript', 'getStyles']) {
- expect(result).toContain(`this.declareFunction("${tool}", {})`)
- }
- expect(result).toContain('class SelectionAgent')
- expect(result).toContain('class AccessibilityAgent')
- })
-
- it.each(['./lighthouse_logo.svg', 'lighthouse_logo.svg'])('replaces only the %s image call and preserves the comma chain', (url) => {
- const source = `style.setProperty("--before", "before.svg"),style.setProperty("--image-file-lighthouse_logo", new URL(new URL("${url}", import.meta.url).href)),style.setProperty("--after", "after.svg");`
- expect(pruneLighthouse('Images/Images.js', source))
- .toBe('style.setProperty("--before", "before.svg"),void 0,style.setProperty("--after", "after.svg");')
- })
-
- it('keeps unowned modules and i18n strings byte-identical', () => {
- const source = '{"showLighthouse":"Show Lighthouse","language":"all locales retained"}\n'
- expect(pruneLighthouse('core/i18n/locales/en-US.json', source)).toBe(source)
- expect(pruneLighthouse('panels/elements/elements.js', 'const Lighthouse = "unrelated";')).toBe('const Lighthouse = "unrelated";')
- expect(pruneLighthouse(entryPath.replaceAll('/', '\\'), entry)).toBe(pruneLighthouse(entryPath, entry))
- })
-
- it('rejects missing or duplicate pinned contributions', () => {
- expect(() => pruneLighthouse(entryPath, entry.replace('id: "lighthouse"', 'id: "renamed"'))).toThrow('expected 1 panel registrations, found 0')
- expect(() => pruneLighthouse(modelPath, model.replace('"runLighthouseAudits"', '"renamed"'))).toThrow('expected 1 runLighthouseAudits, found 0')
- const logo = 'style.setProperty("--image-file-lighthouse_logo", new URL("./lighthouse_logo.svg", import.meta.url));'
- expect(() => pruneLighthouse('Images/Images.js', logo + logo)).toThrow('expected 1 Lighthouse logo registrations, found 2')
- expect(() => pruneLighthouse(panelPath, panel.replace('async #record', 'async record'))).toThrow('private Lighthouse recording method')
- })
-
- it('rejects extra references instead of removing their unrelated consumers', () => {
- expect(() => pruneLighthouse(entryPath, entry + '\nuseElsewhere(Lighthouse);')).toThrow('removed binding Lighthouse remains referenced')
- expect(() => pruneLighthouse(entryPath, entry + '\nuseElsewhere(loadLighthouse);')).toThrow('removed binding loadLighthouse remains referenced')
- expect(() => pruneLighthouse(panelPath, panel.replace('first() {', 'extra() { return this.#report; } first() {')))
- .toThrow('removed private member #report remains referenced')
- expect(() => pruneLighthouse(panelPath, panel + '\nimport("../lighthouse/lighthouse.js");'))
- .toThrow('Lighthouse import remains after pruning')
- })
-
- it('rejects unexpected import forms, syntax, and effect-bearing tool expressions', () => {
- expect(() => pruneLighthouse(entryPath, entry.replace('import * as Lighthouse', 'import Lighthouse'))).toThrow('Lighthouse import must use a namespace')
- expect(() => pruneLighthouse(entryPath, 'function {')).toThrow('pruneLighthouse:')
- expect(() => pruneLighthouse(modelPath, model.replace('this.declareFunction("runLighthouseAudits", {})', 'wrap(this.declareFunction("runLighthouseAudits", {}))')))
- .toThrow('runLighthouseAudits is not an independent expression')
- })
-})
diff --git a/packages/experimental/inspector/tests/workspace-prefetch.host.spec.ts b/packages/experimental/inspector/tests/workspace-prefetch.host.spec.ts
index 2f925f7406..04021ff4bf 100644
--- a/packages/experimental/inspector/tests/workspace-prefetch.host.spec.ts
+++ b/packages/experimental/inspector/tests/workspace-prefetch.host.spec.ts
@@ -1,20 +1,29 @@
-/** DevTools prefetch belongs to contributor setup, not published package installation. */
+/** DevTools sources are fixed npm inputs; package installation starts no external resource download. */
import { readFileSync } from 'node:fs'
import { expect, it } from 'vitest'
interface Manifest {
scripts?: Record
files?: string[]
+ devDependencies?: Record
}
function manifest(path: string): Manifest {
return JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8')) as Manifest
}
-it('prefetches DevTools from the root workspace after installing its existing Git hooks', () => {
+it('installs workspace Git hooks without prefetching DevTools', () => {
const root = manifest('../../../../package.json')
- expect(root.scripts?.postinstall).toBe('node scripts/install-lefthook.mjs && pnpm run prefetch:devtools')
- expect(root.scripts?.['prefetch:devtools']).toBe('tsx packages/experimental/inspector/scripts/download-devtools.ts')
+ expect(root.scripts?.postinstall).toBe('node scripts/install-lefthook.mjs')
+ expect(root.scripts?.['prefetch:devtools']).toBeUndefined()
+})
+
+it('pins the npm source and uses workspace Vite without a webpack dependency', () => {
+ const dependencies = manifest('../package.json').devDependencies
+ expect(dependencies?.['chrome-devtools-frontend']).toBe('1.0.1638082')
+ expect(dependencies?.vite).toBeUndefined()
+ expect(manifest('../../../../package.json').devDependencies?.vite).toBe('8.0.16')
+ expect(Object.keys(dependencies ?? {}).some(name => name.includes('webpack'))).toBe(false)
})
it.each(['../../../../apps/cli/package.json', '../../inspector-profile/package.json', '../package.json'])(
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 90fe37668f..418e9ea566 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -175,6 +175,9 @@ importers:
typescript:
specifier: ^6.0.3
version: 6.0.3
+ vite:
+ specifier: 8.0.16
+ version: 8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)
vite-tsconfig-paths:
specifier: ^6.1.1
version: 6.1.1(supports-color@9.4.0)(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
@@ -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'}
+ chrome-devtools-frontend@1.0.1638082:
+ resolution: {integrity: sha512-KfbM5kZrgseWciU9Y6ic6hDtYWKY7SY6L8jkeq23KF5CgZ1cU7P+janhysDLN+XZr6U4Ro5V+2W7YOHjKW4PAw==}
+
chrome-devtools-mcp@1.9.0:
resolution: {integrity: sha512-RnzXoJiUQ44hpOihWk90uOhLD/CnwDkDy0ldHMZONJ2nYQ+dWN1fq1luHHqyd+7FuYnyIlCY6uTThbN5ut9kSQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
@@ -25711,6 +25720,8 @@ snapshots:
chownr@3.0.0: {}
+ chrome-devtools-frontend@1.0.1638082: {}
+
chrome-devtools-mcp@1.9.0: {}
chromium-pickle-js@0.2.0: {}
diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts
index 8527a0d588..c8f81c6864 100644
--- a/scripts/gen-third-party-notices.spec.ts
+++ b/scripts/gen-third-party-notices.spec.ts
@@ -36,6 +36,8 @@ describe('THIRD_PARTY_NOTICES.md', () => {
expect(generated).toContain(`| [\`numpy\`](https://github.com/numpy/numpy) | ${primaryRuntimeLock.pythonPackages.numpy} | BSD-3-Clause |`)
expect(generated).toContain('## LibreOffice conversion kit')
expect(generated).toContain('Recipients must have access to those corresponding sources and notices.')
+ expect(generated.split('## Development-only npm dependencies')[0]).toContain('| [`chrome-devtools-frontend`]')
+ expect(generated).toContain('third-party license and notice files supplied by the npm source')
expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated)
})
})
diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts
index df055c444f..0cfbf14519 100644
--- a/scripts/gen-third-party-notices.ts
+++ b/scripts/gen-third-party-notices.ts
@@ -15,7 +15,7 @@ import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutB
import parseSpdx from 'spdx-expression-parse'
import primaryRuntimeLock from './primary-runtime/lock.json' with { type: 'json' }
import { browserBundledExternals } from './browser-bundled-externals.ts'
-import { DEVTOOLS_CDN, DEVTOOLS_CHROMIUM_VERSION } from '../packages/experimental/inspector/scripts/download-devtools.ts'
+import { DEVTOOLS_NPM_VERSION, DEVTOOLS_SOURCE_REVISION } from '../packages/experimental/inspector/scripts/devtools/source.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'THIRD_PARTY_NOTICES.md'
@@ -744,6 +744,8 @@ ${rows.join('\n')}
*/
export async function render(): Promise {
const browser = await browserBundledExternals(root)
+ // The Inspector's Host build hook compiles this standalone frontend outside the Client bundle graph.
+ browser.add('chrome-devtools-frontend')
// The linked-manifest cache is keyed by name only, so it must not outlive
// the manifests map it was resolved from; render() owns that single load.
workspaceLinkedManifestCache.clear()
@@ -797,7 +799,7 @@ ${patchedLines.join('\n')}
## Chrome DevTools frontend
-The optional experimental Inspector distributes a local copy of [Chrome ${DEVTOOLS_CHROMIUM_VERSION}'s frontend](${DEVTOOLS_CDN.href}devtools_app.html). The Chromium [BSD-3-Clause license](packages/experimental/inspector/assets/devtools/LICENSE) is included with the frontend. Copied JavaScript retains its embedded license comments; the resource downloader does not collect separate upstream third-party license files. The Chromium root license does not replace those dependencies' licenses.
+The optional experimental Inspector distributes a locally compiled copy of [chrome-devtools-frontend ${DEVTOOLS_NPM_VERSION}](https://www.npmjs.com/package/chrome-devtools-frontend/v/${DEVTOOLS_NPM_VERSION}), from upstream revision [${DEVTOOLS_SOURCE_REVISION}](https://chromium.googlesource.com/devtools/devtools-frontend/+/${DEVTOOLS_SOURCE_REVISION}). The build includes the Chromium [BSD-3-Clause license](packages/experimental/inspector/assets/devtools/LICENSE) and the third-party license and notice files supplied by the npm source. The Chromium root license does not replace those dependencies' licenses.
${renderClaudeDistribution(claudeDistribution)}
${kitRuntime ? `
diff --git a/tsconfig.host.json b/tsconfig.host.json
index 31586f034a..59400c0e35 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -68,6 +68,7 @@
"apps/web/tests/layout-bottom.e2e.ts",
"apps/web/tests/devtools-frontend.e2e.ts",
"apps/web/tests/inspector-panels.e2e.ts",
+ "apps/web/tests/inspector-source-build.e2e.ts",
"apps/web/tests/plugin-install-cancel.e2e.ts",
"apps/web/tests/plugin-install-approve.e2e.ts",
"apps/web/tests/plugin-install-registry.e2e.ts",