diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts index d7d912901..62864a78d 100644 --- a/apps/cli/src/commands/init.ts +++ b/apps/cli/src/commands/init.ts @@ -122,9 +122,7 @@ export const runInit = async (options: InitOptions = {}) => { `Installed! ${highlighter.info("expect-cli")} is now available globally.`, ); } else { - globalSpinner.warn( - `Installed, but ${highlighter.info("expect-cli")} is not on your PATH.`, - ); + globalSpinner.warn(`Installed, but ${highlighter.info("expect-cli")} is not on your PATH.`); const globalPrefix = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], diff --git a/apps/cli/src/index.tsx b/apps/cli/src/index.tsx index 30b71e225..12bd05c7b 100644 --- a/apps/cli/src/index.tsx +++ b/apps/cli/src/index.tsx @@ -19,11 +19,16 @@ import { renderApp } from "./program"; import { CI_EXECUTION_TIMEOUT_MS, VERSION, VERSION_API_URL } from "./constants"; import { prompts } from "./utils/prompts"; import { highlighter } from "./utils/highlighter"; +import { machineId } from "@expect/shared/machine-id"; import { logger } from "./utils/logger"; -try { - fetch(`${VERSION_API_URL}?source=cli&t=${Date.now()}`).catch(() => {}); -} catch {} +if (!isRunningInAgent() && !isHeadless()) { + machineId() + .catch(() => "unknown") + .then((mid) => { + fetch(`${VERSION_API_URL}?source=cli&mid=${mid}&t=${Date.now()}`).catch(() => {}); + }); +} const DEFAULT_INSTRUCTION = "Test all changes from main in the browser and verify they work correctly."; diff --git a/apps/cli/tests/add-skill.test.ts b/apps/cli/tests/add-skill.test.ts index db25f2311..799752b26 100644 --- a/apps/cli/tests/add-skill.test.ts +++ b/apps/cli/tests/add-skill.test.ts @@ -92,9 +92,7 @@ describe("extractTarEntries", () => { }); it("creates nested directories", () => { - const tar = buildTar([ - { name: "prefix/sub/dir/file.txt", content: "nested" }, - ]); + const tar = buildTar([{ name: "prefix/sub/dir/file.txt", content: "nested" }]); extractTarEntries(tar, "prefix/", destDir); diff --git a/apps/website/app/llms.txt/route.ts b/apps/website/app/llms.txt/route.ts index 4dae41ea7..2b0b451e2 100644 --- a/apps/website/app/llms.txt/route.ts +++ b/apps/website/app/llms.txt/route.ts @@ -1,14 +1,22 @@ -import { readFileSync } from "fs"; +import * as fs from "fs"; import { NextResponse } from "next/server"; -import { join } from "path"; +import * as path from "path"; -const skill = readFileSync( - join(process.cwd(), "..", "..", "packages", "expect-skill", "SKILL.md"), - "utf-8", -).replace(/^---[\s\S]*?---\n+/, ""); +const root = path.join(process.cwd(), "..", ".."); + +const readme = fs + .readFileSync(path.join(root, "README.md"), "utf-8") + .replace(/^# ]*\/>\s*/m, "# ") + .replace(/\[!\[.*?\]\(.*?\)\]\(.*?\)\n?/g, ""); + +const skill = fs + .readFileSync(path.join(root, "packages", "expect-skill", "SKILL.md"), "utf-8") + .replace(/^---[\s\S]*?---\n+/, ""); + +const content = `${readme.trim()}\n\n---\n\n${skill.trim()}\n`; export const GET = () => - new NextResponse(skill, { + new NextResponse(content, { headers: { "Content-Type": "text/markdown; charset=utf-8", "Cache-Control": "public, max-age=86400", diff --git a/packages/browser/package.json b/packages/browser/package.json index 9b030d68b..60d1c0956 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -30,10 +30,12 @@ "effect": "4.0.0-beta.35", "playwright": "^1.52.0", "rrweb": "^2.0.0-alpha.18", + "which": "^6.0.1", "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^22.15.0", + "@types/which": "^3.0.4", "esbuild": "^0.25.0", "typescript": "^5.7.0" } diff --git a/packages/browser/src/browser.ts b/packages/browser/src/browser.ts index ecc74dec0..a27fa68f2 100644 --- a/packages/browser/src/browser.ts +++ b/packages/browser/src/browser.ts @@ -1,12 +1,13 @@ import { Browsers, Cookies, layerLive, browserKeyOf, Cookie } from "@expect/cookies"; import type { Browser as BrowserProfile } from "@expect/cookies"; import { chromium, webkit, firefox } from "playwright"; -import type { Locator, Page } from "playwright"; +import type { Browser as PlaywrightBrowser, Locator, Page } from "playwright"; import type { BrowserEngine } from "./types"; import { Array as Arr, Effect, Layer, Option, ServiceMap } from "effect"; const cookiesLayer = Layer.mergeAll(layerLive, Cookies.layer); import { + CDP_DISCOVERY_TIMEOUT_MS, CONTENT_ROLES, HEADLESS_CHROMIUM_ARGS, INTERACTIVE_ROLES, @@ -24,6 +25,7 @@ import { NavigationError, SnapshotTimeoutError, } from "./errors"; +import { autoDiscoverCdp } from "./cdp-discovery"; import { toActionError } from "./utils/action-error"; import { compactTree } from "./utils/compact-tree"; import { createLocator } from "./utils/create-locator"; @@ -194,32 +196,72 @@ export class Browser extends ServiceMap.Service()("@browser/Browser", { options: CreatePageOptions = {}, ) { const engine = options.browserType ?? "chromium"; + const useLiveChrome = options.liveChrome && engine === "chromium" && !options.cdpUrl; yield* Effect.annotateCurrentSpan({ url: url ?? "about:blank", cdp: Boolean(options.cdpUrl), + liveChrome: Boolean(useLiveChrome), browserType: engine, }); + const liveBrowser = yield* Effect.gen(function* () { + if (!useLiveChrome) return Option.none(); + + const discovered = yield* autoDiscoverCdp().pipe( + Effect.map(Option.some), + Effect.catchTag("CdpDiscoveryError", () => Effect.succeed(Option.none())), + ); + + if (Option.isNone(discovered)) { + yield* Effect.logDebug("No running Chrome found, falling back to bundled Chromium"); + return Option.none(); + } + + const connectedBrowser = yield* Effect.tryPromise(() => + chromium.connectOverCDP(discovered.value, { timeout: CDP_DISCOVERY_TIMEOUT_MS }), + ).pipe( + Effect.map(Option.some), + Effect.catchTag("UnknownError", () => Effect.succeed(Option.none())), + ); + + if (Option.isNone(connectedBrowser)) { + yield* Effect.logDebug( + "Failed to connect to discovered Chrome, falling back to bundled Chromium", + { + wsUrl: discovered.value, + }, + ); + return Option.none(); + } + + yield* Effect.logInfo("Connected to live Chrome", { wsUrl: discovered.value }); + return connectedBrowser; + }); + + const isExternalBrowser = Option.isSome(liveBrowser); const cdpEndpoint = engine === "chromium" ? options.cdpUrl : undefined; + const browserType = resolveBrowserType(engine); - const browser = cdpEndpoint - ? yield* Effect.tryPromise({ - try: () => chromium.connectOverCDP(cdpEndpoint), - catch: (cause) => - new CdpConnectionError({ - endpointUrl: cdpEndpoint, - cause: cause instanceof Error ? cause.message : String(cause), - }), - }) - : yield* Effect.tryPromise({ - try: () => - browserType.launch({ - headless: !options.headed, - executablePath: options.executablePath, - args: engine === "chromium" && !options.headed ? HEADLESS_CHROMIUM_ARGS : [], - }), - catch: toBrowserLaunchError, - }); + const browser = Option.isSome(liveBrowser) + ? liveBrowser.value + : cdpEndpoint + ? yield* Effect.tryPromise({ + try: () => chromium.connectOverCDP(cdpEndpoint), + catch: (cause) => + new CdpConnectionError({ + endpointUrl: cdpEndpoint, + cause: cause instanceof Error ? cause.message : String(cause), + }), + }) + : yield* Effect.tryPromise({ + try: () => + browserType.launch({ + headless: !options.headed, + executablePath: options.executablePath, + args: engine === "chromium" && !options.headed ? HEADLESS_CHROMIUM_ARGS : [], + }), + catch: toBrowserLaunchError, + }); const setupPage = Effect.gen(function* () { const defaultBrowserContext = @@ -243,7 +285,7 @@ export class Browser extends ServiceMap.Service()("@browser/Browser", { }; } - const isCdpConnected = Boolean(cdpEndpoint); + const isCdpConnected = isExternalBrowser || Boolean(cdpEndpoint); const existingContexts = isCdpConnected ? browser.contexts() : []; const context = existingContexts.length > 0 @@ -282,13 +324,13 @@ export class Browser extends ServiceMap.Service()("@browser/Browser", { } const existingPages = context.pages(); - const page = - isCdpConnected && existingPages.length > 0 - ? existingPages[0]! - : yield* Effect.tryPromise({ - try: () => context.newPage(), - catch: toBrowserLaunchError, - }); + const reuseExistingPage = isCdpConnected && existingPages.length > 0 && !isExternalBrowser; + const page = reuseExistingPage + ? existingPages[0]! + : yield* Effect.tryPromise({ + try: () => context.newPage(), + catch: toBrowserLaunchError, + }); if (url) { yield* Effect.tryPromise({ @@ -301,12 +343,12 @@ export class Browser extends ServiceMap.Service()("@browser/Browser", { }); } - return { browser, context, page }; + return { browser, context, page, cleanup: Effect.void, isExternalBrowser }; }); return yield* setupPage.pipe( Effect.tapError(() => { - if (cdpEndpoint) return Effect.void; + if (isExternalBrowser) return Effect.void; return Effect.tryPromise(() => browser.close()).pipe( Effect.catchTag("UnknownError", () => Effect.void), ); diff --git a/packages/browser/src/cdp-discovery.ts b/packages/browser/src/cdp-discovery.ts index da4dc0467..a75618b95 100644 --- a/packages/browser/src/cdp-discovery.ts +++ b/packages/browser/src/cdp-discovery.ts @@ -5,6 +5,7 @@ import net from "node:net"; import { Effect, Option } from "effect"; import { CDP_DISCOVERY_TIMEOUT_MS, CDP_COMMON_PORTS, CDP_PORT_PROBE_TIMEOUT_MS } from "./constants"; import { CdpDiscoveryError } from "./errors"; +import { parseDevToolsActivePort } from "./utils/parse-devtools-active-port"; interface VersionInfo { readonly webSocketDebuggerUrl?: string; @@ -105,9 +106,6 @@ export const discoverCdpUrl = Effect.fn("discoverCdpUrl")(function* (host: strin const listResult = yield* tryDiscover(discoverViaJsonList(host, port)); if (Option.isSome(listResult)) return listResult.value; - const reachable = yield* isPortReachable(host, port); - if (reachable) return `ws://${host}:${port}/devtools/browser`; - return yield* new CdpDiscoveryError({ cause: `All CDP discovery methods failed for ${host}:${port}`, }); @@ -126,6 +124,7 @@ const getChromeUserDataDirs = () => { path.join(base, "BraveSoftware", "Brave-Browser"), path.join(base, "Microsoft Edge"), path.join(base, "Arc", "User Data"), + path.join(base, "net.imput.helium"), ]; } @@ -164,21 +163,13 @@ const readDevToolsActivePort = (userDataDir: string) => }), }).pipe( Effect.flatMap((content) => { - const lines = content.trim().split("\n"); - const portStr = lines[0]?.trim(); - if (!portStr) { - return new CdpDiscoveryError({ - cause: `Empty DevToolsActivePort in ${userDataDir}`, - }).asEffect(); - } - const port = Number.parseInt(portStr, 10); - if (Number.isNaN(port)) { + const parsed = parseDevToolsActivePort(content); + if (!parsed) { return new CdpDiscoveryError({ - cause: `Invalid port in DevToolsActivePort: ${portStr}`, + cause: `Invalid DevToolsActivePort in ${userDataDir}`, }).asEffect(); } - const wsPath = lines[1]?.trim() ?? "/devtools/browser"; - return Effect.succeed({ port, wsPath }); + return Effect.succeed(parsed); }), ); diff --git a/packages/browser/src/chrome-launcher.ts b/packages/browser/src/chrome-launcher.ts new file mode 100644 index 000000000..9e1620c26 --- /dev/null +++ b/packages/browser/src/chrome-launcher.ts @@ -0,0 +1,298 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import which from "which"; +import { Effect } from "effect"; +import { ChromeNotFoundError, ChromeLaunchTimeoutError } from "./errors"; +import { parseDevToolsActivePort } from "./utils/parse-devtools-active-port"; +import { + CDP_LAUNCH_TIMEOUT_MS, + CDP_POLL_INTERVAL_MS, + HEADLESS_CHROME_WINDOW_HEIGHT_PX, + HEADLESS_CHROME_WINDOW_WIDTH_PX, +} from "./constants"; + +interface ChromeProcess { + readonly process: ChildProcess; + readonly wsUrl: string; + readonly userDataDir: string; + readonly tempUserDataDir: string | undefined; +} + +const SYSTEM_CHROME_PATHS_DARWIN = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Arc.app/Contents/MacOS/Arc", + "/Applications/Helium.app/Contents/MacOS/Helium", +] as const; + +const SYSTEM_CHROME_NAMES_LINUX = [ + "google-chrome", + "google-chrome-stable", + "chromium-browser", + "chromium", + "brave-browser", + "brave-browser-stable", + "microsoft-edge", +] as const; + +export const findSystemChrome = Effect.fn("findSystemChrome")(function* () { + const platform = os.platform(); + + if (platform === "darwin") { + for (const candidate of SYSTEM_CHROME_PATHS_DARWIN) { + if (fs.existsSync(candidate)) { + yield* Effect.logDebug("Found system Chrome", { path: candidate }); + return candidate; + } + } + } + + if (platform === "linux") { + for (const name of SYSTEM_CHROME_NAMES_LINUX) { + const resolved = which.sync(name, { nothrow: true }); + if (resolved) { + yield* Effect.logDebug("Found system Chrome", { path: resolved }); + return resolved; + } + } + } + + if (platform === "win32") { + // HACK: process.env is used for Windows system path discovery — these are + // OS-level paths, not app configuration + const localAppData = process.env["LOCALAPPDATA"]; + const programFiles = process.env["PROGRAMFILES"]; + const programFilesX86 = process.env["PROGRAMFILES(X86)"]; + + const candidates = [ + localAppData && path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"), + localAppData && + path.join(localAppData, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + localAppData && path.join(localAppData, "Microsoft", "Edge", "Application", "msedge.exe"), + programFiles && path.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"), + programFilesX86 && + path.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"), + programFiles && path.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"), + programFilesX86 && + path.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"), + ].filter(Boolean) as string[]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + yield* Effect.logDebug("Found system Chrome", { path: candidate }); + return candidate; + } + } + } + + return yield* new ChromeNotFoundError(); +}); + +const readDevToolsActivePort = (userDataDir: string) => { + const filePath = path.join(userDataDir, "DevToolsActivePort"); + try { + return parseDevToolsActivePort(fs.readFileSync(filePath, "utf-8")); + } catch { + return undefined; + } +}; + +const buildLaunchArgs = (options: { headless: boolean; userDataDir: string }): string[] => { + const args = [ + "--remote-debugging-port=0", + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "--disable-backgrounding-occluded-windows", + "--disable-component-update", + "--disable-default-apps", + "--disable-hang-monitor", + "--disable-popup-blocking", + "--disable-prompt-on-repost", + "--disable-sync", + "--disable-features=Translate", + "--enable-features=NetworkService,NetworkServiceInProcess", + "--metrics-recording-only", + "--password-store=basic", + "--use-mock-keychain", + `--user-data-dir=${options.userDataDir}`, + ]; + + if (options.headless) { + args.push( + "--headless=new", + "--enable-unsafe-swiftshader", + `--window-size=${HEADLESS_CHROME_WINDOW_WIDTH_PX},${HEADLESS_CHROME_WINDOW_HEIGHT_PX}`, + ); + } + + if (isContainerEnvironment()) { + args.push("--no-sandbox", "--disable-dev-shm-usage"); + } + + return args; +}; + +// HACK: process.env["CI"] is used for container detection — runtime environment +// introspection, not app configuration +const isContainerEnvironment = (): boolean => { + if (process.env["CI"]) return true; + if (os.platform() !== "linux") return false; + if (process.getuid?.() === 0) return true; + if (fs.existsSync("/.dockerenv")) return true; + if (fs.existsSync("/run/.containerenv")) return true; + try { + const cgroup = fs.readFileSync("/proc/1/cgroup", "utf-8"); + return cgroup.includes("docker") || cgroup.includes("kubepods") || cgroup.includes("lxc"); + } catch { + return false; + } +}; + +const cleanupFailedLaunch = (child: ChildProcess, tempDir: string | undefined) => + Effect.gen(function* () { + yield* Effect.sync(() => child.kill()).pipe( + Effect.catchCause((cause) => + Effect.logDebug("Failed to kill Chrome process during cleanup", { cause }), + ), + ); + if (tempDir) { + yield* Effect.sync(() => fs.rmSync(tempDir, { recursive: true, force: true })).pipe( + Effect.catchCause((cause) => + Effect.logDebug("Failed to remove temp dir during cleanup", { cause }), + ), + ); + } + }); + +export const launchSystemChrome = Effect.fn("launchSystemChrome")(function* (options: { + headless: boolean; + profilePath?: string; +}) { + const chromePath = yield* findSystemChrome(); + + let tempDir: string | undefined; + if (!options.profilePath) { + tempDir = yield* Effect.tryPromise(() => + fs.promises.mkdtemp(path.join(os.tmpdir(), "expect-chrome-")), + ).pipe(Effect.catchTag("UnknownError", Effect.die)); + } + + const userDataDir = options.profilePath ?? tempDir!; + + yield* Effect.tryPromise(() => + fs.promises.rm(path.join(userDataDir, "DevToolsActivePort"), { force: true }), + ).pipe(Effect.catchTag("UnknownError", () => Effect.void)); + + const args = buildLaunchArgs({ headless: options.headless, userDataDir }); + + yield* Effect.logInfo("Launching system Chrome", { chromePath, userDataDir }); + + const child = yield* Effect.try({ + try: (): ChildProcess => + spawn(chromePath, args, { + stdio: ["ignore", "ignore", "pipe"], + detached: false, + }), + catch: () => new ChromeNotFoundError(), + }); + + const wsUrl = yield* Effect.callback((resume) => { + const deadline = Date.now() + CDP_LAUNCH_TIMEOUT_MS; + let pendingTimer: ReturnType | undefined; + let settled = false; + + const settle = (effect: Effect.Effect) => { + if (settled) return; + settled = true; + if (pendingTimer !== undefined) clearTimeout(pendingTimer); + child.removeListener("error", onSpawnError); + resume(effect); + }; + + const onSpawnError = (error: Error) => { + settle( + Effect.fail( + new ChromeLaunchTimeoutError({ + timeoutMs: CDP_LAUNCH_TIMEOUT_MS, + cause: `Chrome process error: ${error.message}`, + }), + ), + ); + }; + + child.on("error", onSpawnError); + + const poll = () => { + if (Date.now() > deadline) { + settle( + Effect.fail( + new ChromeLaunchTimeoutError({ + timeoutMs: CDP_LAUNCH_TIMEOUT_MS, + cause: "Timed out waiting for DevToolsActivePort", + }), + ), + ); + return; + } + + const result = readDevToolsActivePort(userDataDir); + if (result) { + settle(Effect.succeed(`ws://127.0.0.1:${result.port}${result.wsPath}`)); + return; + } + + if (child.exitCode !== null) { + settle( + Effect.fail( + new ChromeLaunchTimeoutError({ + timeoutMs: CDP_LAUNCH_TIMEOUT_MS, + cause: `Chrome exited with code ${child.exitCode} before providing CDP URL`, + }), + ), + ); + return; + } + + pendingTimer = setTimeout(poll, CDP_POLL_INTERVAL_MS); + }; + + poll(); + + return Effect.sync(() => { + settled = true; + if (pendingTimer !== undefined) clearTimeout(pendingTimer); + child.removeListener("error", onSpawnError); + }); + }).pipe(Effect.tapError(() => cleanupFailedLaunch(child, tempDir))); + + yield* Effect.logInfo("System Chrome launched, CDP available", { wsUrl }); + + return { + process: child, + wsUrl, + userDataDir, + tempUserDataDir: tempDir, + } satisfies ChromeProcess; +}); + +export const killChromeProcess = (chrome: ChromeProcess) => + Effect.gen(function* () { + yield* Effect.sync(() => chrome.process.kill()).pipe( + Effect.catchCause((cause) => Effect.logDebug("Failed to kill Chrome process", { cause })), + ); + if (chrome.tempUserDataDir) { + yield* Effect.sync(() => + fs.rmSync(chrome.tempUserDataDir!, { recursive: true, force: true }), + ).pipe( + Effect.catchCause((cause) => + Effect.logDebug("Failed to remove temp user data dir", { cause }), + ), + ); + } + }); diff --git a/packages/browser/src/constants.ts b/packages/browser/src/constants.ts index 10516037f..7b846134e 100644 --- a/packages/browser/src/constants.ts +++ b/packages/browser/src/constants.ts @@ -57,3 +57,8 @@ export const REPLAY_PLAYER_HEIGHT_PX = 540; export const CDP_DISCOVERY_TIMEOUT_MS = 2_000; export const CDP_PORT_PROBE_TIMEOUT_MS = 500; export const CDP_COMMON_PORTS = [9222, 9229] as const; +export const CDP_LAUNCH_TIMEOUT_MS = 30_000; +export const CDP_POLL_INTERVAL_MS = 50; + +export const HEADLESS_CHROME_WINDOW_WIDTH_PX = 1280; +export const HEADLESS_CHROME_WINDOW_HEIGHT_PX = 720; diff --git a/packages/browser/src/errors.ts b/packages/browser/src/errors.ts index 144569b81..64fec9211 100644 --- a/packages/browser/src/errors.ts +++ b/packages/browser/src/errors.ts @@ -116,6 +116,25 @@ export class CdpConnectionError extends Schema.ErrorClass("C message = `Failed to connect to CDP endpoint ${this.endpointUrl}: ${this.cause}`; } +export class ChromeNotFoundError extends Schema.ErrorClass( + "ChromeNotFoundError", +)({ + _tag: Schema.tag("ChromeNotFoundError"), +}) { + message = + "No system Chrome installation found. Install Google Chrome or pass an explicit executable path."; +} + +export class ChromeLaunchTimeoutError extends Schema.ErrorClass( + "ChromeLaunchTimeoutError", +)({ + _tag: Schema.tag("ChromeLaunchTimeoutError"), + timeoutMs: Schema.Number, + cause: Schema.String, +}) { + message = `Chrome launch failed (timeout ${this.timeoutMs}ms): ${this.cause}`; +} + export type ActionError = | RefAmbiguousError | RefBlockedError diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 40742be02..d9634d818 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -16,6 +16,8 @@ export { BrowserLaunchError, CdpConnectionError, CdpDiscoveryError, + ChromeNotFoundError, + ChromeLaunchTimeoutError, NavigationError, RecorderInjectionError, RefAmbiguousError, diff --git a/packages/browser/src/mcp/mcp-session.ts b/packages/browser/src/mcp/mcp-session.ts index 31ec141df..9191923ed 100644 --- a/packages/browser/src/mcp/mcp-session.ts +++ b/packages/browser/src/mcp/mcp-session.ts @@ -44,6 +44,8 @@ export interface BrowserSessionData { readonly browser: PlaywrightBrowser; readonly context: BrowserContext; readonly page: Page; + readonly cleanup: Effect.Effect; + readonly isExternalBrowser: boolean; readonly consoleMessages: ConsoleEntry[]; readonly networkRequests: NetworkEntry[]; readonly replayOutputPath: string | undefined; @@ -58,10 +60,12 @@ export interface OpenOptions { waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit"; cdpUrl?: string; browserType?: BrowserEngine; + liveChrome?: boolean; } export interface OpenResult { readonly injectedCookieCount: number; + readonly isExternalBrowser: boolean; } export interface CloseResult { @@ -225,12 +229,15 @@ export class McpSession extends ServiceMap.Service()("@browser/McpSe videoOutputDir, cdpUrl: options.cdpUrl ?? defaultCdpUrl, browserType: options.browserType, + liveChrome: options.liveChrome, }); const sessionData: BrowserSessionData = { browser: pageResult.browser, context: pageResult.context, page: pageResult.page, + cleanup: pageResult.cleanup, + isExternalBrowser: pageResult.isExternalBrowser, consoleMessages: [], networkRequests: [], replayOutputPath: Option.getOrUndefined(replayOutputPath), @@ -301,7 +308,10 @@ export class McpSession extends ServiceMap.Service()("@browser/McpSe ), ); - return { injectedCookieCount } satisfies OpenResult; + return { + injectedCookieCount, + isExternalBrowser: pageResult.isExternalBrowser, + } satisfies OpenResult; }); const snapshot = Effect.fn("McpSession.snapshot")(function* ( @@ -508,8 +518,20 @@ export class McpSession extends ServiceMap.Service()("@browser/McpSe Effect.catchCause((cause) => Effect.logDebug("Failed during close cleanup", { cause })), ); - yield* Effect.tryPromise(() => activeSession.browser.close()).pipe( - Effect.catchCause((cause) => Effect.logDebug("Failed to close browser", { cause })), + if (activeSession.isExternalBrowser) { + yield* Effect.tryPromise(() => activeSession.page.close()).pipe( + Effect.catchCause((cause) => Effect.logDebug("Failed to close page", { cause })), + ); + } else { + yield* Effect.tryPromise(() => activeSession.browser.close()).pipe( + Effect.catchCause((cause) => Effect.logDebug("Failed to close browser", { cause })), + ); + } + + yield* activeSession.cleanup.pipe( + Effect.catchCause((cause) => + Effect.logDebug("Failed to clean up Chrome process", { cause }), + ), ); if (pageVideo) { diff --git a/packages/browser/src/mcp/server.ts b/packages/browser/src/mcp/server.ts index fb8bc6be9..d296b5748 100644 --- a/packages/browser/src/mcp/server.ts +++ b/packages/browser/src/mcp/server.ts @@ -8,7 +8,6 @@ import { evaluateRuntime } from "../utils/evaluate-runtime"; import { runAccessibilityAudit } from "../accessibility"; import { formatPerformanceTrace } from "../performance-trace"; import { McpSession } from "./mcp-session"; -import { autoDiscoverCdp } from "../cdp-discovery"; import { DUPLICATE_REQUEST_WINDOW_MS } from "./constants"; const textResult = (text: string) => ({ @@ -71,7 +70,7 @@ export const createBrowserMcpServer = ( .string() .optional() .describe( - "CDP WebSocket endpoint URL to connect to an existing Chrome instance (e.g. 'ws://localhost:9222/devtools/browser/...'). Use 'auto' to auto-discover a running Chrome.", + "CDP WebSocket endpoint URL to connect to an existing Chrome instance (e.g. 'ws://localhost:9222/devtools/browser/...').", ), browser: z .enum(["chromium", "webkit", "firefox"]) @@ -79,9 +78,15 @@ export const createBrowserMcpServer = ( .describe( "Browser engine to launch (default: chromium). Use 'webkit' for Safari-like testing or 'firefox' for Firefox testing. CDP connections are only supported with chromium.", ), + liveChrome: z + .boolean() + .optional() + .describe( + "Connect to the user's already-running Chrome browser instead of launching Playwright's bundled Chromium. The user must have Chrome open with remote debugging enabled. Falls back to bundled Chromium if no running Chrome is found. Ignored when 'cdp' is provided.", + ), }, }, - ({ url, headed, cookies, waitUntil, cdp, browser: browserType }) => + ({ url, headed, cookies, waitUntil, cdp, browser: browserType, liveChrome }) => runMcp( Effect.gen(function* () { const session = yield* McpSession; @@ -91,25 +96,19 @@ export const createBrowserMcpServer = ( return textResult(`Navigated to ${url}`); } - let cdpUrl: string | undefined; - if (cdp === "auto") { - cdpUrl = yield* autoDiscoverCdp(); - yield* Effect.logInfo("Auto-discovered CDP endpoint", { cdpUrl }); - } else if (cdp) { - cdpUrl = cdp; - } - const result = yield* session.open(url, { headed, cookies, waitUntil, - cdpUrl, + cdpUrl: cdp, browserType, + liveChrome, }); const engineSuffix = browserType && browserType !== "chromium" ? ` [${browserType}]` : ""; - const cdpSuffix = cdpUrl ? ` (connected via CDP: ${cdpUrl})` : ""; + const cdpSuffix = cdp ? ` (connected via CDP: ${cdp})` : ""; + const chromeSuffix = liveChrome && result.isExternalBrowser ? " (live Chrome)" : ""; return textResult( - `Opened ${url}${engineSuffix}${cdpSuffix}` + + `Opened ${url}${engineSuffix}${cdpSuffix}${chromeSuffix}` + (result.injectedCookieCount > 0 ? ` (${result.injectedCookieCount} cookies synced from local browser)` : ""), diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index 209d7b990..2b174f6e4 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -54,6 +54,7 @@ export interface CreatePageOptions { videoOutputDir?: string; cdpUrl?: string; browserType?: BrowserEngine; + liveChrome?: boolean; } export interface AnnotatedScreenshotOptions extends SnapshotOptions { diff --git a/packages/browser/src/utils/parse-devtools-active-port.ts b/packages/browser/src/utils/parse-devtools-active-port.ts new file mode 100644 index 000000000..e1f46cdb8 --- /dev/null +++ b/packages/browser/src/utils/parse-devtools-active-port.ts @@ -0,0 +1,14 @@ +interface DevToolsActivePort { + readonly port: number; + readonly wsPath: string; +} + +export const parseDevToolsActivePort = (content: string): DevToolsActivePort | undefined => { + const lines = content.trim().split("\n"); + const portStr = lines[0]?.trim(); + if (!portStr) return undefined; + const port = Number.parseInt(portStr, 10); + if (Number.isNaN(port)) return undefined; + const wsPath = lines[1]?.trim() ?? "/devtools/browser"; + return { port, wsPath }; +}; diff --git a/packages/browser/tests/chrome-launcher.test.ts b/packages/browser/tests/chrome-launcher.test.ts new file mode 100644 index 000000000..d5c5a5081 --- /dev/null +++ b/packages/browser/tests/chrome-launcher.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ChildProcess } from "node:child_process"; + +const { platformMock, existsSyncMock, rmSyncMock, whichSyncMock } = vi.hoisted(() => ({ + platformMock: vi.fn(), + existsSyncMock: vi.fn(), + rmSyncMock: vi.fn(), + whichSyncMock: vi.fn(), +})); + +vi.mock("node:os", async (importOriginal) => { + const original = await importOriginal(); + return { default: { ...original, platform: platformMock } }; +}); + +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal(); + return { default: { ...original, existsSync: existsSyncMock, rmSync: rmSyncMock } }; +}); + +vi.mock("which", () => ({ + default: { sync: whichSyncMock }, +})); + +import { Effect } from "effect"; +import { findSystemChrome, killChromeProcess } from "../src/chrome-launcher"; +import { ChromeNotFoundError } from "../src/errors"; + +describe("findSystemChrome", () => { + beforeEach(() => { + vi.clearAllMocks(); + existsSyncMock.mockReturnValue(false); + whichSyncMock.mockReturnValue(null); + }); + + it("finds Google Chrome on macOS", async () => { + platformMock.mockReturnValue("darwin"); + existsSyncMock.mockImplementation( + (filePath: string) => + filePath === "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + ); + + const result = await Effect.runPromise(findSystemChrome()); + + expect(result).toBe("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"); + }); + + it("returns first available browser on macOS", async () => { + platformMock.mockReturnValue("darwin"); + existsSyncMock.mockImplementation( + (filePath: string) => + filePath === "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + ); + + const result = await Effect.runPromise(findSystemChrome()); + + expect(result).toBe("/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"); + }); + + it("finds Chrome on Linux via which", async () => { + platformMock.mockReturnValue("linux"); + whichSyncMock.mockImplementation((name: string) => + name === "google-chrome" ? "/usr/bin/google-chrome" : null, + ); + + const result = await Effect.runPromise(findSystemChrome()); + + expect(result).toBe("/usr/bin/google-chrome"); + expect(whichSyncMock).toHaveBeenCalledWith("google-chrome", { nothrow: true }); + }); + + it("tries candidates in priority order on Linux", async () => { + platformMock.mockReturnValue("linux"); + whichSyncMock.mockImplementation((name: string) => + name === "chromium" ? "/usr/bin/chromium" : null, + ); + + const result = await Effect.runPromise(findSystemChrome()); + + expect(result).toBe("/usr/bin/chromium"); + expect(whichSyncMock).toHaveBeenCalledWith("google-chrome", { nothrow: true }); + expect(whichSyncMock).toHaveBeenCalledWith("google-chrome-stable", { nothrow: true }); + expect(whichSyncMock).toHaveBeenCalledWith("chromium-browser", { nothrow: true }); + expect(whichSyncMock).toHaveBeenCalledWith("chromium", { nothrow: true }); + }); + + it("fails with ChromeNotFoundError when no browser is found on macOS", async () => { + platformMock.mockReturnValue("darwin"); + + const error = await Effect.runPromise(Effect.flip(findSystemChrome())); + + expect(error).toBeInstanceOf(ChromeNotFoundError); + }); + + it("fails with ChromeNotFoundError when no browser is found on Linux", async () => { + platformMock.mockReturnValue("linux"); + + const error = await Effect.runPromise(Effect.flip(findSystemChrome())); + + expect(error).toBeInstanceOf(ChromeNotFoundError); + }); + + it("fails with ChromeNotFoundError on unsupported platform", async () => { + platformMock.mockReturnValue("freebsd"); + + const error = await Effect.runPromise(Effect.flip(findSystemChrome())); + + expect(error).toBeInstanceOf(ChromeNotFoundError); + }); +}); + +describe("killChromeProcess", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("kills process and removes temp dir", async () => { + const killMock = vi.fn(() => true); + const chrome = { + process: { kill: killMock } as unknown as ChildProcess, + wsUrl: "ws://127.0.0.1:9222/devtools/browser/abc", + userDataDir: "/tmp/expect-chrome-abc123", + tempUserDataDir: "/tmp/expect-chrome-abc123", + }; + + await Effect.runPromise(killChromeProcess(chrome)); + + expect(killMock).toHaveBeenCalledOnce(); + expect(rmSyncMock).toHaveBeenCalledWith("/tmp/expect-chrome-abc123", { + recursive: true, + force: true, + }); + }); + + it("skips temp dir removal when no temp dir exists", async () => { + const killMock = vi.fn(() => true); + const chrome = { + process: { kill: killMock } as unknown as ChildProcess, + wsUrl: "ws://127.0.0.1:9222/devtools/browser/abc", + userDataDir: "/home/user/.config/google-chrome", + tempUserDataDir: undefined, + }; + + await Effect.runPromise(killChromeProcess(chrome)); + + expect(killMock).toHaveBeenCalledOnce(); + expect(rmSyncMock).not.toHaveBeenCalled(); + }); + + it("continues gracefully when process.kill throws", async () => { + const killMock = vi.fn(() => { + throw new Error("No such process"); + }); + const chrome = { + process: { kill: killMock } as unknown as ChildProcess, + wsUrl: "ws://127.0.0.1:9222/devtools/browser/abc", + userDataDir: "/tmp/expect-chrome-abc123", + tempUserDataDir: "/tmp/expect-chrome-abc123", + }; + + await Effect.runPromise(killChromeProcess(chrome)); + + expect(killMock).toHaveBeenCalledOnce(); + expect(rmSyncMock).toHaveBeenCalledOnce(); + }); + + it("continues gracefully when rmSync throws", async () => { + const killMock = vi.fn(() => true); + rmSyncMock.mockImplementation(() => { + throw new Error("EPERM"); + }); + const chrome = { + process: { kill: killMock } as unknown as ChildProcess, + wsUrl: "ws://127.0.0.1:9222/devtools/browser/abc", + userDataDir: "/tmp/expect-chrome-abc123", + tempUserDataDir: "/tmp/expect-chrome-abc123", + }; + + await Effect.runPromise(killChromeProcess(chrome)); + + expect(killMock).toHaveBeenCalledOnce(); + expect(rmSyncMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/browser/tests/create-page.test.ts b/packages/browser/tests/create-page.test.ts index 1501e4619..0de0eef32 100644 --- a/packages/browser/tests/create-page.test.ts +++ b/packages/browser/tests/create-page.test.ts @@ -5,6 +5,7 @@ const { browserListMock, cookieExtractMock, launchMock, + connectOverCDPMock, newContextMock, addCookiesMock, addInitScriptMock, @@ -12,11 +13,13 @@ const { newPageMock, gotoMock, closeMock, + autoDiscoverCdpMock, } = vi.hoisted(() => ({ defaultBrowserMock: vi.fn(), browserListMock: vi.fn(), cookieExtractMock: vi.fn(), launchMock: vi.fn(), + connectOverCDPMock: vi.fn(), newContextMock: vi.fn(), addCookiesMock: vi.fn(), addInitScriptMock: vi.fn(), @@ -24,6 +27,7 @@ const { newPageMock: vi.fn(), gotoMock: vi.fn(), closeMock: vi.fn(), + autoDiscoverCdpMock: vi.fn(), })); vi.mock("@expect/cookies", async () => { @@ -58,6 +62,7 @@ const firefoxLaunchMock = vi.hoisted(() => vi.fn()); vi.mock("playwright", () => ({ chromium: { launch: launchMock, + connectOverCDP: connectOverCDPMock, }, webkit: { launch: webkitLaunchMock, @@ -67,6 +72,10 @@ vi.mock("playwright", () => ({ }, })); +vi.mock("../src/cdp-discovery", () => ({ + autoDiscoverCdp: autoDiscoverCdpMock, +})); + import { Effect, Option } from "effect"; import { runBrowser } from "../src/browser"; @@ -309,3 +318,130 @@ describe("Browser.createPage browserType", () => { expect(webkitLaunchMock).toHaveBeenCalledWith(expect.objectContaining({ args: [] })); }); }); + +describe("Browser.createPage liveChrome", () => { + const existingPage = { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(undefined), + }; + + beforeEach(() => { + vi.clearAllMocks(); + + gotoMock.mockResolvedValue(undefined); + newPageMock.mockResolvedValue({ goto: gotoMock }); + addCookiesMock.mockResolvedValue(undefined); + addInitScriptMock.mockResolvedValue(undefined); + pagesMock.mockReturnValue([existingPage]); + newContextMock.mockResolvedValue({ + newPage: newPageMock, + addCookies: addCookiesMock, + addInitScript: addInitScriptMock, + pages: pagesMock, + }); + closeMock.mockResolvedValue(undefined); + + const mockCdpBrowser = { + contexts: () => [ + { + newPage: newPageMock, + addCookies: addCookiesMock, + addInitScript: addInitScriptMock, + pages: pagesMock, + }, + ], + newContext: newContextMock, + close: closeMock, + }; + connectOverCDPMock.mockResolvedValue(mockCdpBrowser); + + defaultBrowserMock.mockReturnValue(Effect.succeed(Option.none())); + browserListMock.mockReturnValue(Effect.succeed([])); + }); + + it("connects to auto-discovered Chrome when liveChrome is true", async () => { + autoDiscoverCdpMock.mockReturnValue(Effect.succeed("ws://127.0.0.1:9222/devtools/browser/abc")); + + const result = await runBrowser((browser) => + browser.createPage("https://example.com", { liveChrome: true }), + ); + + expect(autoDiscoverCdpMock).toHaveBeenCalledOnce(); + expect(connectOverCDPMock).toHaveBeenCalledWith( + "ws://127.0.0.1:9222/devtools/browser/abc", + expect.objectContaining({ timeout: expect.any(Number) }), + ); + expect(launchMock).not.toHaveBeenCalled(); + expect(result.isExternalBrowser).toBe(true); + }); + + it("falls back to Playwright when discovered CDP fails to connect", async () => { + autoDiscoverCdpMock.mockReturnValue(Effect.succeed("ws://127.0.0.1:9222/devtools/browser/bad")); + connectOverCDPMock.mockRejectedValueOnce(new Error("Timeout")); + launchMock.mockResolvedValue({ + newContext: newContextMock, + close: closeMock, + }); + + const result = await runBrowser((browser) => + browser.createPage("https://example.com", { liveChrome: true }), + ); + + expect(autoDiscoverCdpMock).toHaveBeenCalledOnce(); + expect(launchMock).toHaveBeenCalledOnce(); + expect(result.isExternalBrowser).toBe(false); + }); + + it("falls back to Playwright when auto-discovery fails", async () => { + const { CdpDiscoveryError } = await import("../src/errors"); + autoDiscoverCdpMock.mockReturnValue( + new CdpDiscoveryError({ cause: "No running Chrome found" }).asEffect(), + ); + launchMock.mockResolvedValue({ + newContext: newContextMock, + close: closeMock, + }); + + const result = await runBrowser((browser) => + browser.createPage("https://example.com", { liveChrome: true }), + ); + + expect(autoDiscoverCdpMock).toHaveBeenCalledOnce(); + expect(launchMock).toHaveBeenCalledOnce(); + expect(result.isExternalBrowser).toBe(false); + }); + + it("opens a fresh tab for external Chrome instead of reusing existing pages", async () => { + autoDiscoverCdpMock.mockReturnValue(Effect.succeed("ws://127.0.0.1:9222/devtools/browser/abc")); + + await runBrowser((browser) => browser.createPage("https://example.com", { liveChrome: true })); + + expect(newPageMock).toHaveBeenCalledOnce(); + }); + + it("skips auto-discovery when cdpUrl is already provided", async () => { + await runBrowser((browser) => + browser.createPage("https://example.com", { + liveChrome: true, + cdpUrl: "ws://custom:1234/devtools/browser/manual", + }), + ); + + expect(autoDiscoverCdpMock).not.toHaveBeenCalled(); + expect(connectOverCDPMock).toHaveBeenCalledWith("ws://custom:1234/devtools/browser/manual"); + }); + + it("ignores liveChrome when browserType is not chromium", async () => { + webkitLaunchMock.mockResolvedValue({ + newContext: newContextMock, + close: closeMock, + }); + + await runBrowser((browser) => + browser.createPage("https://example.com", { liveChrome: true, browserType: "webkit" }), + ); + + expect(autoDiscoverCdpMock).not.toHaveBeenCalled(); + expect(webkitLaunchMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/browser/vite.config.ts b/packages/browser/vite.config.ts index 5a74a86b4..fba575c3f 100644 --- a/packages/browser/vite.config.ts +++ b/packages/browser/vite.config.ts @@ -7,4 +7,7 @@ export default defineConfig({ dts: true, sourcemap: true, }, + test: { + testTimeout: 0, + }, }); diff --git a/packages/shared/package.json b/packages/shared/package.json index 46c4b0588..b4dce97c2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -10,7 +10,8 @@ "./observability": "./src/observability/exports.ts", "./utils": "./src/utils.ts", "./launched-from": "./src/launched-from.ts", - "./is-command-available": "./src/is-command-available.ts" + "./is-command-available": "./src/is-command-available.ts", + "./machine-id": "./src/machine-id.ts" }, "scripts": { "lint": "vp lint && tsc --noEmit", diff --git a/packages/shared/src/analytics/analytics.ts b/packages/shared/src/analytics/analytics.ts index 6fc66a547..331688248 100644 --- a/packages/shared/src/analytics/analytics.ts +++ b/packages/shared/src/analytics/analytics.ts @@ -1,6 +1,6 @@ import { Config, Effect, Layer, Option, ServiceMap } from "effect"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { machineId } from "node-machine-id"; +import { machineId } from "../machine-id"; import { hash } from "ohash"; import { PostHog } from "posthog-node"; diff --git a/packages/shared/src/machine-id.ts b/packages/shared/src/machine-id.ts new file mode 100644 index 000000000..8cfdbd19b --- /dev/null +++ b/packages/shared/src/machine-id.ts @@ -0,0 +1,10 @@ +import { machineId as getRawMachineId } from "node-machine-id"; + +let cached: Promise | undefined; + +export const machineId = (): Promise => { + if (!cached) { + cached = getRawMachineId(); + } + return cached; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d45db5d9e..40818909e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -310,6 +310,9 @@ importers: rrweb: specifier: ^2.0.0-alpha.18 version: 2.0.0-alpha.20 + which: + specifier: ^6.0.1 + version: 6.0.1 zod: specifier: ^4.3.6 version: 4.3.6 @@ -317,6 +320,9 @@ importers: '@types/node': specifier: ^22.15.0 version: 22.19.15 + '@types/which': + specifier: ^3.0.4 + version: 3.0.4 esbuild: specifier: ^0.25.0 version: 0.25.12 @@ -10539,8 +10545,8 @@ snapshots: '@next/eslint-plugin-next': 16.2.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) @@ -10562,7 +10568,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -10573,22 +10579,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10599,7 +10605,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3