Skip to content

Commit 8aebf00

Browse files
committed
test: discover android launch target on ci
1 parent 9aada83 commit 8aebf00

2 files changed

Lines changed: 199 additions & 20 deletions

File tree

‎scripts/github-actions.test.mjs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,9 @@ test("Android integration runner resolves Windows executables", () => {
114114
androidIntegration,
115115
/fallbackAvdName && avds\.size === 0 && devices\.length === 1/,
116116
);
117-
assert.match(androidIntegration, /com\.android\.settings\/\.Settings/);
117+
assert.match(androidIntegration, /resolveAndroidLaunchTarget/);
118+
assert.match(androidIntegration, /query-activities/);
119+
assert.match(androidIntegration, /isAndroidIntentUnavailable/);
118120
assert.match(androidIntegration, /simdeck-bin\.exe/);
119121
assert.match(androidIntegration, /simdeck-bin-win32-x64\.exe/);
120122
assert.match(androidIntegration, /AppData", "Local", "Android", "Sdk/);

‎scripts/integration/android.mjs‎

Lines changed: 196 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ const requireRunningAndroid =
1616
process.env.SIMDECK_INTEGRATION_REQUIRE_RUNNING_ANDROID === "1" ||
1717
process.env.CI === "true";
1818
const requestedAvd = process.env.SIMDECK_INTEGRATION_ANDROID_AVD;
19-
const androidLaunchTarget =
20-
process.env.SIMDECK_INTEGRATION_ANDROID_LAUNCH_TARGET ??
21-
"com.android.settings/.Settings";
19+
const requestedAndroidLaunchTarget =
20+
process.env.SIMDECK_INTEGRATION_ANDROID_LAUNCH_TARGET;
2221
const defaultStepTimeoutMs = Number(
2322
process.env.SIMDECK_INTEGRATION_STEP_TIMEOUT_MS ?? "180000",
2423
);
2524

2625
let session = null;
2726
let androidUDID = "";
27+
let androidLaunchTarget = null;
2828
let shutdownAndroidAfterRun = false;
2929
const stepTimings = [];
3030

@@ -106,6 +106,12 @@ async function main() {
106106
{ timeoutMs: target.isBooted ? 120_000 : 300_000 },
107107
);
108108

109+
androidLaunchTarget = await measuredStep(
110+
"resolve Android launch target",
111+
() => resolveAndroidLaunchTarget(),
112+
{ timeoutMs: 60_000 },
113+
);
114+
109115
await runCliSurface();
110116
await runJsSurface();
111117
console.log("SimDeck Android integration suite passed");
@@ -216,12 +222,35 @@ async function runCliSurface() {
216222
}
217223
});
218224
await measuredStep("CLI app launch and URL", () => {
219-
simdeckJson(["launch", androidUDID, androidLaunchTarget], {
220-
timeoutMs: 60_000,
221-
});
222-
simdeckJson(["open-url", androidUDID, "https://example.com"], {
223-
timeoutMs: 60_000,
224-
});
225+
if (androidLaunchTarget) {
226+
try {
227+
simdeckJson(["launch", androidUDID, androidLaunchTarget], {
228+
timeoutMs: 60_000,
229+
});
230+
} catch (error) {
231+
if (
232+
requestedAndroidLaunchTarget ||
233+
!isAndroidIntentUnavailable(error)
234+
) {
235+
throw error;
236+
}
237+
console.log(
238+
`Android image rejected discovered launch target ${androidLaunchTarget}; continuing with URL/home coverage.`,
239+
);
240+
}
241+
} else {
242+
console.log("Android image did not expose a launchable activity.");
243+
}
244+
try {
245+
simdeckJson(["open-url", androidUDID, "https://example.com"], {
246+
timeoutMs: 60_000,
247+
});
248+
} catch (error) {
249+
if (!isAndroidIntentUnavailable(error)) {
250+
throw error;
251+
}
252+
console.log("Android image did not expose an https URL handler.");
253+
}
225254
simdeckJson(["home", androidUDID]);
226255
});
227256
await measuredStep("CLI pointer gestures", () => {
@@ -358,8 +387,29 @@ async function runJsSurface() {
358387
}
359388
});
360389
await measuredStep("JS app launch and URL", async () => {
361-
await session.launch(androidUDID, androidLaunchTarget);
362-
await session.openUrl(androidUDID, "https://example.com");
390+
if (androidLaunchTarget) {
391+
try {
392+
await session.launch(androidUDID, androidLaunchTarget);
393+
} catch (error) {
394+
if (
395+
requestedAndroidLaunchTarget ||
396+
!isAndroidIntentUnavailable(error)
397+
) {
398+
throw error;
399+
}
400+
console.log(
401+
`Android image rejected discovered launch target ${androidLaunchTarget}; continuing with URL/home coverage.`,
402+
);
403+
}
404+
}
405+
try {
406+
await session.openUrl(androidUDID, "https://example.com");
407+
} catch (error) {
408+
if (!isAndroidIntentUnavailable(error)) {
409+
throw error;
410+
}
411+
console.log("Android image did not expose an https URL handler.");
412+
}
363413
await session.home(androidUDID);
364414
});
365415
await measuredStep("JS pointer gestures", async () => {
@@ -461,19 +511,128 @@ function resolveAndroidDevice() {
461511
: null;
462512
}
463513

514+
function resolveAndroidLaunchTarget() {
515+
if (requestedAndroidLaunchTarget) {
516+
return requestedAndroidLaunchTarget;
517+
}
518+
const adb = androidSdkTool("platform-tools/adb");
519+
if (!adb) {
520+
return null;
521+
}
522+
const serial = onlineAndroidSerials(adb)[0];
523+
if (!serial) {
524+
return null;
525+
}
526+
const queries = [
527+
[
528+
"cmd",
529+
"package",
530+
"query-activities",
531+
"--brief",
532+
"--components",
533+
"-a",
534+
"android.intent.action.MAIN",
535+
"-c",
536+
"android.intent.category.LAUNCHER",
537+
],
538+
[
539+
"cmd",
540+
"package",
541+
"query-activities",
542+
"--brief",
543+
"--components",
544+
"-a",
545+
"android.intent.action.MAIN",
546+
],
547+
[
548+
"cmd",
549+
"package",
550+
"resolve-activity",
551+
"--brief",
552+
"--components",
553+
"-a",
554+
"android.intent.action.MAIN",
555+
"-c",
556+
"android.intent.category.LAUNCHER",
557+
],
558+
[
559+
"cmd",
560+
"package",
561+
"resolve-activity",
562+
"--brief",
563+
"--components",
564+
"-a",
565+
"android.intent.action.MAIN",
566+
],
567+
];
568+
for (const query of queries) {
569+
try {
570+
const output = runText(adb, ["-s", serial, "shell", ...query], {
571+
timeoutMs: 30_000,
572+
});
573+
const target = chooseAndroidLaunchComponent(
574+
parseAndroidActivityComponents(output),
575+
);
576+
if (target) {
577+
if (verbose) {
578+
console.log(`Android launch target ${target}`);
579+
}
580+
return target;
581+
}
582+
} catch (error) {
583+
if (verbose) {
584+
console.log(
585+
`Android launch target query failed: ${error.message.split("\n")[0]}`,
586+
);
587+
}
588+
}
589+
}
590+
return null;
591+
}
592+
593+
function parseAndroidActivityComponents(output) {
594+
const components = [];
595+
for (const line of output.split(/\r?\n/)) {
596+
for (const token of line.trim().split(/\s+/)) {
597+
if (
598+
/^[A-Za-z0-9_.]+\/(?:[A-Za-z0-9_.$]+|\.[A-Za-z0-9_.$]+)$/.test(token)
599+
) {
600+
components.push(token);
601+
}
602+
}
603+
}
604+
return [...new Set(components)];
605+
}
606+
607+
function chooseAndroidLaunchComponent(components) {
608+
const preferredPackages = [
609+
"com.android.settings/",
610+
"com.google.android.apps.nexuslauncher/",
611+
"com.android.launcher3/",
612+
];
613+
for (const packagePrefix of preferredPackages) {
614+
const component = components.find((value) =>
615+
value.startsWith(packagePrefix),
616+
);
617+
if (component) {
618+
return component;
619+
}
620+
}
621+
return (
622+
components.find(
623+
(value) =>
624+
!value.startsWith("com.android.systemui/") &&
625+
!value.startsWith("android/"),
626+
) ?? null
627+
);
628+
}
629+
464630
function runningAndroidAvds(fallbackAvdName = "") {
465631
const adb = androidSdkTool("platform-tools/adb");
466632
if (!adb) {
467633
return new Set();
468634
}
469-
const devices = runText(adb, ["devices"], { timeoutMs: 30_000 })
470-
.split(/\r?\n/)
471-
.map((line) => line.trim().split(/\s+/))
472-
.filter(
473-
([serial, state]) =>
474-
serial?.startsWith("emulator-") && state === "device",
475-
)
476-
.map(([serial]) => serial);
635+
const devices = onlineAndroidSerials(adb);
477636
const avds = new Set();
478637
for (const serial of devices) {
479638
const name = androidAvdNameForSerial(adb, serial);
@@ -487,6 +646,17 @@ function runningAndroidAvds(fallbackAvdName = "") {
487646
return avds;
488647
}
489648

649+
function onlineAndroidSerials(adb) {
650+
return runText(adb, ["devices"], { timeoutMs: 30_000 })
651+
.split(/\r?\n/)
652+
.map((line) => line.trim().split(/\s+/))
653+
.filter(
654+
([serial, state]) =>
655+
serial?.startsWith("emulator-") && state === "device",
656+
)
657+
.map(([serial]) => serial);
658+
}
659+
490660
function androidAvdNameForSerial(adb, serial) {
491661
for (const property of ["ro.boot.qemu.avd_name", "ro.kernel.qemu.avd_name"]) {
492662
try {
@@ -716,6 +886,13 @@ function assertClipboardUnsupported(errorOrResult) {
716886
);
717887
}
718888

889+
function isAndroidIntentUnavailable(error) {
890+
const message = error instanceof Error ? error.message : String(error);
891+
return /unable to resolve Intent|No Activity found|Activity class .* does not exist/i.test(
892+
message,
893+
);
894+
}
895+
719896
function assertAndroidListed(payload, udid) {
720897
assert.ok(
721898
simulatorList(payload).some(

0 commit comments

Comments
 (0)