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",