diff --git a/CHANGELOG.md b/CHANGELOG.md index 0059e2a..f97867b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add optional platform-driver disposal and forward mobile disposal to lifecycle-capable device-mcp backends. +### Changed + +- Start the CLI faster: find the project root without running `git` and do not load zod. Load `@metamask/device-mcp` only when a mobile session uses it, so browser daemons start without it. + ### Fixed - Dispose session-owned drivers after consumer cleanup, including cleanup errors, forced relaunch, failed launch, daemon shutdown, and server startup rollback. Retain failed disposals in one server-owned lifecycle, so the next cleanup or daemon stop retries them. diff --git a/README.md b/README.md index 1228a1c..283dca7 100644 --- a/README.md +++ b/README.md @@ -90,12 +90,17 @@ Use the invocation that matches your installation: ```bash # Project dependency -yarn mm launch +node_modules/.bin/mm launch # or: yarn mm launch # Global installation mm launch ``` +For agents and scripts that call the CLI many times, call the bin directly +(`node_modules/.bin/mm`, or an alias to it), not `yarn mm` or `npx mm`. The +CLI itself starts in about 0.1 s; `yarn mm` adds about 0.35-0.45 s and +`npx mm` about 0.3 s to every call (measured on macOS with Yarn 4). + ## Getting Started Consuming this package requires two things: a **daemon entry point** and a **configuration file**. @@ -155,7 +160,7 @@ mm click e3 # interact using a11y refs mm cleanup --shutdown # stop browser and daemon ``` -Use `yarn mm` in place of `mm` when the CLI is installed as a project dependency. +Use `node_modules/.bin/mm` (or `yarn mm`) in place of `mm` when the CLI is installed as a project dependency. If running from outside the project directory (e.g., a parent folder containing multiple repos): @@ -592,7 +597,7 @@ By default, the CLI resolves the target project from the current git worktree. T 1. **`--project `** — Explicit flag, highest priority. Accepts absolute or relative paths. 2. **`MM_PROJECT`** — Environment variable. Useful for setting once in agent config or shell profile. -3. **Git worktree** — `git rev-parse --show-toplevel` from the current working directory (existing behavior). +3. **Git worktree** — the nearest directory at or above the current working directory that contains a `.git` entry (directory, or file for linked worktrees and submodules). Falls back to `git rev-parse --show-toplevel` when there is none, or when `GIT_DIR` / `GIT_WORK_TREE` is set. ```bash # From inside the project (unchanged) diff --git a/src/cli/mm.test.ts b/src/cli/mm.test.ts index c6a36d9..8c063ed 100644 --- a/src/cli/mm.test.ts +++ b/src/cli/mm.test.ts @@ -1240,9 +1240,45 @@ describe('resolveWorktreeRoot', () => { ); }); - it('falls back to git worktree when no explicit path', async () => { + it('finds the nearest .git entry above the cwd without running git', async () => { const origEnv = process.env.MM_PROJECT; delete process.env.MM_PROJECT; + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue('/repo/a/b'); + vi.mocked(fs.stat).mockImplementation(async (p) => { + if (p === path.join('/repo', '.git')) { + return { isDirectory: () => false } as any; + } + throw new Error('ENOENT'); + }); + const { execSync } = await import('node:child_process'); + vi.mocked(execSync).mockClear(); + + const result = await resolveWorktreeRoot(undefined); + + expect(result).toBe('/repo'); + expect(execSync).not.toHaveBeenCalled(); + cwdSpy.mockRestore(); + process.env.MM_PROJECT = origEnv; + }); + + it('falls back to git rev-parse when no .git entry is found', async () => { + const origEnv = process.env.MM_PROJECT; + delete process.env.MM_PROJECT; + vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT')); + + const { execSync } = await import('node:child_process'); + vi.mocked(execSync).mockReturnValueOnce(Buffer.from('/git/root\n')); + + const result = await resolveWorktreeRoot(undefined); + expect(result).toBe('/git/root'); + + process.env.MM_PROJECT = origEnv; + }); + + it('falls back to git rev-parse when the cwd cannot be resolved', async () => { + const origEnv = process.env.MM_PROJECT; + delete process.env.MM_PROJECT; + vi.mocked(fs.realpath).mockRejectedValueOnce(new Error('ENOENT')); const { execSync } = await import('node:child_process'); vi.mocked(execSync).mockReturnValueOnce(Buffer.from('/git/root\n')); @@ -1253,9 +1289,31 @@ describe('resolveWorktreeRoot', () => { process.env.MM_PROJECT = origEnv; }); + it.each(['GIT_DIR', 'GIT_WORK_TREE'])( + 'lets git resolve the root when %s is set', + async (name) => { + const origEnv = process.env.MM_PROJECT; + delete process.env.MM_PROJECT; + process.env[name] = '/custom'; + + const { execSync } = await import('node:child_process'); + vi.mocked(execSync).mockReturnValueOnce(Buffer.from('/git/root\n')); + + const result = await resolveWorktreeRoot(undefined); + expect(result).toBe('/git/root'); + expect(fs.stat).not.toHaveBeenCalledWith( + expect.stringMatching(/\.git$/u), + ); + + delete process.env[name]; + process.env.MM_PROJECT = origEnv; + }, + ); + it('exits when not in a git repository', async () => { const origEnv = process.env.MM_PROJECT; delete process.env.MM_PROJECT; + vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT')); const { execSync } = await import('node:child_process'); vi.mocked(execSync).mockImplementation(() => { diff --git a/src/cli/mm.ts b/src/cli/mm.ts index f19d5f5..53d2e5f 100644 --- a/src/cli/mm.ts +++ b/src/cli/mm.ts @@ -18,8 +18,8 @@ import { import { BEST_EFFORT_CLEANUP_TIMEOUT_MS } from '../tools/utils/constants.js'; import type { DaemonState } from '../types/http.js'; import { computeTimeoutBudget } from '../utils/timeout-budget.js'; -import { observeLevelSchema } from '../validation/schemas.js'; -import type { ObserveLevel } from '../validation/schemas.js'; +import { OBSERVE_LEVELS } from '../validation/observe-level.js'; +import type { ObserveLevel } from '../validation/observe-level.js'; const COMMAND_TIMEOUTS_MS: Record = { launch: 120_000, @@ -84,8 +84,6 @@ export function extractProjectFlag(argv: string[]): { return { args: remaining, projectPath: value }; } -const OBSERVE_LEVELS: readonly ObserveLevel[] = observeLevelSchema.options; - /** * Extracts and consumes the global `--observe ` flag from * argv, returning the remaining args and the level (if any). @@ -224,11 +222,46 @@ export function formatJsonOutput(output: unknown): string { : JSON.stringify(output); } +/** + * Finds the git worktree root without starting a `git` process (about 90 ms + * per call): walks up from the real cwd to the first directory that has a + * `.git` entry. `.git` is a directory in a main checkout and a file in a + * linked worktree or submodule; both count. + * + * @returns The worktree root, or undefined when no `.git` entry is found or + * `GIT_DIR` / `GIT_WORK_TREE` override git's own discovery. + */ +export async function findGitWorktreeRoot(): Promise { + if (process.env.GIT_DIR || process.env.GIT_WORK_TREE) { + return undefined; + } + let dir: string; + try { + dir = await fs.realpath(process.cwd()); + } catch { + return undefined; + } + for (;;) { + try { + await fs.stat(path.join(dir, '.git')); + return dir; + } catch { + const parent = path.dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } + } +} + /** * Resolves the target project root directory using the following precedence: * 1. `--project ` CLI flag * 2. `MM_PROJECT` environment variable - * 3. `git rev-parse --show-toplevel` (current working directory) + * 3. the git worktree root of the current working directory: the nearest + * `.git` entry (see {@link findGitWorktreeRoot}), else + * `git rev-parse --show-toplevel` * * Both explicit sources accept absolute or relative paths (resolved from cwd). * The resolved path is normalized via `fs.realpath` to handle symlinks. @@ -267,6 +300,11 @@ export async function resolveWorktreeRoot( return real; } + const found = await findGitWorktreeRoot(); + if (found) { + return found; + } + try { return execSync('git rev-parse --show-toplevel', { stdio: ['pipe', 'pipe', 'pipe'], diff --git a/src/platform/device-mcp-runtime.test.ts b/src/platform/device-mcp-runtime.test.ts new file mode 100644 index 0000000..ecfa4f9 --- /dev/null +++ b/src/platform/device-mcp-runtime.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; + +const loaded = vi.hoisted(() => ({ deviceMcp: false })); + +// Runs only if some module imports device-mcp at load time. +vi.mock('@metamask/device-mcp', async (importOriginal) => { + loaded.deviceMcp = true; + return importOriginal(); +}); + +describe('device-mcp runtime loading', () => { + it('does not load device-mcp when the package entry point is imported', async () => { + const core = await import('..'); + + expect(core.MobilePlatformDriver).toBeDefined(); + expect(core.PlaywrightPlatformDriver).toBeDefined(); + expect(loaded.deviceMcp).toBe(false); + }); + + it('loads device-mcp once, on first use', async () => { + const { loadDeviceMcp, resetDeviceMcpForTesting } = + await import('./device-mcp-runtime.js'); + resetDeviceMcpForTesting(); + const cjsRequire = vi.fn(require) as unknown as NodeJS.Require; + + const first = await loadDeviceMcp(cjsRequire); + const second = await loadDeviceMcp(cjsRequire); + + expect(first).toBe(second); + expect(cjsRequire).toHaveBeenCalledTimes(1); + expect(cjsRequire).toHaveBeenCalledWith('@metamask/device-mcp'); + expect(typeof first.HermesSession).toBe('function'); + expect(typeof first.generateLocators).toBe('function'); + }); + + it('uses the module require by default (CommonJS build)', async () => { + const { loadDeviceMcp, resetDeviceMcpForTesting } = + await import('./device-mcp-runtime.js'); + resetDeviceMcpForTesting(); + + const runtime = await loadDeviceMcp(); + + expect(typeof runtime.HermesSession).toBe('function'); + resetDeviceMcpForTesting(); + }); + + it('uses import() when there is no CommonJS require (ESM build)', async () => { + const { loadDeviceMcp, resetDeviceMcpForTesting } = + await import('./device-mcp-runtime.js'); + resetDeviceMcpForTesting(); + + const runtime = await loadDeviceMcp(null); + + expect(typeof runtime.HermesSession).toBe('function'); + resetDeviceMcpForTesting(); + }); +}); diff --git a/src/platform/device-mcp-runtime.ts b/src/platform/device-mcp-runtime.ts new file mode 100644 index 0000000..86eab25 --- /dev/null +++ b/src/platform/device-mcp-runtime.ts @@ -0,0 +1,46 @@ +import type * as DeviceMcp from '@metamask/device-mcp'; + +/** The run-time exports of `@metamask/device-mcp`. */ +export type DeviceMcpRuntime = typeof DeviceMcp; + +let runtime: DeviceMcpRuntime | undefined; + +// `require` exists in the CommonJS build only. +const moduleRequire: NodeJS.Require | null = + typeof require === 'function' + ? require + : /* istanbul ignore next -- the test run always has `require` */ null; + +/** + * Loads `@metamask/device-mcp` on first use. A browser-only daemon then never + * loads it (about 65 ms and 54 modules at daemon start). + * + * The CommonJS build uses `require`, so it shares the copy that a CommonJS + * consumer loaded to create its backend. A native `import()` there would load + * the package's ESM build as a second copy. The ESM build has no `require` + * and uses `import()`. + * + * @param cjsRequire - The CommonJS `require`, or null when there is none. + * Tests pass null to take the ESM path. + * @returns The device-mcp module. + */ +export async function loadDeviceMcp( + cjsRequire: NodeJS.Require | null = moduleRequire, +): Promise { + if (runtime === undefined) { + const loaded = cjsRequire + ? (cjsRequire('@metamask/device-mcp') as DeviceMcpRuntime) + : // The ESM and CJS type declarations differ only in their MCP SDK + // copies, which core does not use. + ((await import('@metamask/device-mcp')) as unknown as DeviceMcpRuntime); + // Concurrent first calls get the same cached module, so the order of + // these writes does not matter. + runtime ??= loaded; + } + return runtime; +} + +/** Forgets the loaded module. Test only. */ +export function resetDeviceMcpForTesting(): void { + runtime = undefined; +} diff --git a/src/platform/mobile-platform-driver.hermes-integration.test.ts b/src/platform/mobile-platform-driver.hermes-integration.test.ts index 4e67432..1728180 100644 --- a/src/platform/mobile-platform-driver.hermes-integration.test.ts +++ b/src/platform/mobile-platform-driver.hermes-integration.test.ts @@ -12,17 +12,20 @@ const mocks = vi.hoisted(() => ({ HermesSession: vi.fn(), })); -vi.mock('@metamask/device-mcp', async (importOriginal) => { - const actual = await importOriginal(); +// The driver loads device-mcp on first use through this module. +vi.mock('./device-mcp-runtime.js', async () => { + const actual = await import('@metamask/device-mcp'); mocks.selectHermesTarget.mockImplementation(actual.selectHermesTarget); mocks.hasAmbiguousTarget.mockImplementation(actual.hasAmbiguousTarget); return { - ...actual, - fetchDiscoveryTargets: mocks.fetchDiscoveryTargets, - selectHermesTarget: mocks.selectHermesTarget, - hasAmbiguousTarget: mocks.hasAmbiguousTarget, - HermesSession: mocks.HermesSession, + loadDeviceMcp: async () => ({ + ...actual, + fetchDiscoveryTargets: mocks.fetchDiscoveryTargets, + selectHermesTarget: mocks.selectHermesTarget, + hasAmbiguousTarget: mocks.hasAmbiguousTarget, + HermesSession: mocks.HermesSession, + }), }; }); diff --git a/src/platform/mobile-platform-driver.hermes.test.ts b/src/platform/mobile-platform-driver.hermes.test.ts index e9cbe88..d9f52eb 100644 --- a/src/platform/mobile-platform-driver.hermes.test.ts +++ b/src/platform/mobile-platform-driver.hermes.test.ts @@ -12,13 +12,17 @@ const mocks = vi.hoisted(() => ({ HermesSession: vi.fn(), })); -vi.mock('@metamask/device-mcp', () => ({ - runHermesCdp: mocks.runHermesCdp, - fetchDiscoveryTargets: mocks.fetchDiscoveryTargets, - selectHermesTarget: mocks.selectHermesTarget, - hasAmbiguousTarget: mocks.hasAmbiguousTarget, - LEGACY_SYNTHETIC_TITLE: 'React Native Experimental (Improved Chrome Reloads)', - HermesSession: mocks.HermesSession, +// The driver loads device-mcp on first use through this module. +vi.mock('./device-mcp-runtime.js', () => ({ + loadDeviceMcp: async () => ({ + runHermesCdp: mocks.runHermesCdp, + fetchDiscoveryTargets: mocks.fetchDiscoveryTargets, + selectHermesTarget: mocks.selectHermesTarget, + hasAmbiguousTarget: mocks.hasAmbiguousTarget, + LEGACY_SYNTHETIC_TITLE: + 'React Native Experimental (Improved Chrome Reloads)', + HermesSession: mocks.HermesSession, + }), })); // Imported AFTER vi.mock so the driver binds to the mocked device-mcp runtime. @@ -47,12 +51,20 @@ describe('MobilePlatformDriver hermes delegation', () => { }); describe('session scoping', () => { - it('owns an instance-scoped Hermes session instead of the process singleton', () => { + it('owns an instance-scoped Hermes session instead of the process singleton', async () => { + mocks.runHermesCdp.mockResolvedValue({ ok: true, result: {} }); const iosDriver = new MobilePlatformDriver(createBackend('ios')); const androidDriver = new MobilePlatformDriver(createBackend('android')); expect(iosDriver.getPlatform()).toBe('ios'); expect(androidDriver.getPlatform()).toBe('android'); + // Created on first use (device-mcp is loaded lazily), once per driver. + expect(mocks.HermesSession).not.toHaveBeenCalled(); + + await iosDriver.cdp({ method: 'Runtime.evaluate', timeoutMs: 1000 }); + await iosDriver.cdp({ method: 'Runtime.evaluate', timeoutMs: 1000 }); + await androidDriver.cdp({ method: 'Runtime.evaluate', timeoutMs: 1000 }); + expect(mocks.HermesSession).toHaveBeenCalledTimes(2); expect(mocks.HermesSession).toHaveBeenNthCalledWith(1, { platform: 'ios', @@ -61,6 +73,18 @@ describe('MobilePlatformDriver hermes delegation', () => { platform: 'android', }); }); + + it('creates one session when first calls run concurrently', async () => { + mocks.runHermesCdp.mockResolvedValue({ ok: true, result: {} }); + const driver = new MobilePlatformDriver(createBackend('ios')); + + await Promise.all([ + driver.cdp({ method: 'Runtime.evaluate', timeoutMs: 1000 }), + driver.cdp({ method: 'Runtime.evaluate', timeoutMs: 1000 }), + ]); + + expect(mocks.HermesSession).toHaveBeenCalledTimes(1); + }); }); afterEach(() => { diff --git a/src/platform/mobile-platform-driver.ts b/src/platform/mobile-platform-driver.ts index 7cf96a2..397c8bf 100644 --- a/src/platform/mobile-platform-driver.ts +++ b/src/platform/mobile-platform-driver.ts @@ -1,19 +1,12 @@ -import { - runHermesCdp, - HermesSession, - fetchDiscoveryTargets, - selectHermesTarget, - hasAmbiguousTarget, - generateLocators, - LEGACY_SYNTHETIC_TITLE, -} from '@metamask/device-mcp'; import type { DeviceBackend, ElementQuery, + HermesSession, HermesTarget, UIElement, } from '@metamask/device-mcp'; +import { loadDeviceMcp } from './device-mcp-runtime.js'; import { filterAppHierarchy, pruneLayoutOnly, @@ -86,7 +79,8 @@ export class MobilePlatformDriver implements IPlatformDriver { // Scoped to this driver instance rather than the process-wide // getHermesSession() singleton so the pinned Hermes deviceId cannot leak // across mobile sessions (e.g. a relaunch against a different simulator). - readonly #hermesSession: HermesSession; + // Created on first use, because device-mcp is loaded on first use. + #hermesSession: HermesSession | undefined; readonly #excludeDevOverlay: boolean; @@ -110,10 +104,21 @@ export class MobilePlatformDriver implements IPlatformDriver { ) { this.#backend = backend; this.#bundleId = bundleId; - this.#hermesSession = new HermesSession({ platform: backend.platform }); this.#excludeDevOverlay = options.excludeDevOverlay ?? true; } + /** + * Returns this driver's Hermes session, creating it on first use. + * + * @returns The Hermes session of this driver. + */ + async #getHermesSession(): Promise { + const { HermesSession: Session } = await loadDeviceMcp(); + // Concurrent first calls both get here; the first one creates the session. + this.#hermesSession ??= new Session({ platform: this.#backend.platform }); + return this.#hermesSession; + } + /** Release only this backend's resources; compatible with older device-mcp releases. */ async dispose(): Promise { const backend = this.#backend as DeviceBackend & { @@ -591,7 +596,8 @@ export class MobilePlatformDriver implements IPlatformDriver { * @returns A discriminated CDP outcome. */ async #hermesCdp(input: CdpInput): Promise { - const session = this.#hermesSession; + const { runHermesCdp } = await loadDeviceMcp(); + const session = await this.#getHermesSession(); const resolved = session.resolve({ metroPort: input.metroPort, appId: input.appId, @@ -671,7 +677,13 @@ export class MobilePlatformDriver implements IPlatformDriver { * @returns A structured report of discovered targets and the selection result. */ async hermesTargets(input: HermesTargetsInput): Promise { - const session = this.#hermesSession; + const { + fetchDiscoveryTargets, + selectHermesTarget, + hasAmbiguousTarget, + LEGACY_SYNTHETIC_TITLE, + } = await loadDeviceMcp(); + const session = await this.#getHermesSession(); const resolved = session.resolve({ metroPort: input.metroPort, appId: input.appId, @@ -753,7 +765,10 @@ export class MobilePlatformDriver implements IPlatformDriver { * @returns The ranked element locators. */ async generateLocators(): Promise { - const { hierarchy } = await this.#snapshotApp(); + const [{ hierarchy }, { generateLocators }] = await Promise.all([ + this.#snapshotApp(), + loadDeviceMcp(), + ]); return generateLocators(hierarchy); } } diff --git a/src/validation/observe-level.ts b/src/validation/observe-level.ts new file mode 100644 index 0000000..f68f825 --- /dev/null +++ b/src/validation/observe-level.ts @@ -0,0 +1,8 @@ +// No zod import here: the CLI reads these levels on every call, and loading +// zod costs about 25 ms. `schemas.ts` builds the zod enum from this list. + +/** Observation levels accepted by `--observe` and the `observe` tool field. */ +export const OBSERVE_LEVELS = ['none', 'diff', 'full'] as const; + +/** Observation to return after a call. */ +export type ObserveLevel = (typeof OBSERVE_LEVELS)[number]; diff --git a/src/validation/schemas.ts b/src/validation/schemas.ts index 719e1b9..5a7d17c 100644 --- a/src/validation/schemas.ts +++ b/src/validation/schemas.ts @@ -1,9 +1,10 @@ import { z } from 'zod'; +import { OBSERVE_LEVELS } from './observe-level.js'; import { SMART_CONTRACT_NAMES, HARDFORKS } from '../tools/types/seeding.js'; export const observeLevelSchema = z - .enum(['none', 'diff', 'full']) + .enum(OBSERVE_LEVELS) .describe( 'Observation to return after the call: ' + 'none = skip it (fastest), ' +