From eeb633d68ecff24eb6b217871fbbcc17dd3426b5 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Mon, 7 Sep 2026 13:25:54 +0800 Subject: [PATCH] fix(extension): verify fill results before reporting success --- .../src/tools/__tests__/fill.test.ts | 370 ++++++++++++++++++ .../src/tools/__tests__/interaction.test.ts | 34 +- apps/extension/src/tools/interaction.ts | 217 ++++++++-- 3 files changed, 574 insertions(+), 47 deletions(-) create mode 100644 apps/extension/src/tools/__tests__/fill.test.ts diff --git a/apps/extension/src/tools/__tests__/fill.test.ts b/apps/extension/src/tools/__tests__/fill.test.ts new file mode 100644 index 0000000..e256c56 --- /dev/null +++ b/apps/extension/src/tools/__tests__/fill.test.ts @@ -0,0 +1,370 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SessionManager } from "@/session-manager/manager"; +import type { CdpRunner } from "@/tools/shared"; +import { handleFill } from "../interaction"; + +interface ScriptParams { + functionDeclaration: string; + arguments?: Array<{ value: unknown }>; +} + +async function setup(markup = '') { + document.body.innerHTML = markup; + const element = document.body.firstElementChild as HTMLInputElement; + vi.spyOn(document, "hasFocus").mockReturnValue(true); + const manager = new SessionManager({ + agentWindow: { + create: async () => 100, + remove: async () => {}, + ensureActiveTab: async () => 4, + }, + }); + const ctx = await manager.start("aa11"); + ctx.refStore.set("e1", 12, { tabId: 4 }); + const script = vi.fn(async (params: ScriptParams): Promise => { + try { + const fn = new Function(`return (${params.functionDeclaration})`)(); + return { + result: { + value: await fn.apply( + element, + (params.arguments ?? []).map((arg) => arg.value), + ), + }, + }; + } catch { + return { exceptionDetails: { text: "test page script failed" } }; + } + }); + const insert = vi.fn(async (text: string) => { + const focused = document.activeElement as HTMLInputElement; + if (focused.readOnly || focused.disabled) return; + const start = focused.selectionStart ?? focused.value.length; + const end = focused.selectionEnd ?? start; + const value = focused.value.slice(0, start) + text + focused.value.slice(end); + focused.value = focused.maxLength >= 0 ? value.slice(0, focused.maxLength) : value; + focused.dispatchEvent(new Event("input", { bubbles: true })); + }); + const release = vi.fn(async () => ({})); + const send = vi.fn(async (_tabId: number, method: string, params?: object) => { + switch (method) { + case "DOM.describeNode": + return { + node: { + nodeName: element.tagName, + attributes: Array.from(element.attributes).flatMap((attr) => [attr.name, attr.value]), + }, + }; + case "DOM.scrollIntoViewIfNeeded": + return {}; + case "DOM.focus": + element.focus(); + return {}; + case "DOM.resolveNode": + return { object: { objectId: "fill-target" } }; + case "Runtime.callFunctionOn": + return script(params as ScriptParams); + case "Input.insertText": + await insert((params as { text: string }).text); + return {}; + case "Runtime.releaseObject": + return release(); + default: + throw new Error(`unexpected ${method}`); + } + }); + return { + element, + script, + insert, + release, + send, + fill: (value = "hello", clearBefore = true, signal?: AbortSignal) => + handleFill( + manager, + { session_id: "aa11", ref: "e1", value, clear_before: clearBefore }, + { + cdp: { send: send as CdpRunner["send"] }, + tabsApi: { + get: async (id) => ({ id, windowId: 100, active: true }) as chrome.tabs.Tab, + query: async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab], + }, + signal, + }, + ), + }; +} + +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; +}); + +describe("fill result verification", () => { + it.each([ + "readonly", + "disabled", + ])("preserves a %s field before rejecting it", async (attribute) => { + const h = await setup(``); + expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /not editable/ }); + expect(h.element.value).toBe("old"); + expect(h.insert).not.toHaveBeenCalled(); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it.each([ + "checkbox", + "radio", + "range", + "date", + "file", + ])("rejects an unsupported %s input before clearing", async (type) => { + const h = await setup(``); + const before = h.element.value; + expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /input type/ }); + expect(h.element.value).toBe(before); + expect(h.insert).not.toHaveBeenCalled(); + }); + + it("rejects an overlong value before clearing", async () => { + const h = await setup(''); + expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /maxlength/ }); + expect(h.element.value).toBe("old"); + expect(h.insert).not.toHaveBeenCalled(); + }); + + it("does not type into a field that gained focus during clearing", async () => { + const h = await setup(); + const other = document.createElement("input"); + document.body.append(other); + h.element.addEventListener("input", () => other.focus()); + expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /lost focus/ }); + expect(other.value).toBe(""); + expect(h.insert).not.toHaveBeenCalled(); + }); + + it.each([ + "remove", + "readonly", + "restore", + "microtask-restore", + ])("stops when clearing causes the page to %s the target", async (action) => { + const h = await setup(); + h.element.addEventListener("input", () => { + if (action === "remove") h.element.remove(); + if (action === "readonly") h.element.readOnly = true; + if (action === "restore") h.element.value = "restored"; + if (action === "microtask-restore") + queueMicrotask(() => { + h.element.value = "restored"; + }); + }); + expect(await h.fill()).toMatchObject({ code: "cdp_failed" }); + expect(h.insert).not.toHaveBeenCalled(); + }); + + it("fails when insertText is acknowledged without changing the value", async () => { + const h = await setup(); + h.insert.mockImplementation(async () => {}); + expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /expected value/ }); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it("detects truncation when maxlength changes after preparation", async () => { + const h = await setup(); + h.element.addEventListener( + "input", + () => { + h.element.maxLength = 3; + }, + { once: true }, + ); + expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /expected value/ }); + expect(h.element.value).toBe("hel"); + }); + + it.each([ + "input", + "change", + "microtask-change", + ])("detects a value reverted by the %s handler", async (event) => { + const h = await setup(); + h.element.addEventListener(event === "microtask-change" ? "change" : event, () => { + if (event === "microtask-change") + queueMicrotask(() => { + h.element.value = ""; + }); + else h.element.value = ""; + }); + expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /expected value/ }); + }); + + it("compares content rather than length without including field values in the error", async () => { + const h = await setup(''); + h.element.addEventListener("change", () => { + h.element.value = "other-secret"; + }); + const result = await h.fill("given-secret"); + expect(result).toMatchObject({ code: "cdp_failed", message: /expected value/ }); + expect(JSON.stringify(result)).not.toMatch(/given-secret|other-secret/); + }); + + it("does not report success for a target replaced by a change handler", async () => { + const h = await setup(); + h.element.addEventListener("change", () => h.element.replaceWith(h.element.cloneNode())); + expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /expected value/ }); + }); + + it.each([ + 1, 2, 3, 4, + ])("rejects script exceptions at phase %s and releases the target", async (phase) => { + const h = await setup(); + const run = h.script.getMockImplementation()!; + let count = 0; + h.script.mockImplementation(async (params) => + ++count === phase ? { exceptionDetails: { text: "page secret" } } : run(params), + ); + const result = await h.fill(); + expect(result).toMatchObject({ code: "cdp_failed" }); + expect(JSON.stringify(result)).not.toContain("page secret"); + if (phase <= 2) expect(h.insert).not.toHaveBeenCalled(); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it.each([1, 2, 4])("rejects missing results at phase %s", async (phase) => { + const h = await setup(); + const run = h.script.getMockImplementation()!; + let count = 0; + h.script.mockImplementation(async (params) => + ++count === phase ? { result: { type: "undefined" } } : run(params), + ); + expect(await h.fill()).toMatchObject({ code: "cdp_failed" }); + if (phase <= 2) expect(h.insert).not.toHaveBeenCalled(); + }); + + it.each(["input", "change"])("checks state after nested microtasks from %s", async (event) => { + const h = await setup(); + h.element.addEventListener(event, () => { + queueMicrotask(() => + queueMicrotask(() => { + h.element.value = "reverted"; + }), + ); + }); + expect(await h.fill()).toMatchObject({ code: "cdp_failed" }); + if (event === "input") expect(h.insert).not.toHaveBeenCalled(); + }); + + it.each([ + "", + '', + "", + ])("verifies successful text in %s", async (markup) => { + const h = await setup(markup); + expect(await h.fill("你好🙂")).toMatchObject({ value_length: 4 }); + expect(h.element.value).toBe("你好🙂"); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it("verifies append against the old value plus the requested text", async () => { + const h = await setup(); + h.element.focus(); + h.element.setSelectionRange(3, 3); + expect(await h.fill("!", false)).toMatchObject({ value_length: 1 }); + expect(h.element.value).toBe("old!"); + }); + + it.each([ + ["", "a\r\nb", "a\nb"], + ["", "a\nb", "a b"], + ["", "a\r\nb", "a b"], + [ + '', + " a@example.com , b@example.com ", + "a@example.com,b@example.com", + ], + ])("accepts native normalization for %s", async (markup, requested, normalized) => { + if (markup.startsWith(" { + h.element.value = normalized; + }); + expect(await h.fill(requested)).toMatchObject({ value_length: normalized.length }); + }); + + it("does not count an editable padding break as an extra typed character", async () => { + const h = await setup('
'); + // These two layout/editability properties are missing in Happy DOM. + let innerText = ""; + Object.defineProperty(h.element, "isContentEditable", { get: () => true }); + Object.defineProperty(h.element, "innerText", { get: () => innerText }); + h.insert.mockImplementation(async () => { + h.element.innerHTML = "one

"; + innerText = "one\n\n"; + }); + expect(await h.fill("one\n")).toMatchObject({ value_length: 4 }); + }); + + it("rejects a non-append selection before typing", async () => { + const h = await setup(); + h.element.focus(); + h.element.setSelectionRange(1, 1); + expect(await h.fill("!", false)).toMatchObject({ code: "cdp_failed" }); + expect(h.element.value).toBe("old"); + expect(h.insert).not.toHaveBeenCalled(); + }); + + it.each([true, false])("verifies an empty request with clear_before=%s", async (clearBefore) => { + const h = await setup(); + expect(await h.fill("", clearBefore)).toMatchObject({ value_length: 0 }); + expect(h.element.value).toBe(clearBefore ? "" : "old"); + expect(h.insert).not.toHaveBeenCalled(); + }); + + it("cancels after insertion without sending change events", async () => { + const h = await setup(); + const controller = new AbortController(); + h.insert.mockImplementation(async () => controller.abort()); + expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" }); + expect(h.script).toHaveBeenCalledTimes(2); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it("cancels after resolving the remote object without clearing the field", async () => { + const h = await setup(); + const controller = new AbortController(); + const send = h.send.getMockImplementation()!; + h.send.mockImplementation(async (tabId, method, params) => { + const result = await send(tabId, method, params); + if (method === "DOM.resolveNode") controller.abort(); + return result; + }); + expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" }); + expect(h.element.value).toBe("old"); + expect(h.script).not.toHaveBeenCalled(); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it("honors cancellation during verification", async () => { + const h = await setup(); + const controller = new AbortController(); + h.element.addEventListener("change", () => controller.abort()); + expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" }); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it("keeps the verified outcome when releasing the remote object fails", async () => { + const h = await setup(); + h.release.mockRejectedValueOnce(new Error("target closed")); + expect(await h.fill()).toMatchObject({ value_length: 5 }); + }); +}); diff --git a/apps/extension/src/tools/__tests__/interaction.test.ts b/apps/extension/src/tools/__tests__/interaction.test.ts index 45ab74e..7d552b2 100644 --- a/apps/extension/src/tools/__tests__/interaction.test.ts +++ b/apps/extension/src/tools/__tests__/interaction.test.ts @@ -540,6 +540,18 @@ describe("handleHover", () => { }); }); +function successfulFillScript(params: unknown) { + const args = (params as { arguments?: Array<{ value: unknown }> }).arguments ?? []; + return { + result: { + value: + args.length === 2 + ? { before: "", expected: args[0].value, valueLength: String(args[0].value).length } + : true, + }, + }; +} + describe("handleFill", () => { it("returns not_found for unknown ref", async () => { const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); @@ -586,7 +598,8 @@ describe("handleFill", () => { "DOM.scrollIntoViewIfNeeded": () => ({}), "DOM.focus": () => ({}), "DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }), - "Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }), + "Runtime.callFunctionOn": successfulFillScript, + "Runtime.releaseObject": () => ({}), "Input.insertText": () => ({}), }); const res = await handleFill( @@ -600,13 +613,12 @@ describe("handleFill", () => { expect(res.used_ref).toBe("e1"); const insert = fake.sent.find((c) => c.method === "Input.insertText"); expect(insert?.params).toEqual({ text: "hello" }); - // clear_before defaults to true → callFunctionOn invoked once to - // clear, once to fire input/change after typing. + // Preparation, readiness, notifications, and final verification. const callFns = fake.sent.filter((c) => c.method === "Runtime.callFunctionOn"); - expect(callFns.length).toBeGreaterThanOrEqual(2); + expect(callFns).toHaveLength(4); }); - it("clear_before=false skips the wipe call", async () => { + it("passes clear_before=false to preparation and verifies the result", async () => { const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); const ctx = await sm.start("aa11"); ctx.refStore.set("e1", 1, { tabId: 4 }); @@ -618,12 +630,11 @@ describe("handleFill", () => { "DOM.focus": () => ({}), "DOM.resolveNode": () => ({ object: { objectId: "obj-2" } }), "Runtime.callFunctionOn": (p) => { - const fn = (p as { functionDeclaration?: string }).functionDeclaration ?? ""; - // No "this.value = ''" clearing on a no-clear path; only the - // post-input dispatchEvent. - expect(fn).not.toMatch(/this\.value\s*=\s*''/); - return { result: { type: "undefined" } }; + const args = (p as { arguments?: Array<{ value: unknown }> }).arguments ?? []; + if (args.length === 2) expect(args[1].value).toBe(false); + return successfulFillScript(p); }, + "Runtime.releaseObject": () => ({}), "Input.insertText": () => ({}), }); const res = await handleFill( @@ -650,7 +661,8 @@ describe("handleFill", () => { "DOM.scrollIntoViewIfNeeded": () => ({}), "DOM.focus": () => ({}), "DOM.resolveNode": () => ({ object: { objectId: "obj-3" } }), - "Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }), + "Runtime.callFunctionOn": successfulFillScript, + "Runtime.releaseObject": () => ({}), "Input.insertText": () => ({}), }); const res = await handleFill( diff --git a/apps/extension/src/tools/interaction.ts b/apps/extension/src/tools/interaction.ts index 18608ca..5a48349 100644 --- a/apps/extension/src/tools/interaction.ts +++ b/apps/extension/src/tools/interaction.ts @@ -609,6 +609,36 @@ type SelectMutationResult = // tool.fill // --------------------------------------------------------------------------- +interface FillPreparation { + before?: string; + expected?: string; + valueLength?: number; + error?: string; +} + +interface FillScriptReply { + result?: { value?: T }; + exceptionDetails?: unknown; +} + +const FILL_EDITABLE_FUNCTION = `function() { + const tag = this.tagName.toLowerCase(); + const supported = tag === 'input' + ? ['text', 'search', 'tel', 'url', 'email', 'password', 'number'].includes(this.type) + : tag === 'textarea' || this.isContentEditable; + return this.isConnected && supported && !this.readOnly && !this.matches(':disabled'); +}`; + +// Chrome renders a trailing editable newline with an empty

. +// innerText includes the padding break; it is not an extra typed character. +const FILL_VALUE_FUNCTION = `function() { + if (!this.isContentEditable) return this.value; + const value = this.innerText; + const tail = this.lastChild; + const padding = tail && tail.nodeName === 'DIV' && tail.childNodes.length === 1 && tail.firstChild.nodeName === 'BR'; + return padding && value.endsWith('\\n\\n') ? value.slice(0, -1) : value; +}`; + export async function handleFill( manager: SessionManager, params: FillParams, @@ -678,57 +708,172 @@ export async function handleFill( const clearBefore = params.clear_before ?? true; try { - if (clearBefore) { - // Clear input/textarea value or wipe contenteditable innerText, - // then fire `input` so frameworks observe the empty state. - await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", { - objectId, - functionDeclaration: `function() { - if (this.isContentEditable) { this.textContent = ''; } - else { - const proto = this instanceof HTMLTextAreaElement - ? HTMLTextAreaElement.prototype - : HTMLInputElement.prototype; - const descriptor = Object.getOwnPropertyDescriptor(proto, 'value'); - if (descriptor && descriptor.set) descriptor.set.call(this, ''); - else this.value = ''; - } - this.dispatchEvent(new Event('input', { bubbles: true })); - }`, - returnByValue: true, - }); - } if (throwIfAborted(deps.signal)) { return { code: "cancelled", message: "fill aborted" }; } + // Check editability before clearing. Keep the expected result tied to + // this object, including the existing value on the append path. + const prepared = await nodeCdp.send>( + target.tabId, + "Runtime.callFunctionOn", + { + objectId, + functionDeclaration: `function(value, clearBefore) { + const tag = this.tagName.toLowerCase(); + const native = tag === 'input' || tag === 'textarea'; + if (!(${FILL_EDITABLE_FUNCTION}).call(this)) return { error: 'fill target is not editable or its input type is unsupported' }; + const focused = () => this.getRootNode().activeElement === this && this.ownerDocument.hasFocus(); + if (!focused()) return { error: 'fill target does not have focus' }; + const before = clearBefore ? '' : (${FILL_VALUE_FUNCTION}).call(this); + if (!clearBefore && value !== '' && native && typeof this.selectionStart === 'number' && + (this.selectionStart !== before.length || this.selectionEnd !== before.length)) { + return { error: 'fill append requires the caret at the end of the field' }; + } + let expected = before + value; + if (native) { + // Use the browser's own value sanitization (e.g. textarea + // line endings) without changing the live control. + const normalizer = this.ownerDocument.createElement(tag); + if (tag === 'input') { + normalizer.type = this.type; + normalizer.multiple = this.multiple; + } + // insertText treats line breaks in a single-line input as spaces. + if (tag === 'input') expected = expected.replace(/\\r\\n|\\r|\\n/g, ' '); + normalizer.value = expected; + if (expected !== '' && normalizer.value === '' && this.type === 'number') { + return { error: 'fill value is not valid for a number input' }; + } + expected = normalizer.value; + if (this.type !== 'number' && this.maxLength >= 0 && expected.length > this.maxLength) { + return { error: 'fill value exceeds the target maxlength' }; + } + } else { + expected = expected.replace(/\\r\\n?/g, '\\n'); + } + if (clearBefore) { + if (native) { + const proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + Object.getOwnPropertyDescriptor(proto, 'value').set.call(this, ''); + } else { + this.textContent = ''; + } + this.dispatchEvent(new Event('input', { bubbles: true })); + } + return { before, expected, valueLength: Math.max(0, expected.length - before.length) }; + }`, + arguments: [{ value: params.value }, { value: clearBefore }], + returnByValue: true, + }, + ); + if (throwIfAborted(deps.signal)) { + return { code: "cancelled", message: "fill aborted" }; + } + if (prepared.exceptionDetails) { + return { code: "cdp_failed", message: "fill preparation script failed" }; + } + const preparation = prepared.result?.value; + if ( + preparation?.error || + typeof preparation?.before !== "string" || + typeof preparation?.expected !== "string" || + typeof preparation.valueLength !== "number" + ) { + return { + code: "cdp_failed", + message: preparation?.error ?? "fill preparation returned an unexpected result", + }; + } + // A separate call runs after the clearing event's microtasks drain. + // Confirm the target before sending input to the focused element. + const ready = await nodeCdp.send>( + target.tabId, + "Runtime.callFunctionOn", + { + objectId, + functionDeclaration: `function(before) { + return (${FILL_EDITABLE_FUNCTION}).call(this) && + this.getRootNode().activeElement === this && this.ownerDocument.hasFocus() && + (${FILL_VALUE_FUNCTION}).call(this) === before; + }`, + arguments: [{ value: preparation.before }], + returnByValue: true, + }, + ); + if (throwIfAborted(deps.signal)) { + return { code: "cancelled", message: "fill aborted" }; + } + if (ready.exceptionDetails || ready.result?.value !== true) { + return { + code: "cdp_failed", + message: "fill target changed, lost focus, or could not be checked before typing", + }; + } // CDP `Input.insertText` handles IME / multi-byte input out of the // box, much more reliably than per-key `dispatchKeyEvent`. - await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value }); + if (params.value !== "") { + await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value }); + } if (throwIfAborted(deps.signal)) { return { code: "cancelled", message: "fill aborted" }; } - // Fire `input` + `change` so React / Vue controlled inputs commit. - await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", { - objectId, - functionDeclaration: `function() { - this.dispatchEvent(new Event('input', { bubbles: true })); - this.dispatchEvent(new Event('change', { bubbles: true })); - }`, - returnByValue: true, + // Keep the existing notifications, then read in a separate call so + // nested microtasks queued by the page cannot race the verification. + const notified = await nodeCdp.send>( + target.tabId, + "Runtime.callFunctionOn", + { + objectId, + functionDeclaration: `function() { + this.dispatchEvent(new Event('input', { bubbles: true })); + this.dispatchEvent(new Event('change', { bubbles: true })); + }`, + returnByValue: true, + }, + ); + if (throwIfAborted(deps.signal)) { + return { code: "cancelled", message: "fill aborted" }; + } + if (notified.exceptionDetails) { + return { code: "cdp_failed", message: "fill notification script failed" }; + } + const verified = await nodeCdp.send>( + target.tabId, + "Runtime.callFunctionOn", + { + objectId, + functionDeclaration: `function(expected) { + return this.isConnected && (${FILL_VALUE_FUNCTION}).call(this) === expected; + }`, + arguments: [{ value: preparation.expected }], + returnByValue: true, + }, + ); + if (throwIfAborted(deps.signal)) { + return { code: "cancelled", message: "fill aborted" }; + } + if (verified.exceptionDetails || verified.result?.value !== true) { + return { + code: "cdp_failed", + message: verified.exceptionDetails + ? "fill verification script failed" + : "fill could not verify the expected value; observe the page before retrying", + }; + } + return attachDialogs(deps.cdp, target.tabId, dialogCursor, { + tab_id: target.tabId, + used_ref: node.usedRef, + used_selector: node.usedSelector, + value_length: preparation.valueLength, }); } catch (err) { return { code: "cdp_failed", message: err instanceof Error ? err.message : String(err), }; + } finally { + await nodeCdp.send(target.tabId, "Runtime.releaseObject", { objectId }).catch(() => undefined); } - - return attachDialogs(deps.cdp, target.tabId, dialogCursor, { - tab_id: target.tabId, - used_ref: node.usedRef, - used_selector: node.usedSelector, - value_length: params.value.length, - }); } // ---------------------------------------------------------------------------