Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down Expand Up @@ -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):

Expand Down Expand Up @@ -592,7 +597,7 @@ By default, the CLI resolves the target project from the current git worktree. T

1. **`--project <path>`** — 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)
Expand Down
60 changes: 59 additions & 1 deletion src/cli/mm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -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(() => {
Expand Down
48 changes: 43 additions & 5 deletions src/cli/mm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
launch: 120_000,
Expand Down Expand Up @@ -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 <none|diff|full>` flag from
* argv, returning the remaining args and the level (if any).
Expand Down Expand Up @@ -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<string | undefined> {
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 <path>` 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.
Expand Down Expand Up @@ -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'],
Expand Down
57 changes: 57 additions & 0 deletions src/platform/device-mcp-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
46 changes: 46 additions & 0 deletions src/platform/device-mcp-runtime.ts
Original file line number Diff line number Diff line change
@@ -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<DeviceMcpRuntime> {
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;
}
17 changes: 10 additions & 7 deletions src/platform/mobile-platform-driver.hermes-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,20 @@ const mocks = vi.hoisted(() => ({
HermesSession: vi.fn(),
}));

vi.mock('@metamask/device-mcp', async (importOriginal) => {
const actual = await importOriginal<typeof import('@metamask/device-mcp')>();
// 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,
}),
};
});

Expand Down
Loading