Skip to content

Commit 43bfaab

Browse files
committed
feat(quest-tools): install and launch a PR's CI APK
1 parent ce15f59 commit 43bfaab

2 files changed

Lines changed: 167 additions & 8 deletions

File tree

‎.claude/skills/vr-device-loop/scripts/quest-device.mjs‎

Lines changed: 117 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
#!/usr/bin/env node
22

33
import { execFileSync } from "node:child_process";
4-
import { mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
4+
import {
5+
existsSync,
6+
mkdtempSync,
7+
renameSync,
8+
rmSync,
9+
unlinkSync,
10+
writeFileSync,
11+
} from "node:fs";
512
import { tmpdir } from "node:os";
613
import { dirname, resolve } from "node:path";
714
import { pathToFileURL } from "node:url";
815

916
export const PACKAGE = "com.tommybuilds.shock2quest";
1017
export const ACTIVITY = `${PACKAGE}/android.app.NativeActivity`;
1118
export const MISSION_CONFIG = "/sdcard/shock2quest/vr-mission.txt";
19+
export const DEFAULT_MISSION = "medsci1.mis";
1220

1321
const delay = (milliseconds) =>
1422
new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
@@ -453,6 +461,104 @@ export function deviceStatus(serial) {
453461
};
454462
}
455463

464+
// Uploaded by .github/workflows/build-android.yml.
465+
export function apkArtifactName(sha) {
466+
return `shock2quest-apk-${sha}`;
467+
}
468+
469+
// Picks a successful Build Android run from `gh run list --commit SHA` rows
470+
// (newest first); otherwise throws, so a stale build is never installed.
471+
export function selectBuildRun(runs, sha) {
472+
const succeeded = runs.find(
473+
(run) => run.status === "completed" && run.conclusion === "success",
474+
);
475+
if (succeeded) return succeeded;
476+
const latest = runs[0];
477+
if (!latest) {
478+
throw new Error(
479+
`no Build Android run for ${sha} (markdown-only pushes skip it)`,
480+
);
481+
}
482+
if (latest.status !== "completed") {
483+
throw new Error(`Build Android for ${sha} is still ${latest.status}: ${latest.url}`);
484+
}
485+
throw new Error(`Build Android for ${sha} ended ${latest.conclusion}: ${latest.url}`);
486+
}
487+
488+
// A CI APK (release key) cannot update a locally built one (debug key).
489+
export function isSignatureMismatch(output) {
490+
return output.includes("INSTALL_FAILED_UPDATE_INCOMPATIBLE");
491+
}
492+
493+
function gh(args) {
494+
return execFileSync("gh", args, {
495+
encoding: "utf8",
496+
stdio: ["ignore", "pipe", "pipe"],
497+
});
498+
}
499+
500+
// Downloads PR NUMBER's CI APK into a per-SHA temp cache; returns its path.
501+
export function fetchPrApk(number) {
502+
const { headRefOid: sha, headRefName } = JSON.parse(
503+
gh(["pr", "view", number, "--json", "headRefOid,headRefName"]),
504+
);
505+
const cacheDirectory = resolve(tmpdir(), "shock2quest-pr-apk", sha);
506+
const apkPath = resolve(cacheDirectory, "shock2quest.apk");
507+
process.stderr.write(`PR #${number} ${headRefName} @ ${sha}\n`);
508+
if (existsSync(apkPath)) return apkPath;
509+
510+
const runs = JSON.parse(
511+
gh([
512+
"run",
513+
"list",
514+
"--workflow",
515+
"build-android.yml",
516+
"--commit",
517+
sha,
518+
"--json",
519+
"databaseId,status,conclusion,url",
520+
]),
521+
);
522+
const run = selectBuildRun(runs, sha);
523+
// Download beside the cache so an interrupted download is never reused.
524+
const partial = `${cacheDirectory}.partial`;
525+
rmSync(partial, { recursive: true, force: true });
526+
gh([
527+
"run",
528+
"download",
529+
String(run.databaseId),
530+
"-n",
531+
apkArtifactName(sha),
532+
"-D",
533+
partial,
534+
]);
535+
renameSync(partial, cacheDirectory);
536+
return apkPath;
537+
}
538+
539+
export function installApk(serial, apkPath) {
540+
// -g grants storage up front; a fresh install would otherwise prompt.
541+
const install = () => adb(serial, ["install", "-r", "-g", apkPath]);
542+
try {
543+
install();
544+
} catch (error) {
545+
const output = `${error.message}${error.stdout ?? ""}${error.stderr ?? ""}`;
546+
if (!isSignatureMismatch(output)) throw error;
547+
process.stderr.write(
548+
"installed APK has another signature; uninstalling it first (this deletes its app-private saves)\n",
549+
);
550+
adb(serial, ["uninstall", PACKAGE]);
551+
install();
552+
}
553+
}
554+
555+
function takeOption(args, name) {
556+
const index = args.indexOf(name);
557+
if (index === -1) return undefined;
558+
const [, value] = args.splice(index, 2);
559+
return value;
560+
}
561+
456562
function usage() {
457563
return `Usage:
458564
quest-device.mjs [--serial SERIAL] status
@@ -463,17 +569,13 @@ function usage() {
463569
quest-device.mjs [--serial SERIAL] record OUTPUT.mp4 [SECONDS]
464570
quest-device.mjs [--serial SERIAL] stop
465571
quest-device.mjs [--serial SERIAL] restore
466-
quest-device.mjs [--serial SERIAL] reset-mission`;
572+
quest-device.mjs [--serial SERIAL] reset-mission
573+
quest-device.mjs pr NUMBER [--mission MISSION] [--serial SERIAL]`;
467574
}
468575

469576
async function main(argv) {
470577
const args = [...argv];
471-
let explicitSerial;
472-
if (args[0] === "--serial") {
473-
explicitSerial = args[1];
474-
args.splice(0, 2);
475-
}
476-
const serial = resolveSerial(explicitSerial);
578+
const serial = resolveSerial(takeOption(args, "--serial"));
477579
const [command, ...values] = args;
478580

479581
if (command === "status") {
@@ -497,6 +599,13 @@ async function main(argv) {
497599
} else if (command === "record" && values[0]) {
498600
const seconds = values[1] === undefined ? 8 : Number(values[1]);
499601
process.stdout.write(`${recordVideo(serial, values[0], seconds)}\n`);
602+
} else if (command === "pr" && /^\d+$/.test(values[0] ?? "")) {
603+
const mission = validateMission(
604+
takeOption(values, "--mission") ?? DEFAULT_MISSION,
605+
);
606+
installApk(serial, fetchPrApk(values[0]));
607+
const { logs: _logs, ...summary } = await launchMission(serial, mission);
608+
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
500609
} else if (command === "stop") {
501610
stopApp(serial);
502611
} else if (command === "restore") {

‎.claude/skills/vr-device-loop/scripts/quest-device.test.mjs‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import { beforeEach, test } from "node:test";
66

77
import {
88
MISSION_CONFIG,
9+
apkArtifactName,
10+
isSignatureMismatch,
911
missionSelection,
1012
restoreMissionSelection,
13+
selectBuildRun,
1114
selectMission,
1215
} from "./quest-device.mjs";
1316

@@ -100,3 +103,50 @@ test("unexpected probe output cannot be treated as an absent selector", () => {
100103
device.failure = "probe";
101104
assert.throws(() => missionSelection("test-device"), /mission selector/);
102105
});
106+
107+
const SHA = "0123456789abcdef0123456789abcdef01234567";
108+
const run = (databaseId, status, conclusion) => ({
109+
databaseId,
110+
status,
111+
conclusion,
112+
url: `https://example.test/runs/${databaseId}`,
113+
});
114+
115+
test("artifact name carries the full head SHA", () => {
116+
assert.equal(apkArtifactName(SHA), `shock2quest-apk-${SHA}`);
117+
});
118+
119+
test("picks a successful run for the SHA", () => {
120+
const runs = [run(2, "completed", "failure"), run(1, "completed", "success")];
121+
assert.equal(selectBuildRun(runs, SHA).databaseId, 1);
122+
});
123+
124+
test("refuses when the SHA has no run", () => {
125+
assert.throws(() => selectBuildRun([], SHA), /no Build Android run/);
126+
});
127+
128+
test("refuses an in-progress run and names its URL", () => {
129+
assert.throws(
130+
() => selectBuildRun([run(3, "in_progress", "")], SHA),
131+
/still in_progress: https:\/\/example.test\/runs\/3/,
132+
);
133+
});
134+
135+
test("refuses a failed run and names its URL", () => {
136+
assert.throws(
137+
() => selectBuildRun([run(4, "completed", "failure")], SHA),
138+
/failure: https:\/\/example.test\/runs\/4/,
139+
);
140+
});
141+
142+
test("only a signature mismatch triggers the uninstall fallback", () => {
143+
assert.ok(
144+
isSignatureMismatch(
145+
"adb: failed to install x.apk: Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE: Existing package signatures do not match newer version; ignoring!]",
146+
),
147+
);
148+
assert.equal(
149+
isSignatureMismatch("Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE]"),
150+
false,
151+
);
152+
});

0 commit comments

Comments
 (0)