203 lines
8.9 KiB
TypeScript
203 lines
8.9 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { existsSync } from "node:fs";
|
|
import {
|
|
collectAdbStatus,
|
|
collectCarTelemetry,
|
|
installAdbApk,
|
|
launchAdbTarget,
|
|
listInstalledAdbPackages,
|
|
type AdbConfig,
|
|
type AdbExecutor
|
|
} from "../src/adb.js";
|
|
|
|
const enabledConfig: AdbConfig = {
|
|
enabled: true,
|
|
executable: "/usr/bin/adb",
|
|
serial: "MG4-HEAD-UNIT",
|
|
timeoutMs: 2_500
|
|
};
|
|
|
|
describe("read-only ADB collector", () => {
|
|
it("does not execute anything while integration is disabled", async () => {
|
|
const execute = vi.fn<AdbExecutor>();
|
|
await expect(collectAdbStatus({ ...enabledConfig, enabled: false }, execute)).resolves.toEqual({
|
|
available: false,
|
|
state: "disabled",
|
|
reason: "ADB integration is not configured"
|
|
});
|
|
expect(execute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses only fixed argument arrays for one serial and parses identity and display values", async () => {
|
|
const values = new Map<string, string>([
|
|
["get-state", "device\n"],
|
|
["shell getprop ro.product.manufacturer", "SAIC\n"],
|
|
["shell getprop ro.product.model", "AUTUS\n"],
|
|
["shell getprop ro.build.version.release", "9\n"],
|
|
["shell getprop ro.build.version.sdk", "28\n"],
|
|
["shell getprop ro.build.display.id", "PPR1.181005.003\n"],
|
|
["shell getprop ro.product.cpu.abi", "arm64-v8a\n"],
|
|
["shell wm size", "Physical size: 1920x720\n"],
|
|
["shell wm density", "Physical density: 160\n"]
|
|
]);
|
|
const calls: string[][] = [];
|
|
const execute: AdbExecutor = async (executable, arguments_, options) => {
|
|
expect(executable).toBe("/usr/bin/adb");
|
|
expect(options).toEqual({ timeout: 2_500, maxBuffer: 16 * 1024 });
|
|
calls.push([...arguments_]);
|
|
return { stdout: values.get(arguments_.slice(2).join(" ")) ?? "", stderr: "" };
|
|
};
|
|
|
|
await expect(collectAdbStatus(enabledConfig, execute)).resolves.toMatchObject({
|
|
available: true,
|
|
value: {
|
|
state: "connected",
|
|
identity: {
|
|
manufacturer: { available: true, value: "SAIC" },
|
|
androidVersion: { available: true, value: "9" },
|
|
apiLevel: { available: true, value: 28 },
|
|
abi: { available: true, value: "arm64-v8a" }
|
|
},
|
|
display: {
|
|
size: { available: true, value: { widthPixels: 1920, heightPixels: 720 } },
|
|
densityDpi: { available: true, value: 160 }
|
|
}
|
|
}
|
|
});
|
|
expect(calls).toHaveLength(9);
|
|
for (const arguments_ of calls) {
|
|
expect(arguments_.slice(0, 2)).toEqual(["-s", "MG4-HEAD-UNIT"]);
|
|
expect(arguments_).not.toContain("sh");
|
|
expect(arguments_).not.toContain("-c");
|
|
}
|
|
});
|
|
|
|
it("reports authorization and offline failures without leaking command output", async () => {
|
|
const unauthorized: AdbExecutor = async () => {
|
|
throw { stderr: "error: device unauthorized. private-debug-details" };
|
|
};
|
|
await expect(collectAdbStatus(enabledConfig, unauthorized)).resolves.toEqual({
|
|
available: false,
|
|
state: "unauthorized",
|
|
reason: "Authorize this Pi on the infotainment screen"
|
|
});
|
|
|
|
const offline: AdbExecutor = async () => ({ stdout: "offline\n", stderr: "" });
|
|
await expect(collectAdbStatus(enabledConfig, offline)).resolves.toMatchObject({
|
|
available: false,
|
|
state: "offline"
|
|
});
|
|
});
|
|
|
|
it("automatically discovers exactly one device and refuses ambiguous connections", async () => {
|
|
const calls: string[][] = [];
|
|
const execute: AdbExecutor = async (_executable, arguments_) => {
|
|
calls.push([...arguments_]);
|
|
if (arguments_[0] === "devices") {
|
|
return { stdout: "List of devices attached\nAUTO-MG4\tdevice product:saic model:autus\n", stderr: "" };
|
|
}
|
|
if (arguments_.at(-1) === "get-state") return { stdout: "device\n", stderr: "" };
|
|
return { stdout: "value\n", stderr: "" };
|
|
};
|
|
const status = await collectAdbStatus({ ...enabledConfig, serial: null }, execute);
|
|
expect(status).toMatchObject({ available: true, value: { state: "connected" } });
|
|
expect(calls[0]).toEqual(["devices"]);
|
|
for (const arguments_ of calls.slice(1)) expect(arguments_.slice(0, 2)).toEqual(["-s", "AUTO-MG4"]);
|
|
|
|
const multiple: AdbExecutor = async () => ({
|
|
stdout: "List of devices attached\nMG4\tdevice\nPHONE\tdevice\n",
|
|
stderr: ""
|
|
});
|
|
await expect(collectAdbStatus({ ...enabledConfig, serial: null }, multiple)).resolves.toMatchObject({
|
|
available: false,
|
|
state: "ambiguous"
|
|
});
|
|
});
|
|
|
|
it("reports an automatically discovered device awaiting authorization", async () => {
|
|
const execute: AdbExecutor = async () => ({
|
|
stdout: "List of devices attached\nAUTO-MG4\tunauthorized\n",
|
|
stderr: ""
|
|
});
|
|
await expect(collectAdbStatus({ ...enabledConfig, serial: null }, execute)).resolves.toMatchObject({
|
|
available: false,
|
|
state: "unauthorized"
|
|
});
|
|
});
|
|
|
|
it("keeps individual fields unavailable when a connected device only partially responds", async () => {
|
|
const execute: AdbExecutor = async (_executable, arguments_) => {
|
|
if (arguments_.at(-1) === "get-state") return { stdout: "device\n", stderr: "" };
|
|
if (arguments_.at(-1) === "ro.product.model") throw new Error("read failed");
|
|
return { stdout: "unexpected\n", stderr: "" };
|
|
};
|
|
const status = await collectAdbStatus(enabledConfig, execute);
|
|
expect(status).toMatchObject({
|
|
available: true,
|
|
value: { identity: { model: { available: false, reason: "ADB could not read model" } } }
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ADB dashboard operations", () => {
|
|
it("lists installed packages and identifies system paths", async () => {
|
|
const execute: AdbExecutor = async (_executable, arguments_) => {
|
|
expect(arguments_).toEqual(["-s", "MG4-HEAD-UNIT", "shell", "pm", "list", "packages", "-f"]);
|
|
return {
|
|
stdout: "package:/data/app/com.example/base.apk=com.example\npackage:/system/app/Settings/Settings.apk=com.android.settings\n",
|
|
stderr: ""
|
|
};
|
|
};
|
|
await expect(listInstalledAdbPackages(enabledConfig, execute)).resolves.toEqual([
|
|
{ packageName: "com.android.settings", apkPath: "/system/app/Settings/Settings.apk", system: true },
|
|
{ packageName: "com.example", apkPath: "/data/app/com.example/base.apk", system: false }
|
|
]);
|
|
});
|
|
|
|
it("launches validated packages and components without a shell", async () => {
|
|
const calls: string[][] = [];
|
|
const execute: AdbExecutor = async (_executable, arguments_) => {
|
|
calls.push([...arguments_]);
|
|
return { stdout: "", stderr: "" };
|
|
};
|
|
await launchAdbTarget(enabledConfig, { type: "component", value: "com.android.settings/.Settings" }, execute);
|
|
await launchAdbTarget(enabledConfig, { type: "package", value: "com.example.app" }, execute);
|
|
expect(calls).toEqual([
|
|
["-s", "MG4-HEAD-UNIT", "shell", "am", "start", "-n", "com.android.settings/.Settings"],
|
|
["-s", "MG4-HEAD-UNIT", "shell", "monkey", "-p", "com.example.app", "-c", "android.intent.category.LAUNCHER", "1"]
|
|
]);
|
|
await expect(launchAdbTarget(enabledConfig, { type: "component", value: "com.example/.Main;reboot" }, execute)).rejects.toThrow("INVALID_ADB_COMPONENT");
|
|
});
|
|
|
|
it("parses the verified MG4 live properties and marks SAIC-only data unavailable", async () => {
|
|
const execute: AdbExecutor = async (_executable, arguments_) => arguments_.includes("content")
|
|
? { stdout: "Result: Bundle[{status=ok, sampledAt=1785540000000, soc=73.5, rangeKm=286, speedKph=18.0, batteryVolts=397.4, totalConsumptionKwh=6.2, chargingStatus=0}]", stderr: "" }
|
|
: { stdout: "[arcsoft.avm.mCurCarSpeed]: [17]\n[arcsoft.avm.mCurCarGear]: [4]\n[arcsoft.avm.mCurCarWheelAngle]: [-12.5]\n", stderr: "" };
|
|
await expect(collectCarTelemetry(enabledConfig, execute)).resolves.toMatchObject({
|
|
available: true,
|
|
speedKph: { available: true, value: 18 },
|
|
gear: { available: true, value: "4" },
|
|
wheelAngleDegrees: { available: true, value: -12.5 },
|
|
batteryPercent: { available: true, value: 73.5 },
|
|
rangeKm: { available: true, value: 286 },
|
|
batteryVoltage: { available: true, value: 397.4 },
|
|
totalConsumptionKwh: { available: true, value: 6.2 },
|
|
chargingStatus: { available: true, value: 0 }
|
|
});
|
|
});
|
|
|
|
it("accepts only ZIP-formatted APK payloads and removes the temporary file", async () => {
|
|
let temporaryPath = "";
|
|
const execute: AdbExecutor = async (_executable, arguments_, options) => {
|
|
temporaryPath = arguments_.at(-1) ?? "";
|
|
expect(arguments_.slice(0, 4)).toEqual(["-s", "MG4-HEAD-UNIT", "install", "-r"]);
|
|
expect(existsSync(temporaryPath)).toBe(true);
|
|
expect(options.timeout).toBe(120_000);
|
|
return { stdout: "Success\n", stderr: "" };
|
|
};
|
|
await installAdbApk(enabledConfig, Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00]), execute);
|
|
expect(existsSync(temporaryPath)).toBe(false);
|
|
await expect(installAdbApk(enabledConfig, Buffer.from("not an apk"), execute)).rejects.toThrow("INVALID_APK");
|
|
});
|
|
});
|