Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/windows-appium-cmd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@qawolf/cli": patch
---

Fix Android flows and the `appium` / `uiautomator2-driver` doctor checks on Windows. The CLI resolved `node_modules/.bin/appium`, which is the extension-less POSIX script. Windows cannot execute that file. The CLI now uses the `appium.cmd` wrapper npm writes beside it, with the `shell: true` Node requires for a batch file after CVE-2024-27980. `qawolf install android` gets the same fix for the `sdkmanager.bat` and `avdmanager.bat` wrappers in cmdline-tools. The `adb` and `emulator` paths built from `ANDROID_HOME` now name the `.exe` suffix directly instead of relying on the spawn path search.
2 changes: 1 addition & 1 deletion src/commands/doctor/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export async function handleDoctor(
androidHome: process.env["ANDROID_HOME"] ?? process.env["ANDROID_SDK_ROOT"],
checkExists: (path: string) => ctx.fs.existsSync(path),
envDir,
resolveAppiumBin,
resolveAppiumBin: (dir) => resolveAppiumBin(dir, process.platform),
requiredAvds,
platform: process.platform,
});
Expand Down
21 changes: 4 additions & 17 deletions src/commands/install/android.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { join } from "node:path";

import { avdManagerBin, sdkManagerBin } from "~/core/androidBins.js";
import {
expandPatterns as defaultExpandPatterns,
makePeekFlowMeta,
Expand Down Expand Up @@ -30,25 +29,13 @@ export async function handleInstallAndroid(
arch: process.arch,
androidHome,
checkExists: (path: string) => fs.existsSync(path),
sdkManagerPath: join(
androidHome,
"cmdline-tools",
"latest",
"bin",
"sdkmanager",
),
avdManagerPath: join(
androidHome,
"cmdline-tools",
"latest",
"bin",
"avdmanager",
),
sdkManagerPath: sdkManagerBin(androidHome, process.platform),
avdManagerPath: avdManagerBin(androidHome, process.platform),
expandPatterns: (patterns, cwd) =>
defaultExpandPatterns(patterns, cwd ?? process.cwd(), undefined, fs),
peekFlowMeta: makePeekFlowMeta(fs),
resolveDepsRoot: async (files) =>
envDir ?? (await resolveDepsRootHelper({ files, fs })).depsRoot,
resolveAppiumBin,
resolveAppiumBin: (dir) => resolveAppiumBin(dir, process.platform),
});
}
63 changes: 63 additions & 0 deletions src/core/androidBins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from "bun:test";
import { join } from "node:path";

import {
adbBin,
avdManagerBin,
emulatorBin,
sdkManagerBin,
} from "./androidBins.js";

describe("emulatorBin", () => {
const home = join("/opt", "android-sdk");

it("returns the extension-less path on linux and macOS", () => {
expect(emulatorBin(home, "linux")).toBe(join(home, "emulator", "emulator"));
expect(emulatorBin(home, "darwin")).toBe(
join(home, "emulator", "emulator"),
);
});

it("returns emulator.exe on win32", () => {
expect(emulatorBin(home, "win32")).toBe(
join(home, "emulator", "emulator.exe"),
);
});

it("falls back to the bare name on PATH when ANDROID_HOME is unset", () => {
expect(emulatorBin(undefined, "linux")).toBe("emulator");
expect(emulatorBin(undefined, "win32")).toBe("emulator.exe");
});
});

describe("adbBin", () => {
const home = join("/opt", "android-sdk");

it("returns the extension-less path on linux and macOS", () => {
expect(adbBin(home, "linux")).toBe(join(home, "platform-tools", "adb"));
});

it("returns adb.exe on win32", () => {
expect(adbBin(home, "win32")).toBe(join(home, "platform-tools", "adb.exe"));
});

it("falls back to the bare name on PATH when ANDROID_HOME is unset", () => {
expect(adbBin(undefined, "linux")).toBe("adb");
expect(adbBin(undefined, "win32")).toBe("adb.exe");
});
});

describe("sdkManagerBin and avdManagerBin", () => {
const home = join("/opt", "android-sdk");
const binDir = join(home, "cmdline-tools", "latest", "bin");

it("returns the extension-less scripts on linux and macOS", () => {
expect(sdkManagerBin(home, "linux")).toBe(join(binDir, "sdkmanager"));
expect(avdManagerBin(home, "darwin")).toBe(join(binDir, "avdmanager"));
});

it("returns the .bat wrappers on win32", () => {
expect(sdkManagerBin(home, "win32")).toBe(join(binDir, "sdkmanager.bat"));
expect(avdManagerBin(home, "win32")).toBe(join(binDir, "avdmanager.bat"));
});
});
41 changes: 41 additions & 0 deletions src/core/androidBins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { join } from "node:path";

// The SDK ships emulator.exe and adb.exe on Windows. Name the extension rather
// than rely on libuv appending it during the spawn path search.
function withExeSuffix(name: string, platform: NodeJS.Platform): string {
return platform === "win32" ? `${name}.exe` : name;
}

export function emulatorBin(
home: string | undefined,
platform: NodeJS.Platform,
): string {
const name = withExeSuffix("emulator", platform);
return home ? join(home, "emulator", name) : name;
}

export function adbBin(
home: string | undefined,
platform: NodeJS.Platform,
): string {
const name = withExeSuffix("adb", platform);
return home ? join(home, "platform-tools", name) : name;
}

// cmdline-tools ships each command as a POSIX script plus a .bat wrapper.
function cmdlineToolsBin(
home: string,
name: string,
platform: NodeJS.Platform,
): string {
const file = platform === "win32" ? `${name}.bat` : name;
return join(home, "cmdline-tools", "latest", "bin", file);
}

export function sdkManagerBin(home: string, platform: NodeJS.Platform): string {
return cmdlineToolsBin(home, "sdkmanager", platform);
}

export function avdManagerBin(home: string, platform: NodeJS.Platform): string {
return cmdlineToolsBin(home, "avdmanager", platform);
}
1 change: 1 addition & 0 deletions src/domains/doctor/checks/android.fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function baseDeps(
envDir,
resolveAppiumBin: (dir: string) => `${dir}/node_modules/.bin/appium`,
requiredAvds: [] as readonly string[],
platform: "linux" as NodeJS.Platform,
...over,
};
}
Expand Down
13 changes: 13 additions & 0 deletions src/domains/doctor/checks/android.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, mock } from "bun:test";
import { join } from "node:path";

import type { SpawnFn } from "~/shell/spawn.js";

Expand Down Expand Up @@ -179,3 +180,15 @@ describe("checkAndroid: android-avd", () => {
expect(avd?.detail).not.toContain("Could not launch emulator");
});
});

describe("checkAndroid: Windows binary names", () => {
it("launches adb.exe and emulator.exe on win32", async () => {
const spawn = mock<SpawnFn>(() => Promise.resolve(success));
await checkAndroid(
baseDeps({ spawn, platform: "win32", requiredAvds: ["Pixel_9"] }),
);
const spawned = spawn.mock.calls.map((call) => call[0]);
expect(spawned).toContain(join(sdk, "platform-tools", "adb.exe"));
expect(spawned).toContain(join(sdk, "emulator", "emulator.exe"));
});
});
7 changes: 4 additions & 3 deletions src/domains/doctor/checks/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,17 @@ export type CheckAndroidDeps = {
readonly envDir: string | undefined;
readonly resolveAppiumBin: (envDir: string) => string;
readonly requiredAvds: readonly string[];
readonly platform: NodeJS.Platform;
};

export async function checkAndroid(
deps: CheckAndroidDeps,
): Promise<CheckResult[]> {
const home = checkHome(deps.androidHome, deps.checkExists);
const [adb, emulator, avds] = await Promise.all([
checkAdb(deps.spawn, deps.androidHome),
checkEmulatorBin(deps.spawn, deps.androidHome),
checkAvds(deps.spawn, deps.androidHome, deps.requiredAvds),
checkAdb(deps.spawn, deps.androidHome, deps.platform),
checkEmulatorBin(deps.spawn, deps.androidHome, deps.platform),
checkAvds(deps.spawn, deps.androidHome, deps.requiredAvds, deps.platform),
]);
const { appium, bin } = checkAppium(
deps.envDir,
Expand Down
10 changes: 7 additions & 3 deletions src/domains/doctor/checks/androidSdk.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { adbBin, emulatorBin } from "~/core/androidBins.js";
import { doctorMessages } from "~/core/messages/index.js";
import type { CheckResult } from "~/domains/doctor/types.js";
import type { SpawnFn, SpawnResult } from "~/shell/spawn.js";
Expand Down Expand Up @@ -33,8 +34,9 @@ export function checkHome(
export async function checkAdb(
spawn: SpawnFn,
androidHome: string | undefined,
platform: NodeJS.Platform,
): Promise<CheckResult> {
const bin = androidHome ? `${androidHome}/platform-tools/adb` : "adb";
const bin = adbBin(androidHome, platform);
const result = await spawn(bin, ["--version"]);
if (result.exitCode < 0) {
return {
Expand All @@ -52,8 +54,9 @@ export async function checkAdb(
export async function checkEmulatorBin(
spawn: SpawnFn,
androidHome: string | undefined,
platform: NodeJS.Platform,
): Promise<CheckResult> {
const bin = androidHome ? `${androidHome}/emulator/emulator` : "emulator";
const bin = emulatorBin(androidHome, platform);
const result = await spawn(bin, ["-version"]);
if (result.exitCode < 0) {
return {
Expand All @@ -79,9 +82,10 @@ export async function checkAvds(
spawn: SpawnFn,
androidHome: string | undefined,
requiredAvds: readonly string[],
platform: NodeJS.Platform,
): Promise<CheckResult[]> {
if (requiredAvds.length === 0) return [];
const bin = androidHome ? `${androidHome}/emulator/emulator` : "emulator";
const bin = emulatorBin(androidHome, platform);
const result = await spawn(bin, ["-list-avds"]);
if (result.exitCode < 0) {
return [
Expand Down
1 change: 1 addition & 0 deletions src/domains/doctor/checks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export async function runChecks(deps: CheckDeps): Promise<CheckResult[]> {
envDir: deps.envDir,
resolveAppiumBin: deps.resolveAppiumBin,
requiredAvds: deps.requiredAvds,
platform: deps.platform,
})
: Promise.resolve<CheckResult[]>([]),
]);
Expand Down
2 changes: 1 addition & 1 deletion src/domains/runner/runAndroidFlowDeps.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createAppiumServer } from "~/shell/appium/createAppiumServer.js";
import { createEmulatorPool } from "~/shell/appium/createEmulatorPool.js";
import { defaultAdb } from "~/shell/appium/emulatorSetup.js";
import { defaultAdb } from "~/shell/appium/adb.js";
import type { AppiumDriver } from "~/shell/appium/types.js";
import type { RunAndroidFlowDeps } from "./runAndroidFlow.js";
import { createRunnerDeps } from "./runnerDeps.js";
Expand Down
14 changes: 14 additions & 0 deletions src/shell/appium/adb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";

import { adbBin } from "~/core/androidBins.js";

const execFileAsync = promisify(execFile);

export type AdbFn = (args: string[]) => Promise<{ stdout: string }>;

export const defaultAdb: AdbFn = async (args) => {
const home = process.env["ANDROID_HOME"] ?? process.env["ANDROID_SDK_ROOT"];
const { stdout } = await execFileAsync(adbBin(home, process.platform), args);
return { stdout };
};
3 changes: 2 additions & 1 deletion src/shell/appium/createAndroidEmulator.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, mock } from "bun:test";
import type { AdbFn, SpawnFn } from "./createAndroidEmulator.js";
import type { AdbFn } from "./adb.js";
import type { SpawnFn } from "./createAndroidEmulator.js";
import { createAndroidEmulator } from "./createAndroidEmulator.js";

afterEach(() => {
Expand Down
25 changes: 5 additions & 20 deletions src/shell/appium/createAndroidEmulator.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,23 @@
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { spawn } from "node:child_process";

import { emulatorBin } from "~/core/androidBins.js";
import { defaultAdb, type AdbFn } from "./adb.js";

const execFileAsync = promisify(execFile);
const defaultBootTimeoutMs = 120_000;
const pollIntervalMs = 2_000;

function androidHome(): string | undefined {
return process.env["ANDROID_HOME"] ?? process.env["ANDROID_SDK_ROOT"];
}

function emulatorBin(): string {
const home = androidHome();
return home ? `${home}/emulator/emulator` : "emulator";
}

function adbBin(): string {
const home = androidHome();
return home ? `${home}/platform-tools/adb` : "adb";
}

export type SpawnFn = (bin: string, args: string[]) => { stop: () => void };
export type AdbFn = (args: string[]) => Promise<{ stdout: string }>;

const defaultSpawn: SpawnFn = (bin, args) => {
const child = spawn(bin, args, { stdio: "ignore" });
child.unref();
return { stop: () => child.kill() };
};

const defaultAdb: AdbFn = async (args) => {
const { stdout } = await execFileAsync(adbBin(), args);
return { stdout };
};

async function bootSequence(
adb: AdbFn,
serial: string,
Expand Down Expand Up @@ -97,7 +82,7 @@ export async function createAndroidEmulator(params: {
const timeoutMs = params.options?.bootTimeoutMs ?? defaultBootTimeoutMs;
const serial = `emulator-${port}`;

const proc = spawnFn(emulatorBin(), [
const proc = spawnFn(emulatorBin(androidHome(), process.platform), [
"-avd",
avdName,
"-no-audio",
Expand Down
3 changes: 2 additions & 1 deletion src/shell/appium/createAndroidLaunchContext.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { EmulatorSlot } from "./createEmulatorPool.js"; // (D2)
import { configureEmulator, defaultAdb } from "./emulatorSetup.js";
import { defaultAdb } from "./adb.js";
import { configureEmulator } from "./emulatorSetup.js";
import type {
AndroidCleanupResult,
AndroidLaunchContext,
Expand Down
4 changes: 3 additions & 1 deletion src/shell/appium/createAppiumServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ export async function createAppiumServer(
}> {
const spawnFn = params?.deps?.spawn ?? defaultSpawnAppium;
const findFreePortFn = params?.deps?.findFreePort ?? findFreePort;
const resolveAppiumBinFn = params?.deps?.resolveAppiumBin ?? resolveAppiumBin;
const resolveAppiumBinFn =
params?.deps?.resolveAppiumBin ??
((dir: string) => resolveAppiumBin(dir, process.platform));
const appiumHome =
params?.options?.appiumHome ?? join(envPaths("qawolf").data, "appium");
const timeoutMs = params?.options?.startTimeoutMs ?? defaultStartTimeoutMs;
Expand Down
3 changes: 2 additions & 1 deletion src/shell/appium/createEmulatorPool.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, mock } from "bun:test";
import type { AdbFn, SpawnFn } from "./createAndroidEmulator.js";
import type { AdbFn } from "./adb.js";
import type { SpawnFn } from "./createAndroidEmulator.js";
import { createEmulatorPool } from "./createEmulatorPool.js";
import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.js";

Expand Down
2 changes: 1 addition & 1 deletion src/shell/appium/createEmulatorPool.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {
createAndroidEmulator,
type AdbFn,
type SpawnFn,
} from "./createAndroidEmulator.js";
import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js";
import type { AdbFn } from "./adb.js";

export type EmulatorSlot = { serial: string; avdName: string };

Expand Down
18 changes: 1 addition & 17 deletions src/shell/appium/emulatorSetup.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,4 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { AdbFn } from "./createAndroidEmulator.js";

const execFileAsync = promisify(execFile);

function adbBin(): string {
const home = process.env["ANDROID_HOME"] ?? process.env["ANDROID_SDK_ROOT"];
return home ? `${home}/platform-tools/adb` : "adb";
}

// Duplicates the private defaultAdb in createAndroidEmulator.ts.
// Extract to a shared helper when a third callsite appears.
export const defaultAdb: AdbFn = async (args) => {
const { stdout } = await execFileAsync(adbBin(), args);
return { stdout };
};
import type { AdbFn } from "./adb.js";

async function disableAnimations(adb: AdbFn, serial: string): Promise<void> {
await Promise.all([
Expand Down
Loading
Loading