diff --git a/README.md b/README.md index cf4cab7..38e5ded 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Browser DevTools is excellent at inspecting one element, but finding why two sim - **Difference-first table**: changed properties are shown by default, with a `Show all` toggle for the full computed style list. - **Property search**: filter CSS properties by name while reviewing the comparison. - **One-click copy**: click a value cell to copy `property: value;`. +- **Native page hover highlight**: hover a source/target DOM line in the diff table to highlight that DOM node in the inspected page, and remove the highlight immediately when the pointer leaves. - **Cross-window/tab sync**: selected element data is broadcast to other open windows/tabs, which helps compare page states side by side. - **Localized UI**: includes English and Simplified Chinese browser i18n messages. @@ -31,6 +32,17 @@ Browser DevTools is excellent at inspecting one element, but finding why two sim Download the packaged zip from [Releases](https://github.com/jevin98/css-diff-devtools/releases), then install or load it manually from your browser's extensions page. +## Chrome Debugger Permission + +CSS-Diff requests Chrome's `debugger` permission so the diff table can use the Chrome DevTools Protocol `Overlay.highlightNode` and `Overlay.hideHighlight` commands. This is what makes DOM line hover highlighting appear and disappear immediately in the inspected page. + +Chrome treats this permission as sensitive: + +- Chrome may show a permission warning when the extension is installed or updated. +- Chrome may show a browser-level notice that CSS-Diff is debugging the current page while the extension is attached. +- CSS-Diff uses this permission only for temporary DOM hover highlighting from the DevTools sidebar. +- If the permission is denied, CSS comparison still works, but native hover highlighting in the inspected page is unavailable. + ## Usage 1. Open the page you want to inspect. @@ -38,8 +50,9 @@ Download the packaged zip from [Releases](https://github.com/jevin98/css-diff-de 3. Open the `CSS-Diff` sidebar. 4. Select the first DOM element, then select the second DOM element. 5. Review the highlighted differences, search for a property, or enable `Show all`. -6. Click a left/right value cell to copy the CSS declaration. -7. Click `Clear Selection` to start another comparison. +6. Hover a source/target DOM line in the diff table to highlight that DOM node in the inspected page. +7. Click a left/right value cell to copy the CSS declaration. +8. Click `Clear Selection` to start another comparison. ## Local Development diff --git a/README.zh-CN.md b/README.zh-CN.md index 8a0cf63..fb828db 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,6 +21,7 @@ - **差异优先展示**:默认只展示有差异的属性,也可以通过 `显示全部` 查看完整 computed style 列表。 - **属性搜索**:按 CSS 属性名过滤对比结果。 - **一键复制**:点击任意左右侧样式值单元格,即可复制 `property: value;`。 +- **原生页面悬停高亮**:在差异表格中悬停源/目标 DOM 行时,会在被检查页面中高亮对应 DOM 节点,鼠标移出后立即取消高亮。 - **跨窗口/标签页同步**:选中的元素数据会同步广播到其他已打开的窗口/标签页,便于并排比较不同页面状态。 - **本地化界面**:内置英文和简体中文浏览器 i18n 文案。 @@ -31,6 +32,17 @@ 从 [Releases](https://github.com/jevin98/css-diff-devtools/releases) 下载打包后的 zip 文件,然后在浏览器扩展程序页面中手动安装或加载。 +## Chrome Debugger 权限 + +CSS-Diff 会请求 Chrome 的 `debugger` 权限,用于通过 Chrome DevTools Protocol 的 `Overlay.highlightNode` 和 `Overlay.hideHighlight` 命令实现差异表格 DOM 行的页面高亮,并在鼠标移出时立即取消高亮。 + +Chrome 会把该权限视为敏感权限: + +- 安装或更新扩展时,Chrome 可能显示权限警告。 +- 扩展附加到当前页面时,Chrome 可能显示“正在调试此浏览器/页面”的提示。 +- CSS-Diff 仅将该权限用于 DevTools 侧边栏中的临时 DOM 悬停高亮。 +- 如果未授予该权限,CSS 对比功能仍可使用,但被检查页面中的原生悬停高亮不可用。 + ## 使用方法 1. 打开需要检查的页面。 @@ -38,8 +50,9 @@ 3. 打开 `CSS-Diff` 侧边栏。 4. 先选择第一个 DOM 元素,再选择第二个 DOM 元素。 5. 查看高亮的样式差异、搜索属性,或启用 `显示全部`。 -6. 点击左侧或右侧的样式值单元格,复制对应 CSS 声明。 -7. 点击 `清除选择` 开始新的比较。 +6. 在差异表格中悬停源/目标 DOM 行,在被检查页面中高亮对应 DOM 节点。 +7. 点击左侧或右侧的样式值单元格,复制对应 CSS 声明。 +8. 点击 `清除选择` 开始新的比较。 ## 本地开发 diff --git a/entrypoints/devtools-panel/devtools-panel.vue b/entrypoints/devtools-panel/devtools-panel.vue index 75b8ab7..4c306b5 100644 --- a/entrypoints/devtools-panel/devtools-panel.vue +++ b/entrypoints/devtools-panel/devtools-panel.vue @@ -36,6 +36,8 @@ const { handleClearSelection, handleRemoveSelectedElement, handleCopyStyle, + handleInspectSelectedElement, + handleRestoreInspectedElement, } = useDevToolsPanel() const themePreference = ref('system') @@ -149,6 +151,20 @@ function getStyleValueLabel(value: string) { return value === UNDEFINED_STYLE_VALUE ? t('undefinedStyleValue') : value } +function handleInspectPointerLeave(event: PointerEvent, element: SelectedElType) { + const relatedTarget = event.relatedTarget + + if (relatedTarget instanceof Element) { + const nextInspectTarget = relatedTarget.closest('[data-css-diff-inspect-id]') + + if (nextInspectTarget?.getAttribute('data-css-diff-inspect-id')) { + return + } + } + + handleRestoreInspectedElement(element) +} + onMounted(() => { initializeLocale(localStorage.getItem(LOCALE_STORAGE_KEY)) systemThemeQuery = window.matchMedia(THEME_MEDIA_QUERY) @@ -403,6 +419,10 @@ const PropertyNode = defineComponent({ v-for="slot in selectionSlots" :key="slot.key" class="min-w-[280px] border-l border-border px-3 py-2 font-medium" + :data-css-diff-inspect-id="slot.element?.inspectId" + :title="slot.element ? t('nativeHoverTooltip') : undefined" + @pointerenter="slot.element && handleInspectSelectedElement(slot.element)" + @pointerleave="slot.element && handleInspectPointerLeave($event, slot.element)" >
@@ -493,6 +513,10 @@ const PropertyNode = defineComponent({ :key="`${row.property}-${slot.key}`" class="group min-w-[280px] cursor-copy border-l border-border px-3 py-2 align-top transition-colors hover:bg-accent hover:text-accent-foreground" :class="row.isDiff ? 'bg-muted/30 font-medium text-foreground' : ''" + :data-css-diff-inspect-id="slot.element?.inspectId" + :title="slot.element ? t('nativeHoverTooltip') : undefined" + @pointerenter="slot.element && handleInspectSelectedElement(slot.element)" + @pointerleave="slot.element && handleInspectPointerLeave($event, slot.element)" @click="slot.element && handleCopyStyle(row, slot.element.valueType)" >
diff --git a/entrypoints/devtools-panel/hooks/useDevToolsPanel.ts b/entrypoints/devtools-panel/hooks/useDevToolsPanel.ts index 732c80a..7746da6 100644 --- a/entrypoints/devtools-panel/hooks/useDevToolsPanel.ts +++ b/entrypoints/devtools-panel/hooks/useDevToolsPanel.ts @@ -3,7 +3,7 @@ import { useClipboard } from '@vueuse/core' import { toast } from 'vue-sonner' import { t } from '../lang' import SM from '../message' -import { compareStyles, formatStyle, type FormatStyleValue, getVisibleCssDiffs, UNDEFINED_STYLE_VALUE } from '../utils' +import { type CaptureSelectedElementResult, compareStyles, createCaptureSelectedElementScript, devToolsOverlayHighlighter, getVisibleCssDiffs, UNDEFINED_STYLE_VALUE } from '../utils' export function useDevToolsPanel() { const inputValue = ref('') @@ -12,31 +12,26 @@ export function useDevToolsPanel() { const cssDiffs: Array = reactive([]) const isAllProperty = ref(false) - const isLoadComplete = ref(false) onMounted(() => { browser.devtools.panels.elements.onSelectionChanged.addListener(() => { - browser.devtools.inspectedWindow.eval( - `(() => document.readyState)($0)`, - (readyState: Document['readyState']) => { - if (readyState === 'complete') { - isLoadComplete.value = true - } - else { - isLoadComplete.value = false - } - }, - ) + const valueType = getAvailableValueType() + + if (!valueType) { + return + } browser.devtools.inspectedWindow.eval( - `(${formatStyle.toString()})($0)`, - (result: FormatStyleValue, isException) => { - if (!isException && result != null && isLoadComplete.value) { - const valueType = getAvailableValueType() - - if (valueType) { - saveSelectedEl({ ...result, valueType }) - } + createCaptureSelectedElementScript(valueType), + (payload: CaptureSelectedElementResult, isException) => { + if (!isException && payload?.readyState === 'complete' && payload.result != null) { + saveSelectedEl({ + ...payload.result, + inspectId: payload.inspectId, + inspectPath: payload.inspectPath, + inspectTabId: browser.devtools.inspectedWindow.tabId, + valueType, + }) } }, ) @@ -55,6 +50,7 @@ export function useDevToolsPanel() { if (selectedEl.length === 2) { compareSelectedEl() + prepareSelectedElementOverlay() } else { cssDiffs.length = 0 @@ -62,6 +58,10 @@ export function useDevToolsPanel() { }) }) + onUnmounted(() => { + devToolsOverlayHighlighter.detach() + }) + function saveSelectedEl(result: SelectedElType) { if (!getAvailableValueType()) { return @@ -74,12 +74,14 @@ export function useDevToolsPanel() { if (selectedEl.length === 2) { compareSelectedEl() + prepareSelectedElementOverlay() } } function handleClearSelection() { selectedEl.length = 0 cssDiffs.length = 0 + devToolsOverlayHighlighter.detach() // Send selected data to other windows/tabs SM.send([]) @@ -94,6 +96,7 @@ export function useDevToolsPanel() { selectedEl.splice(index, 1) cssDiffs.length = 0 + devToolsOverlayHighlighter.detach() // Send selected data to other windows/tabs SM.send(selectedEl) @@ -148,6 +151,39 @@ export function useDevToolsPanel() { toast.success(`${t('copyInfo')} > ${source}`) } + function prepareSelectedElementOverlay() { + const inspectedTabId = browser.devtools.inspectedWindow.tabId + const localElement = selectedEl.find(element => element.inspectTabId === inspectedTabId) + + if (!localElement) { + return + } + + devToolsOverlayHighlighter.prepare({ + inspectTabId: localElement.inspectTabId, + }) + } + + function handleInspectSelectedElement(element: SelectedElType) { + if (!element.inspectId) { + return + } + + devToolsOverlayHighlighter.highlight({ + inspectId: element.inspectId, + inspectPath: element.inspectPath, + inspectTabId: element.inspectTabId, + }) + } + + function handleRestoreInspectedElement(element: SelectedElType) { + if (!element.inspectId) { + return + } + + devToolsOverlayHighlighter.hide() + } + return { inputValue, @@ -158,5 +194,7 @@ export function useDevToolsPanel() { handleClearSelection, handleRemoveSelectedElement, handleCopyStyle, + handleInspectSelectedElement, + handleRestoreInspectedElement, } } diff --git a/entrypoints/devtools-panel/lang.ts b/entrypoints/devtools-panel/lang.ts index 006f49c..c21a3cb 100644 --- a/entrypoints/devtools-panel/lang.ts +++ b/entrypoints/devtools-panel/lang.ts @@ -33,6 +33,7 @@ export type MessageKey = | 'copyInfo' | 'clearFilter' | 'inputPlaceholder' + | 'nativeHoverTooltip' | 'switchToDarkTheme' | 'switchToLightTheme' | 'waitingSelection' diff --git a/entrypoints/devtools-panel/types.ts b/entrypoints/devtools-panel/types.ts index 804279d..f00771d 100644 --- a/entrypoints/devtools-panel/types.ts +++ b/entrypoints/devtools-panel/types.ts @@ -2,6 +2,9 @@ import type { FormatStyleValue } from './utils' export type SelectedElType = { valueType: 'left' | 'right' + inspectId?: string + inspectPath?: string + inspectTabId?: number } & FormatStyleValue export interface CssDiffsType { diff --git a/entrypoints/devtools-panel/utils/devtoolsOverlay.ts b/entrypoints/devtools-panel/utils/devtoolsOverlay.ts new file mode 100644 index 0000000..a0d58fe --- /dev/null +++ b/entrypoints/devtools-panel/utils/devtoolsOverlay.ts @@ -0,0 +1,292 @@ +import { createResolveSelectedElementExpression } from './inspectedElement' + +const DEBUGGER_PROTOCOL_VERSION = '1.3' +const DEBUGGER_OBJECT_GROUP = 'css-diff-devtools-hover' + +interface RuntimeEvaluateResponse { + result?: { + objectId?: string + } +} + +interface DOMDescribeNodeResponse { + node?: { + backendNodeId?: number + } +} + +export interface DebuggerApi { + attach: typeof chrome.debugger.attach + detach: typeof chrome.debugger.detach + sendCommand: typeof chrome.debugger.sendCommand +} + +export interface DevToolsOverlayHighlightTarget { + inspectId: string + inspectPath?: string + inspectTabId?: number +} + +const unavailableDebuggerApi: DebuggerApi = { + attach: async () => { + throw new Error('chrome.debugger is unavailable.') + }, + detach: async () => undefined, + sendCommand: async () => { + throw new Error('chrome.debugger is unavailable.') + }, +} + +const HIGHLIGHT_CONFIG = { + borderColor: { a: 0.7, b: 153, g: 229, r: 255 }, + contentColor: { a: 0.18, b: 220, g: 168, r: 111 }, + marginColor: { a: 0.35, b: 107, g: 178, r: 246 }, + paddingColor: { a: 0.35, b: 125, g: 196, r: 147 }, + showInfo: true, +} + +export class DevToolsOverlayHighlighter { + private attachedDebuggee: chrome.debugger.Debuggee | undefined + private attachPromise: Promise | undefined + private activeInspectId: string | undefined + private desiredInspectId: string | undefined + private hoverVersion = 0 + + constructor( + private readonly getTabId: () => number | undefined, + private readonly debuggerApi: DebuggerApi, + ) {} + + async prepare(target?: Pick) { + const debuggee = this.getDebuggee() + + if (!debuggee) { + return false + } + + if (typeof target?.inspectTabId === 'number' && target.inspectTabId !== debuggee.tabId) { + return false + } + + try { + await this.ensureAttached(debuggee) + return true + } + catch { + this.attachedDebuggee = undefined + this.activeInspectId = undefined + return false + } + } + + async highlight(target: DevToolsOverlayHighlightTarget | string) { + const highlightTarget = typeof target === 'string' ? { inspectId: target } : target + const { inspectId } = highlightTarget + this.desiredInspectId = inspectId + const hoverVersion = ++this.hoverVersion + const debuggee = this.getDebuggee() + + if (!debuggee) { + return false + } + + if (typeof highlightTarget.inspectTabId === 'number' && highlightTarget.inspectTabId !== debuggee.tabId) { + return false + } + + try { + await this.ensureAttached(debuggee) + + if (!this.isCurrentHighlight(inspectId, hoverVersion)) { + return false + } + + const evaluated = await this.sendCommand( + debuggee, + 'Runtime.evaluate', + { + expression: createResolveSelectedElementExpression(inspectId, highlightTarget.inspectPath), + objectGroup: DEBUGGER_OBJECT_GROUP, + returnByValue: false, + silent: true, + }, + ) + + if (!this.isCurrentHighlight(inspectId, hoverVersion)) { + return false + } + + const objectId = evaluated.result?.objectId + + if (!objectId) { + if (this.isCurrentHighlight(inspectId, hoverVersion)) { + await this.hide() + } + + return false + } + + if (!this.isCurrentHighlight(inspectId, hoverVersion)) { + return false + } + + const backendNodeId = await this.resolveBackendNodeId(debuggee, objectId) + + await this.sendCommand(debuggee, 'Overlay.highlightNode', { + highlightConfig: HIGHLIGHT_CONFIG, + ...(backendNodeId ? { backendNodeId } : { objectId }), + }) + + if (!this.isCurrentHighlight(inspectId, hoverVersion)) { + await this.hideStaleHighlight(debuggee) + this.rehighlightDesiredElement() + return false + } + + this.activeInspectId = inspectId + return true + } + catch { + this.attachedDebuggee = undefined + this.activeInspectId = undefined + return false + } + } + + async hide(inspectId?: string) { + if (inspectId && this.activeInspectId && this.activeInspectId !== inspectId) { + return false + } + + this.desiredInspectId = undefined + const hoverVersion = ++this.hoverVersion + + if (!this.attachedDebuggee) { + return false + } + + try { + await this.sendCommand(this.attachedDebuggee, 'Overlay.hideHighlight') + await this.releaseObjectGroup() + + if (hoverVersion === this.hoverVersion) { + this.activeInspectId = undefined + } + + return true + } + catch { + this.attachedDebuggee = undefined + this.activeInspectId = undefined + return false + } + } + + async detach() { + if (!this.attachedDebuggee) { + return + } + + const debuggee = this.attachedDebuggee + + await this.hide() + + try { + await this.debuggerApi.detach(debuggee) + } + catch {} + finally { + this.attachedDebuggee = undefined + this.activeInspectId = undefined + } + } + + private getDebuggee(): chrome.debugger.Debuggee | null { + const tabId = this.getTabId() + + return typeof tabId === 'number' ? { tabId } : null + } + + private async ensureAttached(debuggee: chrome.debugger.Debuggee) { + if (this.attachPromise) { + await this.attachPromise + } + + if (this.attachedDebuggee?.tabId === debuggee.tabId) { + return + } + + if (this.attachedDebuggee) { + await this.detach() + } + + this.attachPromise ||= this.debuggerApi + .attach(debuggee, DEBUGGER_PROTOCOL_VERSION) + .then(async () => { + await this.sendCommand(debuggee, 'DOM.enable').catch(() => undefined) + await this.sendCommand(debuggee, 'Overlay.enable').catch(() => undefined) + this.attachedDebuggee = debuggee + }) + .finally(() => { + this.attachPromise = undefined + }) + + await this.attachPromise + } + + private async releaseObjectGroup() { + if (!this.attachedDebuggee) { + return + } + + await this.sendCommand(this.attachedDebuggee, 'Runtime.releaseObjectGroup', { + objectGroup: DEBUGGER_OBJECT_GROUP, + }).catch(() => undefined) + } + + private async resolveBackendNodeId(debuggee: chrome.debugger.Debuggee, objectId: string) { + try { + const describedNode = await this.sendCommand( + debuggee, + 'DOM.describeNode', + { objectId }, + ) + + return describedNode.node?.backendNodeId + } + catch { + return undefined + } + } + + private isCurrentHighlight(inspectId: string, hoverVersion: number) { + return this.hoverVersion === hoverVersion && this.desiredInspectId === inspectId + } + + private async hideStaleHighlight(debuggee: chrome.debugger.Debuggee) { + await this.sendCommand(debuggee, 'Overlay.hideHighlight').catch(() => undefined) + await this.releaseObjectGroup() + this.activeInspectId = undefined + } + + private rehighlightDesiredElement() { + const inspectId = this.desiredInspectId + + if (inspectId) { + void this.highlight(inspectId) + } + } + + private async sendCommand( + debuggee: chrome.debugger.Debuggee, + method: string, + commandParams?: object, + ): Promise { + return await this.debuggerApi.sendCommand(debuggee, method, commandParams) as T + } +} + +export const devToolsOverlayHighlighter = new DevToolsOverlayHighlighter( + () => browser.devtools.inspectedWindow.tabId, + typeof chrome !== 'undefined' && chrome.debugger ? chrome.debugger : unavailableDebuggerApi, +) diff --git a/entrypoints/devtools-panel/utils/index.ts b/entrypoints/devtools-panel/utils/index.ts index 904befd..ff82839 100644 --- a/entrypoints/devtools-panel/utils/index.ts +++ b/entrypoints/devtools-panel/utils/index.ts @@ -1,6 +1,8 @@ export * from './array' export * from './cssDiff' +export * from './devtoolsOverlay' export * from './diff' export * from './formatStyle' +export * from './inspectedElement' export * from './locale' export * from './theme' diff --git a/entrypoints/devtools-panel/utils/inspectedElement.ts b/entrypoints/devtools-panel/utils/inspectedElement.ts new file mode 100644 index 0000000..36dbe9e --- /dev/null +++ b/entrypoints/devtools-panel/utils/inspectedElement.ts @@ -0,0 +1,106 @@ +import type { FormatStyleValue } from './formatStyle' +import { formatStyle } from './formatStyle' + +export type SelectedElementValueType = 'left' | 'right' + +export interface CaptureSelectedElementResult { + inspectPath?: string + readyState: Document['readyState'] + result: FormatStyleValue | null + inspectId?: string +} + +export const SELECTED_ELEMENT_STORE_KEY = '__CSS_DIFF_DEVTOOLS_SELECTED_ELEMENTS__' + +function getElementCssPath(element: Element | null) { + if (!(element instanceof Element)) { + return undefined + } + + const segments: string[] = [] + let currentElement: Element | null = element + + while (currentElement) { + const tagName = currentElement.tagName.toLowerCase() + const parentElement: Element | null = currentElement.parentElement + + if (!tagName) { + return undefined + } + + if (!parentElement) { + segments.unshift(tagName) + break + } + + let index = 1 + let sibling: Element | null = currentElement.previousElementSibling + + while (sibling) { + if (sibling.tagName === currentElement.tagName) { + index += 1 + } + + sibling = sibling.previousElementSibling + } + + segments.unshift(`${tagName}:nth-of-type(${index})`) + currentElement = parentElement + } + + return segments.join(' > ') +} + +export function createCaptureSelectedElementScript(valueType: SelectedElementValueType) { + return `(() => { + const element = $0; + const result = (${formatStyle.toString()})(element); + const readyState = document.readyState; + const inspectPath = (${getElementCssPath.toString()})(element); + + if (result != null && readyState === 'complete') { + const storeKey = ${JSON.stringify(SELECTED_ELEMENT_STORE_KEY)}; + const store = globalThis[storeKey] = globalThis[storeKey] || {}; + + store.elements = store.elements || {}; + store.valueTypeIds = store.valueTypeIds || {}; + + const previousInspectId = store.valueTypeIds[${JSON.stringify(valueType)}]; + + if (previousInspectId) { + delete store.elements[previousInspectId]; + } + + const inspectId = [ + ${JSON.stringify(valueType)}, + Date.now().toString(36), + Math.random().toString(36).slice(2), + ].join('-'); + + store.elements[inspectId] = element; + store.valueTypeIds[${JSON.stringify(valueType)}] = inspectId; + + return { readyState, result, inspectId, inspectPath }; + } + + return { readyState, result }; + })()` +} + +export function createResolveSelectedElementExpression(inspectId: string, inspectPath?: string) { + const cachedElementExpression = `globalThis[${JSON.stringify(SELECTED_ELEMENT_STORE_KEY)}]?.elements?.[${JSON.stringify(inspectId)}] ?? null` + + if (!inspectPath) { + return cachedElementExpression + } + + return `(() => { + const cachedElement = ${cachedElementExpression}; + + if (cachedElement) { + return cachedElement; + } + + return document.querySelector(${JSON.stringify(inspectPath)}); + })()` +} diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index fb0cdf4..134a62a 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -97,5 +97,8 @@ }, "inputPlaceholder": { "message": "Please enter the css property you want to view" + }, + "nativeHoverTooltip": { + "message": "Hover to highlight this DOM node in the inspected page. Requires Chrome debugger permission and may show a browser debugging notice." } } diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json index 13ec0b0..b93d0ac 100644 --- a/public/_locales/zh_CN/messages.json +++ b/public/_locales/zh_CN/messages.json @@ -97,5 +97,8 @@ }, "inputPlaceholder": { "message": "请输入需要查看的 css 属性" + }, + "nativeHoverTooltip": { + "message": "悬停时在被检查页面中高亮此 DOM 节点。需要授予 Chrome debugger 权限,浏览器可能显示正在调试的提示。" } } diff --git a/tests/unit/devtoolsOverlay.test.ts b/tests/unit/devtoolsOverlay.test.ts new file mode 100644 index 0000000..72eecac --- /dev/null +++ b/tests/unit/devtoolsOverlay.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from 'vitest' +import { type DebuggerApi, DevToolsOverlayHighlighter } from '../../entrypoints/devtools-panel/utils/devtoolsOverlay' + +function createDebuggerApi(objectId = 'node-object-id') { + const calls: Array<{ commandParams?: object, method: string }> = [] + const debuggerApi: DebuggerApi = { + attach: vi.fn(async () => undefined), + detach: vi.fn(async () => undefined), + sendCommand: vi.fn(async (_debuggee, method, commandParams) => { + calls.push({ commandParams, method }) + + if (method === 'Runtime.evaluate') { + return { result: objectId ? { objectId } : {} } + } + + return {} + }), + } + + return { + calls, + debuggerApi, + } +} + +function createDeferred() { + let resolve!: (value: T) => void + + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + + return { promise, resolve } +} + +async function flushPromises() { + await new Promise(resolve => setTimeout(resolve, 0)) +} + +describe('devToolsOverlayHighlighter', () => { + it('highlights a cached inspected element through the Chrome debugger overlay', async () => { + const { calls, debuggerApi } = createDebuggerApi() + const highlighter = new DevToolsOverlayHighlighter(() => 10, debuggerApi) + + await expect(highlighter.highlight('source-inspect-id')).resolves.toBe(true) + + expect(debuggerApi.attach).toHaveBeenCalledWith({ tabId: 10 }, '1.3') + expect(calls.map(call => call.method)).toEqual([ + 'DOM.enable', + 'Overlay.enable', + 'Runtime.evaluate', + 'DOM.describeNode', + 'Overlay.highlightNode', + ]) + expect(calls[2]?.commandParams).toMatchObject({ + expression: expect.stringContaining('source-inspect-id'), + returnByValue: false, + }) + expect(calls[3]?.commandParams).toMatchObject({ + objectId: 'node-object-id', + }) + expect(calls[4]?.commandParams).toMatchObject({ + objectId: 'node-object-id', + }) + }) + + it('resolves a selected element by DOM path and highlights its backend node id', async () => { + const calls: Array<{ commandParams?: object, method: string }> = [] + const debuggerApi: DebuggerApi = { + attach: vi.fn(async () => undefined), + detach: vi.fn(async () => undefined), + sendCommand: vi.fn(async (_debuggee, method, commandParams) => { + calls.push({ commandParams, method }) + + if (method === 'Runtime.evaluate') { + return { result: { objectId: 'node-object-id' } } + } + + if (method === 'DOM.describeNode') { + return { node: { backendNodeId: 42 } } + } + + return {} + }), + } + const highlighter = new DevToolsOverlayHighlighter(() => 10, debuggerApi) + + await expect(highlighter.highlight({ + inspectId: 'source-inspect-id', + inspectPath: 'html > body > a:nth-of-type(1)', + inspectTabId: 10, + } as any)).resolves.toBe(true) + + expect(calls.map(call => call.method)).toEqual([ + 'DOM.enable', + 'Overlay.enable', + 'Runtime.evaluate', + 'DOM.describeNode', + 'Overlay.highlightNode', + ]) + expect(calls.find(call => call.method === 'Runtime.evaluate')?.commandParams).toMatchObject({ + expression: expect.stringContaining('querySelector'), + }) + expect(calls.find(call => call.method === 'Overlay.highlightNode')?.commandParams).toMatchObject({ + backendNodeId: 42, + }) + }) + + it('hides the active debugger overlay immediately when hover ends', async () => { + const { calls, debuggerApi } = createDebuggerApi() + const highlighter = new DevToolsOverlayHighlighter(() => 10, debuggerApi) + + await highlighter.highlight('source-inspect-id') + await expect(highlighter.hide('source-inspect-id')).resolves.toBe(true) + + expect(calls.map(call => call.method)).toEqual([ + 'DOM.enable', + 'Overlay.enable', + 'Runtime.evaluate', + 'DOM.describeNode', + 'Overlay.highlightNode', + 'Overlay.hideHighlight', + 'Runtime.releaseObjectGroup', + ]) + }) + + it('does not highlight synced element data that has no cached DOM object in the current tab', async () => { + const { calls, debuggerApi } = createDebuggerApi('') + const highlighter = new DevToolsOverlayHighlighter(() => 10, debuggerApi) + + await expect(highlighter.highlight('remote-inspect-id')).resolves.toBe(false) + + expect(calls.map(call => call.method)).toEqual([ + 'DOM.enable', + 'Overlay.enable', + 'Runtime.evaluate', + 'Overlay.hideHighlight', + 'Runtime.releaseObjectGroup', + ]) + }) + + it('does not draw a stale highlight after hover has already been cancelled', async () => { + const calls: Array<{ commandParams?: object, method: string }> = [] + const evaluated = createDeferred<{ result: { objectId: string } }>() + const debuggerApi: DebuggerApi = { + attach: vi.fn(async () => undefined), + detach: vi.fn(async () => undefined), + sendCommand: vi.fn(async (_debuggee, method, commandParams) => { + calls.push({ commandParams, method }) + + if (method === 'Runtime.evaluate') { + return await evaluated.promise + } + + return {} + }), + } + const highlighter = new DevToolsOverlayHighlighter(() => 10, debuggerApi) + + const highlightPromise = highlighter.highlight('stale-inspect-id') + + await flushPromises() + await highlighter.hide() + + evaluated.resolve({ result: { objectId: 'stale-node-object-id' } }) + + await expect(highlightPromise).resolves.toBe(false) + expect(calls.map(call => call.method)).toEqual([ + 'DOM.enable', + 'Overlay.enable', + 'Runtime.evaluate', + 'Overlay.hideHighlight', + 'Runtime.releaseObjectGroup', + ]) + }) +}) diff --git a/tests/unit/devtoolsPanel.test.ts b/tests/unit/devtoolsPanel.test.ts index 16d2d82..643788b 100644 --- a/tests/unit/devtoolsPanel.test.ts +++ b/tests/unit/devtoolsPanel.test.ts @@ -1,12 +1,10 @@ import { mount } from '@vue/test-utils' import { describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' import { fakeBrowser } from 'wxt/testing' describe('devtools-panel', () => { - it('renders the initial comparison panel shell', async () => { - vi.resetModules() - await fakeBrowser.tabs.create({ active: true, url: 'https://example.com' }) - + function setupExtensionApi() { const messages: Record = { allProperties: 'All properties', changed: 'Changed', @@ -21,9 +19,11 @@ describe('devtools-panel', () => { info: 'Select two elements in the Elements tab of the DevTools panel and the style differences will be shown below.', inputPlaceholder: 'Please enter the css property you want to view', isAllProperty: 'Show all', + nativeHoverTooltip: 'Hover to highlight this DOM node in the inspected page. Requires Chrome debugger permission and may show a browser debugging notice.', property: 'property', readyToCompare: 'Ready to compare', removeBtn: 'Clear Selection', + removeSelectedElement: 'Remove $1 selection', selectedInfo: 'Please select two elements to compare.', selection: 'Selection', sourceElement: 'Source', @@ -34,32 +34,131 @@ describe('devtools-panel', () => { undefinedStyleValue: 'Undefined', waitingSelection: 'Waiting for selection', } + const runtimeMessageListeners: Array<(data: unknown) => void> = [] + let selectionChangedListener: (() => void) | undefined + const inspectedWindowEval = vi.fn((_expression: string, callback?: (result: unknown, isException?: boolean) => void) => { + callback?.(true, false) + }) + const debuggerCommands: Array<{ commandParams?: object, method: string }> = [] + const debuggerApi = { + attach: vi.fn(async () => undefined), + detach: vi.fn(async () => undefined), + sendCommand: vi.fn(async (_debuggee: chrome.debugger.Debuggee, method: string, commandParams?: object) => { + debuggerCommands.push({ commandParams, method }) + + if (method === 'Runtime.evaluate') { + return { result: { objectId: 'node-object-id' } } + } + + if (method === 'DOM.describeNode') { + return { node: { backendNodeId: 77 } } + } + + return {} + }), + } - vi.spyOn(fakeBrowser.i18n, 'getMessage').mockImplementation((key: string) => messages[key] ?? key) + vi.resetModules() + vi.spyOn(fakeBrowser.i18n, 'getMessage').mockImplementation((key: string, substitutions?: string | string[]) => { + const value = messages[key] ?? key + const replacements = Array.isArray(substitutions) ? substitutions : substitutions ? [substitutions] : [] + + return replacements.reduce((message, replacement, index) => message.replace(`$${index + 1}`, replacement), value) + }) vi.spyOn(fakeBrowser.i18n, 'getUILanguage').mockReturnValue('en') + vi.spyOn(fakeBrowser.runtime.onMessage, 'addListener').mockImplementation((callback: (data: unknown) => void) => { + runtimeMessageListeners.push(callback) + + return undefined as never + }) ;(globalThis as any).browser.devtools = { panels: { elements: { onSelectionChanged: { - addListener: vi.fn(), + addListener: vi.fn((callback: () => void) => { + selectionChangedListener = callback + }), }, }, }, inspectedWindow: { - eval: vi.fn(), + tabId: 1, + eval: inspectedWindowEval, }, } + ;(globalThis as any).chrome = { + ...(globalThis as any).chrome, + debugger: debuggerApi, + } + return { + debuggerApi, + debuggerCommands, + dispatchRuntimeMessage(data: unknown) { + runtimeMessageListeners.forEach(listener => listener(data)) + }, + inspectedWindowEval, + triggerSelectionChanged() { + selectionChangedListener?.() + }, + } + } + + async function flushDebuggerPromises() { + await new Promise(resolve => window.setTimeout(resolve, 0)) + await new Promise(resolve => window.setTimeout(resolve, 0)) + } + + async function mountPanel() { const { default: DevtoolsPanel } = await import('../../entrypoints/devtools-panel/devtools-panel.vue') - const wrapper = mount(DevtoolsPanel, { + return mount(DevtoolsPanel, { global: { stubs: { Toaster: true, }, }, }) + } + + async function selectTwoElements(dispatchRuntimeMessage: (data: unknown) => void) { + dispatchRuntimeMessage([ + { + valueType: 'left', + inspectId: 'left-local-inspect-id', + inspectPath: 'html > body > div:nth-of-type(1)', + inspectTabId: 1, + tag: 'div', + id: 'source', + class: 'source-card', + style: { + color: 'rgb(34, 34, 34)', + display: 'block', + }, + }, + { + valueType: 'right', + inspectId: 'right-remote-inspect-id', + inspectPath: 'html > body > img:nth-of-type(1)', + inspectTabId: 1, + tag: 'div', + id: 'target', + class: 'target-card', + style: { + color: 'rgb(0, 0, 0)', + display: 'flex', + }, + }, + ]) + await nextTick() + } + + it('renders the initial comparison panel shell', async () => { + await fakeBrowser.tabs.create({ active: true, url: 'https://example.com' }) + setupExtensionApi() + + const wrapper = await mountPanel() expect(wrapper.text()).toContain('DOM Diff') expect(wrapper.text()).toContain('Waiting for selection') @@ -68,4 +167,184 @@ describe('devtools-panel', () => { expect(wrapper.text()).toContain('Target') expect(wrapper.text()).toContain('Please select two elements to compare.') }) + + it('highlights the source column through the Chrome debugger overlay when hovered', async () => { + await fakeBrowser.tabs.create({ active: true, url: 'https://example.com' }) + const extensionApi = setupExtensionApi() + const wrapper = await mountPanel() + + await selectTwoElements(extensionApi.dispatchRuntimeMessage) + await wrapper.findAll('th')[1].trigger('pointerenter') + await flushDebuggerPromises() + + expect(extensionApi.debuggerApi.attach).toHaveBeenCalledWith({ tabId: 1 }, '1.3') + expect(extensionApi.debuggerCommands.map(call => call.method)).toContain('Overlay.highlightNode') + expect(extensionApi.debuggerCommands.find(call => call.method === 'Runtime.evaluate')?.commandParams).toMatchObject({ + expression: expect.stringContaining('left-local-inspect-id'), + }) + }) + + it('prepares the Chrome debugger session after two local elements are selected', async () => { + await fakeBrowser.tabs.create({ active: true, url: 'https://example.com' }) + const extensionApi = setupExtensionApi() + await mountPanel() + + await selectTwoElements(extensionApi.dispatchRuntimeMessage) + await flushDebuggerPromises() + + expect(extensionApi.debuggerApi.attach).toHaveBeenCalledWith({ tabId: 1 }, '1.3') + expect(extensionApi.debuggerCommands.map(call => call.method)).toEqual([ + 'DOM.enable', + 'Overlay.enable', + ]) + }) + + it('hides the debugger overlay when the selected table header is no longer hovered', async () => { + await fakeBrowser.tabs.create({ active: true, url: 'https://example.com' }) + const extensionApi = setupExtensionApi() + const wrapper = await mountPanel() + const sourceHeader = wrapper.findAll('th')[1] + + await selectTwoElements(extensionApi.dispatchRuntimeMessage) + await sourceHeader.trigger('pointerenter') + await flushDebuggerPromises() + await sourceHeader.trigger('pointerleave') + await flushDebuggerPromises() + + expect(extensionApi.debuggerCommands.map(call => call.method)).toEqual([ + 'DOM.enable', + 'Overlay.enable', + 'Runtime.evaluate', + 'DOM.describeNode', + 'Overlay.highlightNode', + 'Overlay.hideHighlight', + 'Runtime.releaseObjectGroup', + ]) + }) + + it('keeps the same inspected element active when the pointer moves within a selected column', async () => { + await fakeBrowser.tabs.create({ active: true, url: 'https://example.com' }) + const extensionApi = setupExtensionApi() + const wrapper = await mountPanel() + + await selectTwoElements(extensionApi.dispatchRuntimeMessage) + + const sourceCells = wrapper.findAll('[data-css-diff-inspect-id="left-local-inspect-id"]') + + expect(sourceCells.length).toBeGreaterThanOrEqual(2) + + await sourceCells[1]!.trigger('pointerenter') + await flushDebuggerPromises() + await sourceCells[1]!.trigger('pointerleave', { + relatedTarget: sourceCells[2]!.element, + }) + await flushDebuggerPromises() + + expect(extensionApi.debuggerCommands.map(call => call.method)).not.toContain('Overlay.hideHighlight') + }) + + it('keeps the hover session active when the pointer moves between selected columns', async () => { + await fakeBrowser.tabs.create({ active: true, url: 'https://example.com' }) + const extensionApi = setupExtensionApi() + const wrapper = await mountPanel() + + await selectTwoElements(extensionApi.dispatchRuntimeMessage) + + const sourceCells = wrapper.findAll('[data-css-diff-inspect-id="left-local-inspect-id"]') + const targetCells = wrapper.findAll('[data-css-diff-inspect-id="right-remote-inspect-id"]') + + await targetCells[1]!.trigger('pointerenter') + await flushDebuggerPromises() + await targetCells[1]!.trigger('pointerleave', { + relatedTarget: sourceCells[1]!.element, + }) + await flushDebuggerPromises() + await sourceCells[1]!.trigger('pointerenter') + await flushDebuggerPromises() + + expect(extensionApi.debuggerCommands.map(call => call.method).filter(method => method === 'Overlay.highlightNode')).toHaveLength(2) + expect(extensionApi.debuggerCommands.map(call => call.method)).not.toContain('Overlay.hideHighlight') + }) + + it('uses the selected element inspect id instead of a value type when hovering synced columns', async () => { + await fakeBrowser.tabs.create({ active: true, url: 'https://example.com' }) + const extensionApi = setupExtensionApi() + const wrapper = await mountPanel() + + await selectTwoElements(extensionApi.dispatchRuntimeMessage) + await wrapper.findAll('th')[2].trigger('pointerenter') + await flushDebuggerPromises() + + const runtimeEvaluate = extensionApi.debuggerCommands.find(call => call.method === 'Runtime.evaluate') + + expect(runtimeEvaluate?.commandParams).toMatchObject({ + expression: expect.stringContaining('right-remote-inspect-id'), + }) + expect(runtimeEvaluate?.commandParams).toMatchObject({ + expression: expect.stringContaining('querySelector'), + }) + expect(JSON.stringify(runtimeEvaluate?.commandParams)).not.toContain('["right"]') + }) + + it('does not highlight DOM data selected from another inspected tab', async () => { + await fakeBrowser.tabs.create({ active: true, url: 'https://example.com' }) + const extensionApi = setupExtensionApi() + const wrapper = await mountPanel() + + extensionApi.dispatchRuntimeMessage([ + { + valueType: 'left', + inspectId: 'left-remote-inspect-id', + inspectPath: 'html > body > div:nth-of-type(1)', + inspectTabId: 2, + tag: 'div', + id: 'source', + class: 'source-card', + style: { + color: 'rgb(34, 34, 34)', + }, + }, + { + valueType: 'right', + inspectId: 'right-remote-inspect-id', + inspectPath: 'html > body > img:nth-of-type(1)', + inspectTabId: 2, + tag: 'img', + id: '', + class: '', + style: { + color: 'rgb(0, 0, 0)', + }, + }, + ]) + await nextTick() + await wrapper.findAll('th')[1].trigger('pointerenter') + await flushDebuggerPromises() + + expect(extensionApi.debuggerApi.attach).not.toHaveBeenCalled() + expect(extensionApi.debuggerCommands).toEqual([]) + }) + + it('does not use inspectedWindow inspect scripts when hovering a diff column', async () => { + await fakeBrowser.tabs.create({ active: true, url: 'https://example.com' }) + const extensionApi = setupExtensionApi() + const wrapper = await mountPanel() + + await selectTwoElements(extensionApi.dispatchRuntimeMessage) + await wrapper.findAll('th')[1].trigger('pointerenter') + await flushDebuggerPromises() + + expect(extensionApi.inspectedWindowEval).not.toHaveBeenCalled() + expect(extensionApi.debuggerCommands.map(call => call.method)).toContain('Overlay.highlightNode') + }) + + it('shows a tooltip explaining the native hover permission requirement on DOM diff cells', async () => { + await fakeBrowser.tabs.create({ active: true, url: 'https://example.com' }) + const extensionApi = setupExtensionApi() + const wrapper = await mountPanel() + + await selectTwoElements(extensionApi.dispatchRuntimeMessage) + + expect(wrapper.find('[data-css-diff-inspect-id="left-local-inspect-id"]').attributes('title')).toBe('Hover to highlight this DOM node in the inspected page. Requires Chrome debugger permission and may show a browser debugging notice.') + }) }) diff --git a/tests/unit/inspectedElement.test.ts b/tests/unit/inspectedElement.test.ts new file mode 100644 index 0000000..ab69131 --- /dev/null +++ b/tests/unit/inspectedElement.test.ts @@ -0,0 +1,32 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it, vi } from 'vitest' +import { createResolveSelectedElementExpression, SELECTED_ELEMENT_STORE_KEY } from '../../entrypoints/devtools-panel/utils/inspectedElement' + +describe('inspected element scripts', () => { + it('resolves a cached selected element by inspect id', () => { + const element = {} + const context = { + [SELECTED_ELEMENT_STORE_KEY]: { + elements: { + 'target-inspect-id': element, + }, + }, + } + + expect(runInNewContext(createResolveSelectedElementExpression('target-inspect-id'), context)).toBe(element) + }) + + it('returns null when the selected element is not cached in the current inspected page', () => { + expect(runInNewContext(createResolveSelectedElementExpression('missing-inspect-id'), {})).toBeNull() + }) + + it('falls back to a DOM path when the selected element cache is unavailable', () => { + const element = {} + const document = { + querySelector: vi.fn(() => element), + } + + expect(runInNewContext((createResolveSelectedElementExpression as any)('missing-inspect-id', 'html > body > a:nth-of-type(1)'), { document })).toBe(element) + expect(document.querySelector).toHaveBeenCalledWith('html > body > a:nth-of-type(1)') + }) +}) diff --git a/wxt.config.ts b/wxt.config.ts index 1cea76b..849cedc 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ name: '__MSG_extName__', description: '__MSG_extDescription__', default_locale: 'en', - permissions: ['nativeMessaging', 'tabs', 'activeTab', 'scripting'], + permissions: ['nativeMessaging', 'tabs', 'activeTab', 'scripting', 'debugger'], }, modules: ['@wxt-dev/module-vue'], vite: () => ({