diff --git a/lerna.json b/lerna.json index 75243372..2aa0ead9 100644 --- a/lerna.json +++ b/lerna.json @@ -2,6 +2,6 @@ "packages": [ "packages/*" ], - "version": "0.8.69", + "version": "0.8.70-beta", "npmClient": "yarn" } \ No newline at end of file diff --git a/package.json b/package.json index d85e4dd7..e0d36b83 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "clarity", "private": true, - "version": "0.8.69", + "version": "0.8.70-beta", "repository": "git+https://github.com/microsoft/clarity.git", "author": "Sarvesh Nagpal ", "license": "MIT", diff --git a/packages/clarity-decode/package.json b/packages/clarity-decode/package.json index 2fdbbe0e..d573d0c4 100644 --- a/packages/clarity-decode/package.json +++ b/packages/clarity-decode/package.json @@ -1,6 +1,6 @@ { "name": "clarity-decode", - "version": "0.8.69", + "version": "0.8.70-beta", "description": "An analytics library that uses web page interactions to generate aggregated insights", "author": "Microsoft Corp.", "license": "MIT", @@ -26,7 +26,7 @@ "url": "https://github.com/Microsoft/clarity/issues" }, "dependencies": { - "clarity-js": "^0.8.69" + "clarity-js": "^0.8.70-beta" }, "devDependencies": { "@rollup/plugin-commonjs": "^24.0.0", diff --git a/packages/clarity-devtools/package.json b/packages/clarity-devtools/package.json index 20283015..0c382271 100644 --- a/packages/clarity-devtools/package.json +++ b/packages/clarity-devtools/package.json @@ -1,6 +1,6 @@ { "name": "clarity-devtools", - "version": "0.8.69", + "version": "0.8.70-beta", "private": true, "description": "Adds Clarity debugging support to browser devtools", "author": "Microsoft Corp.", @@ -24,9 +24,9 @@ "url": "https://github.com/Microsoft/clarity/issues" }, "dependencies": { - "clarity-decode": "^0.8.69", - "clarity-js": "^0.8.69", - "clarity-visualize": "^0.8.69" + "clarity-decode": "^0.8.70-beta", + "clarity-js": "^0.8.70-beta", + "clarity-visualize": "^0.8.70-beta" }, "devDependencies": { "@rollup/plugin-node-resolve": "^15.0.0", diff --git a/packages/clarity-devtools/static/manifest.json b/packages/clarity-devtools/static/manifest.json index 0d8a667b..38c7e452 100644 --- a/packages/clarity-devtools/static/manifest.json +++ b/packages/clarity-devtools/static/manifest.json @@ -2,8 +2,8 @@ "manifest_version": 3, "name": "Microsoft Clarity Developer Tools", "description": "Clarity helps you understand how users are interacting with your website.", - "version": "0.8.69", - "version_name": "0.8.69", + "version": "0.8.70", + "version_name": "0.8.70-beta", "minimum_chrome_version": "88", "devtools_page": "devtools.html", "icons": { diff --git a/packages/clarity-js/package.json b/packages/clarity-js/package.json index 4653e36f..a8d8a588 100644 --- a/packages/clarity-js/package.json +++ b/packages/clarity-js/package.json @@ -1,6 +1,6 @@ { "name": "clarity-js", - "version": "0.8.69", + "version": "0.8.70-beta", "description": "An analytics library that uses web page interactions to generate aggregated insights", "author": "Microsoft Corp.", "license": "MIT", diff --git a/packages/clarity-js/src/core/version.ts b/packages/clarity-js/src/core/version.ts index 1c290300..0fe3af37 100644 --- a/packages/clarity-js/src/core/version.ts +++ b/packages/clarity-js/src/core/version.ts @@ -1,2 +1,2 @@ -let version = "0.8.69"; +let version = "0.8.70-beta"; export default version; diff --git a/packages/clarity-js/src/layout/agent.ts b/packages/clarity-js/src/layout/agent.ts new file mode 100644 index 00000000..8158a3c2 --- /dev/null +++ b/packages/clarity-js/src/layout/agent.ts @@ -0,0 +1,24 @@ +import { AgenticBrowserSignal, Dimension } from "@clarity-types/data"; +import * as dimension from "@src/data/dimension"; + +export function detect(id: string): void { + let signal = identify(id); + if (signal) { dimension.log(Dimension.AgenticBrowserSignal, signal.toString()); } +} + +function identify(id: string): AgenticBrowserSignal { + switch (id) { + case "claude-agent-glow-border": + return AgenticBrowserSignal.ClaudeAgentGlowBorder; + case "claude-agent-glow-border-inner": + return AgenticBrowserSignal.ClaudeAgentGlowBorderInner; + case "claude-agent-stop-container": + return AgenticBrowserSignal.ClaudeAgentStopContainer; + case "claude-agent-stop-button": + return AgenticBrowserSignal.ClaudeAgentStopButton; + case "claude-phantom-cursor": + return AgenticBrowserSignal.ClaudePhantomCursor; + default: + return AgenticBrowserSignal.None; + } +} diff --git a/packages/clarity-js/src/layout/node.ts b/packages/clarity-js/src/layout/node.ts index c5d1af3c..453ae6ab 100644 --- a/packages/clarity-js/src/layout/node.ts +++ b/packages/clarity-js/src/layout/node.ts @@ -11,6 +11,7 @@ import * as interaction from "@src/interaction"; import * as mutation from "@src/layout/mutation"; import * as schema from "@src/layout/schema"; import * as custom from "@src/layout/custom"; +import * as agent from "@src/layout/agent"; import { checkDocumentStyles } from "@src/layout/style"; import { electron } from "@src/data/metadata"; @@ -94,6 +95,8 @@ export default function (node: Node, source: Source, timestamp: number): Node { let element = (node as HTMLElement); let tag = element.tagName; let attributes = getAttributes(element); + let id = attributes[Constant.Id]; + if (id) { agent.detect(id); } // In some cases, external libraries like vue-fragment, can modify parentNode property to not be in sync with the DOM // For correctness, we first look at parentElement and if it not present then fall back to using parentNode parent = node.parentElement ? node.parentElement : (node.parentNode ? node.parentNode as HTMLElement : null); diff --git a/packages/clarity-js/types/data.d.ts b/packages/clarity-js/types/data.d.ts index 061ef0c0..6efecfa6 100644 --- a/packages/clarity-js/types/data.d.ts +++ b/packages/clarity-js/types/data.d.ts @@ -183,7 +183,8 @@ export const enum Dimension { TimezoneOffset = 35, Consent = 36, InteractionNextPaint = 37, - GlobalPrivacyControl = 38 + GlobalPrivacyControl = 38, + AgenticBrowserSignal = 39 // 200-300 reserved for internal use } @@ -233,6 +234,15 @@ export const enum BooleanFlag { True = 1 } +export const enum AgenticBrowserSignal { + None = 0, + ClaudeAgentGlowBorder = 1, + ClaudeAgentGlowBorderInner = 2, + ClaudeAgentStopContainer = 3, + ClaudeAgentStopButton = 4, + ClaudePhantomCursor = 5 +} + export const enum GCMConsent { Unknown = 0, Granted = 1, diff --git a/packages/clarity-visualize/package.json b/packages/clarity-visualize/package.json index c99e491d..4b251322 100644 --- a/packages/clarity-visualize/package.json +++ b/packages/clarity-visualize/package.json @@ -1,6 +1,6 @@ { "name": "clarity-visualize", - "version": "0.8.69", + "version": "0.8.70-beta", "description": "An analytics library that uses web page interactions to generate aggregated insights", "author": "Microsoft Corp.", "license": "MIT", @@ -27,7 +27,7 @@ "url": "https://github.com/Microsoft/clarity/issues" }, "dependencies": { - "clarity-decode": "^0.8.69" + "clarity-decode": "^0.8.70-beta" }, "devDependencies": { "@rollup/plugin-commonjs": "^24.0.0", diff --git a/test/agentic-browser.test.ts b/test/agentic-browser.test.ts new file mode 100644 index 00000000..996e1afa --- /dev/null +++ b/test/agentic-browser.test.ts @@ -0,0 +1,154 @@ +import { expect, test } from "@playwright/test"; +import { decode } from "clarity-decode"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +import { pathToFileURL } from "url"; +import type { Page } from "@playwright/test"; +import type { Data } from "clarity-decode"; + +declare global { + interface Window { + clarity: (method: string, ...args: any[]) => void; + payloads: string[]; + } +} + +const AgenticBrowserSignalDimension = 39; +const Signals = { + ClaudeAgentGlowBorder: "1", + ClaudeAgentGlowBorderInner: "2", + ClaudeAgentStopContainer: "3", + ClaudeAgentStopButton: "4", + ClaudePhantomCursor: "5", +} as const; + +async function start(page: Page, markup: string = ""): Promise { + const htmlPath = resolve(__dirname, "./html/core.html"); + const htmlFileUrl = pathToFileURL(htmlPath).toString(); + const html = readFileSync(htmlPath, "utf8"); + const clarity = readFileSync(resolve(__dirname, "../packages/clarity-js/build/clarity.min.js"), "utf8"); + await page.goto(htmlFileUrl); + await page.setContent(html.replace("", `${markup} + + + `)); +} + +async function collect(page: Page): Promise { + await page.waitForTimeout(300); + await page.evaluate((): void => window.clarity("stop")); + await page.waitForFunction("window.payloads.length > 0"); + const payloads = await page.evaluate((): string[] => window.payloads); + const signals: string[] = []; + for (const payload of payloads.map((value: string): Data.DecodedPayload => decode(value))) { + for (const event of payload.dimension || []) { + if (event.data && event.data[AgenticBrowserSignalDimension]) { + signals.push(...event.data[AgenticBrowserSignalDimension]); + } + } + } + return signals; +} + +test.describe("Agentic browser markers", (): void => { + test("captures the Claude marker family", async ({ page }): Promise => { + await start(page, ` +
+
+
+ +
+ `); + + expect((await collect(page)).sort()).toEqual(Object.values(Signals).sort()); + }); + + test("captures a marker inserted after load", async ({ page }): Promise => { + await start(page); + await page.evaluate((): void => { + const marker = document.createElement("div"); + marker.id = "claude-agent-glow-border"; + document.body.appendChild(marker); + }); + + expect(await collect(page)).toEqual([Signals.ClaudeAgentGlowBorder]); + }); + + test("captures an id assigned after insertion", async ({ page }): Promise => { + await start(page); + await page.evaluate((): void => { + const marker = document.createElement("div"); + marker.id = "pending-marker"; + document.body.appendChild(marker); + }); + await page.waitForTimeout(150); + await page.evaluate((): void => { + document.getElementById("pending-marker").id = "claude-agent-stop-container"; + }); + + expect(await collect(page)).toEqual([Signals.ClaudeAgentStopContainer]); + }); + + test("retains a transient marker removed before upload", async ({ page }): Promise => { + await start(page); + await page.evaluate((): void => { + const marker = document.createElement("button"); + marker.id = "claude-agent-stop-button"; + document.body.appendChild(marker); + marker.remove(); + }); + + expect(await collect(page)).toEqual([Signals.ClaudeAgentStopButton]); + }); + + test("emits each marker once per page", async ({ page }): Promise => { + await start(page); + await page.evaluate((): void => { + for (let i = 0; i < 2; i++) { + const marker = document.createElement("div"); + marker.id = "claude-phantom-cursor"; + document.body.appendChild(marker); + } + }); + + expect(await collect(page)).toEqual([Signals.ClaudePhantomCursor]); + }); + + test("captures a marker in an open shadow root", async ({ page }): Promise => { + await start(page); + await page.evaluate((): void => { + const host = document.createElement("div"); + document.body.appendChild(host); + const marker = document.createElement("div"); + marker.id = "claude-phantom-cursor"; + host.attachShadow({ mode: "open" }).appendChild(marker); + }); + + expect(await collect(page)).toEqual([Signals.ClaudePhantomCursor]); + }); + + test("captures a marker in a same-origin iframe", async ({ page }): Promise => { + await start(page); + await page.evaluate((): void => { + const frame = document.createElement("iframe"); + frame.srcdoc = "
"; + document.body.appendChild(frame); + }); + + expect(await collect(page)).toEqual([Signals.ClaudeAgentStopButton]); + }); + + test("does not emit a dimension for similar ids", async ({ page }): Promise => { + await start(page, `
`); + + expect(await collect(page)).toEqual([]); + }); +});