@loadmill/droid-cua 2.43.0 → 2.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -7
- package/build/index.js +39 -15
- package/build/src/cli/device-selector.js +13 -6
- package/build/src/cli/headless-target.js +24 -0
- package/build/src/cli/ios-device-command.js +121 -0
- package/build/src/cli/ios-device-list.js +32 -0
- package/build/src/cli/network-capture-device.js +21 -0
- package/build/src/commands/help.js +14 -3
- package/build/src/core/execution-engine.js +3 -0
- package/build/src/desktop-shared/ios-real-device.js +82 -0
- package/build/src/device/factory.js +4 -0
- package/build/src/device/ios/actions.js +48 -12
- package/build/src/device/ios/appium-server.js +7 -18
- package/build/src/device/ios/connection.js +71 -12
- package/build/src/device/ios/node-cli-env.js +55 -0
- package/build/src/device/ios/real-device/device-state.js +40 -0
- package/build/src/device/ios/real-device/devicectl.js +154 -0
- package/build/src/device/ios/real-device/readiness.js +439 -0
- package/build/src/device/ios/real-device/session.js +176 -0
- package/build/src/device/ios/real-device/signing.js +466 -0
- package/build/src/device/ios/real-device/wda-provisioning.js +229 -0
- package/build/src/network/certificate-authority.js +21 -1
- package/build/src/network/certificate-install.js +38 -0
- package/build/src/network/local-proxy.js +21 -1
- package/build/src/network/run-network-capture.js +16 -5
- package/package.json +1 -1
- package/skill/droid-cua/SKILL.md +2 -2
- package/skill/droid-cua/references/authoring-tests.md +1 -1
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +31 -0
- package/skill/droid-cua/references/running-and-healing.md +11 -2
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { access, copyFile, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { chooseSigning, PROFILE_DIRS, runProvisioningBuild, wdaRunnerBundleId } from "./signing.js";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
export const APPIUM_WDA_DIR = path.join(os.homedir(), ".appium/node_modules/appium-xcuitest-driver/node_modules/appium-webdriveragent");
|
|
10
|
+
const DATA_DIR = path.join(os.homedir(), ".droid-cua", "ios-devices");
|
|
11
|
+
const ZIP_NAME = "WebDriverAgentRunner-Runner.zip";
|
|
12
|
+
const APP_NAME = "WebDriverAgentRunner-Runner.app";
|
|
13
|
+
const XCTEST_NAME = "WebDriverAgentRunner.xctest";
|
|
14
|
+
const RETRYABLE_BUILD_ERRORS = ["certificate-missing", "unknown"];
|
|
15
|
+
const defaultRunner = (cmd, args) => execFileAsync(cmd, args, { maxBuffer: 64 * 1024 * 1024 });
|
|
16
|
+
let runner = defaultRunner;
|
|
17
|
+
export function __setWdaRunner(run = null) {
|
|
18
|
+
runner = run || defaultRunner;
|
|
19
|
+
}
|
|
20
|
+
export class WdaProvisioningError extends Error {
|
|
21
|
+
constructor({ kind, message, details }) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "WdaProvisioningError";
|
|
24
|
+
this.kind = kind;
|
|
25
|
+
if (details)
|
|
26
|
+
this.details = details;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function exists(file) {
|
|
30
|
+
return access(file).then(() => true, () => false);
|
|
31
|
+
}
|
|
32
|
+
function sha256(bytes) {
|
|
33
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
export function wdaBundleIdBase(teamId) {
|
|
36
|
+
return `com.droidcua.${teamId.toLowerCase()}.WebDriverAgentRunner`;
|
|
37
|
+
}
|
|
38
|
+
export function wdaCacheDir(version) {
|
|
39
|
+
return path.join(DATA_DIR, "wda", version);
|
|
40
|
+
}
|
|
41
|
+
export function wdaDownloadUrl(version) {
|
|
42
|
+
return `https://github.com/appium/WebDriverAgent/releases/download/v${version}/${ZIP_NAME}`;
|
|
43
|
+
}
|
|
44
|
+
export async function getPinnedWdaVersion({ wdaDir = APPIUM_WDA_DIR } = {}) {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(await readFile(path.join(wdaDir, "package.json"), "utf8")).version;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new WdaProvisioningError({
|
|
50
|
+
kind: "wda-not-installed",
|
|
51
|
+
message: "Appium's XCUITest driver isn't installed, so droid can't tell which WebDriverAgent version to use.",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export async function downloadWda({ version, cacheDir = wdaCacheDir(version), fetchImpl = fetch }) {
|
|
56
|
+
const zipPath = path.join(cacheDir, ZIP_NAME);
|
|
57
|
+
const recordPath = path.join(cacheDir, "download.json");
|
|
58
|
+
const record = await readFile(recordPath, "utf8").then(JSON.parse, () => null);
|
|
59
|
+
if (record?.sha256 && (await exists(zipPath))) {
|
|
60
|
+
const actual = sha256(await readFile(zipPath));
|
|
61
|
+
if (actual !== record.sha256) {
|
|
62
|
+
throw new WdaProvisioningError({
|
|
63
|
+
kind: "checksum-mismatch",
|
|
64
|
+
message: `The cached WebDriverAgent ${version} download doesn't match its recorded checksum. Delete ${cacheDir} and retry.`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return { zipPath, sha256: actual, downloaded: false };
|
|
68
|
+
}
|
|
69
|
+
const url = wdaDownloadUrl(version);
|
|
70
|
+
const response = await fetchImpl(url);
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
throw new WdaProvisioningError({
|
|
73
|
+
kind: "download-failed",
|
|
74
|
+
message: `Downloading WebDriverAgent ${version} failed (HTTP ${response.status} from ${url}).`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
78
|
+
const actual = sha256(bytes);
|
|
79
|
+
if (record?.sha256 && actual !== record.sha256) {
|
|
80
|
+
throw new WdaProvisioningError({
|
|
81
|
+
kind: "checksum-mismatch",
|
|
82
|
+
message: `The WebDriverAgent ${version} download doesn't match the checksum recorded on first download. Delete ${cacheDir} and retry.`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
await mkdir(cacheDir, { recursive: true });
|
|
86
|
+
const tempPath = `${zipPath}.${process.pid}.tmp`;
|
|
87
|
+
await writeFile(tempPath, bytes);
|
|
88
|
+
await rename(tempPath, zipPath);
|
|
89
|
+
if (!record) {
|
|
90
|
+
await writeFile(recordPath, `${JSON.stringify({ version, url, sha256: actual, bytes: bytes.length, downloadedAt: new Date().toISOString() }, null, 2)}\n`);
|
|
91
|
+
}
|
|
92
|
+
return { zipPath, sha256: actual, downloaded: true };
|
|
93
|
+
}
|
|
94
|
+
async function innerCode(dir) {
|
|
95
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
96
|
+
const found = [];
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
const full = path.join(dir, entry.name);
|
|
99
|
+
if (entry.isDirectory()) {
|
|
100
|
+
found.push(...(await innerCode(full)));
|
|
101
|
+
if (entry.name.endsWith(".framework"))
|
|
102
|
+
found.push(full);
|
|
103
|
+
}
|
|
104
|
+
else if (entry.name.endsWith(".dylib")) {
|
|
105
|
+
found.push(full);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return found;
|
|
109
|
+
}
|
|
110
|
+
export function runnerEntitlementsPlist(teamId, bundleIdBase) {
|
|
111
|
+
const appId = `${teamId}.${wdaRunnerBundleId(bundleIdBase)}`;
|
|
112
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
113
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
114
|
+
<plist version="1.0">
|
|
115
|
+
<dict>
|
|
116
|
+
<key>application-identifier</key>
|
|
117
|
+
<string>${appId}</string>
|
|
118
|
+
<key>com.apple.developer.team-identifier</key>
|
|
119
|
+
<string>${teamId}</string>
|
|
120
|
+
<key>get-task-allow</key>
|
|
121
|
+
<true/>
|
|
122
|
+
<key>keychain-access-groups</key>
|
|
123
|
+
<array>
|
|
124
|
+
<string>${appId}</string>
|
|
125
|
+
</array>
|
|
126
|
+
</dict>
|
|
127
|
+
</plist>
|
|
128
|
+
`;
|
|
129
|
+
}
|
|
130
|
+
function codesign(identity, target, extraArgs = []) {
|
|
131
|
+
return runner("codesign", ["--force", "--sign", identity.sha1, "--timestamp=none", "--generate-entitlement-der", ...extraArgs, target]);
|
|
132
|
+
}
|
|
133
|
+
export async function prepareSignedWda({ version, bundleIdBase, identity, profile, workDir, zipPath = path.join(wdaCacheDir(version), ZIP_NAME) }) {
|
|
134
|
+
const appPath = path.join(workDir, APP_NAME);
|
|
135
|
+
const xctestPath = path.join(appPath, "PlugIns", XCTEST_NAME);
|
|
136
|
+
await rm(appPath, { recursive: true, force: true });
|
|
137
|
+
await mkdir(workDir, { recursive: true });
|
|
138
|
+
await runner("unzip", ["-q", "-o", zipPath, "-d", workDir]);
|
|
139
|
+
if (!(await exists(appPath))) {
|
|
140
|
+
throw new WdaProvisioningError({ kind: "bad-download", message: `${APP_NAME} was not found in the WebDriverAgent ${version} download.` });
|
|
141
|
+
}
|
|
142
|
+
await runner("plutil", ["-replace", "CFBundleIdentifier", "-string", wdaRunnerBundleId(bundleIdBase), path.join(appPath, "Info.plist")]);
|
|
143
|
+
await runner("plutil", ["-replace", "CFBundleIdentifier", "-string", bundleIdBase, path.join(xctestPath, "Info.plist")]);
|
|
144
|
+
await rm(path.join(appPath, "PlugIns", `${XCTEST_NAME}.dSYM`), { recursive: true, force: true });
|
|
145
|
+
const inner = [...(await innerCode(path.join(xctestPath, "Frameworks"))), ...(await innerCode(path.join(appPath, "Frameworks")))];
|
|
146
|
+
for (const target of inner)
|
|
147
|
+
await codesign(identity, target);
|
|
148
|
+
await codesign(identity, xctestPath);
|
|
149
|
+
const entitlementsPath = path.join(workDir, "entitlements.plist");
|
|
150
|
+
await writeFile(entitlementsPath, runnerEntitlementsPlist(profile.teamId, bundleIdBase));
|
|
151
|
+
await copyFile(profile.path, path.join(appPath, "embedded.mobileprovision"));
|
|
152
|
+
await codesign(identity, appPath, ["--entitlements", entitlementsPath]);
|
|
153
|
+
try {
|
|
154
|
+
await runner("codesign", ["--verify", "--deep", "--strict", appPath]);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
throw new WdaProvisioningError({
|
|
158
|
+
kind: "signature-invalid",
|
|
159
|
+
message: `The signed WebDriverAgent failed verification: ${(error.stderr || error.message || "").trim()}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return appPath;
|
|
163
|
+
}
|
|
164
|
+
export async function ensureSigning({ udid, teamId, bundleIdBase = wdaBundleIdBase(teamId), wdaDir = APPIUM_WDA_DIR, derivedDataPath = path.join(DATA_DIR, "provisioning-build", teamId), profileDirs = PROFILE_DIRS, onProgress = () => { }, onOutput, }) {
|
|
165
|
+
onProgress({ step: "signing", message: "Looking for your signing certificate and provisioning profile" });
|
|
166
|
+
const found = await chooseSigning({ teamId, bundleIdBase, udid, profileDirs });
|
|
167
|
+
if (found.profile)
|
|
168
|
+
return { ...found, provisioningBuild: null };
|
|
169
|
+
onProgress({ step: "provisioning-build", message: `${found.reason} Running the one-time signing build (about a minute).` });
|
|
170
|
+
let attempts = 0;
|
|
171
|
+
let durationMs = 0;
|
|
172
|
+
let build;
|
|
173
|
+
do {
|
|
174
|
+
attempts++;
|
|
175
|
+
build = await runProvisioningBuild({ teamId, bundleIdBase, udid, wdaProjectDir: wdaDir, derivedDataPath, onOutput });
|
|
176
|
+
durationMs += build.durationMs;
|
|
177
|
+
} while (!build.ok && attempts < 2 && RETRYABLE_BUILD_ERRORS.includes(build.error.kind));
|
|
178
|
+
if (!build.ok)
|
|
179
|
+
throw new WdaProvisioningError(build.error);
|
|
180
|
+
onProgress({ step: "signing", message: "Looking for the provisioning profile Xcode created" });
|
|
181
|
+
const after = await chooseSigning({ teamId, bundleIdBase, udid, profileDirs });
|
|
182
|
+
if (!after.profile) {
|
|
183
|
+
throw new WdaProvisioningError({
|
|
184
|
+
kind: "profile-missing",
|
|
185
|
+
message: `The signing build passed but no usable profile was found. ${after.reason}`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return { ...after, provisioningBuild: { attempts, durationMs } };
|
|
189
|
+
}
|
|
190
|
+
export async function provisionWda({ udid, teamId, bundleIdBase = wdaBundleIdBase(teamId), version, wdaDir = APPIUM_WDA_DIR, cacheDir, workDir, derivedDataPath = path.join(DATA_DIR, "provisioning-build", teamId), profileDirs = PROFILE_DIRS, fetchImpl = fetch, onProgress = () => { }, onOutput, installApp, listApps, }) {
|
|
191
|
+
installApp ??= (await import("./devicectl.js")).installApp;
|
|
192
|
+
listApps ??= (await import("./devicectl.js")).listApps;
|
|
193
|
+
const runnerBundleId = wdaRunnerBundleId(bundleIdBase);
|
|
194
|
+
const signing = await ensureSigning({ udid, teamId, bundleIdBase, wdaDir, derivedDataPath, profileDirs, onProgress, onOutput });
|
|
195
|
+
version ??= await getPinnedWdaVersion({ wdaDir });
|
|
196
|
+
cacheDir ??= wdaCacheDir(version);
|
|
197
|
+
onProgress({ step: "download", message: `Getting WebDriverAgent ${version}` });
|
|
198
|
+
const download = await downloadWda({ version, cacheDir, fetchImpl });
|
|
199
|
+
onProgress({ step: "sign", message: `Signing WebDriverAgent as ${signing.identity.name}` });
|
|
200
|
+
const appPath = await prepareSignedWda({
|
|
201
|
+
version,
|
|
202
|
+
bundleIdBase,
|
|
203
|
+
identity: signing.identity,
|
|
204
|
+
profile: signing.profile,
|
|
205
|
+
workDir: workDir ?? path.join(cacheDir, "signed", teamId),
|
|
206
|
+
zipPath: download.zipPath,
|
|
207
|
+
});
|
|
208
|
+
onProgress({ step: "install", message: "Installing WebDriverAgent on the iPhone" });
|
|
209
|
+
await installApp(udid, appPath);
|
|
210
|
+
onProgress({ step: "confirm", message: "Checking WebDriverAgent is on the iPhone" });
|
|
211
|
+
const apps = await listApps(udid, { includeAll: true });
|
|
212
|
+
if (!apps.some((app) => app.bundleIdentifier === runnerBundleId)) {
|
|
213
|
+
throw new WdaProvisioningError({
|
|
214
|
+
kind: "install-not-confirmed",
|
|
215
|
+
message: `WebDriverAgent (${runnerBundleId}) was installed but doesn't show up in the iPhone's app list.`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
runnerBundleId,
|
|
220
|
+
bundleIdBase,
|
|
221
|
+
version,
|
|
222
|
+
sha256: download.sha256,
|
|
223
|
+
appPath,
|
|
224
|
+
identity: signing.identity,
|
|
225
|
+
profile: signing.profile,
|
|
226
|
+
signingReason: signing.reason,
|
|
227
|
+
provisioningBuild: signing.provisioningBuild,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -10,6 +10,8 @@ export function getCertificatePaths(directory = path.join(os.homedir(), ".droid-
|
|
|
10
10
|
privateKey: path.join(directory, "ca-key.pem"),
|
|
11
11
|
displayName: path.join(directory, "ca-name.json"),
|
|
12
12
|
preparedDevices: path.join(directory, "prepared-devices.json"),
|
|
13
|
+
iphoneCertificates: path.join(directory, "iphone"),
|
|
14
|
+
androidCertificates: path.join(directory, "android"),
|
|
13
15
|
};
|
|
14
16
|
}
|
|
15
17
|
function inspectCertificate(certificate, privateKey) {
|
|
@@ -163,7 +165,25 @@ export async function generateCertificateAuthority(directory) {
|
|
|
163
165
|
}
|
|
164
166
|
export async function removeCertificateAuthority(directory) {
|
|
165
167
|
const paths = getCertificatePaths(directory);
|
|
166
|
-
await Promise.all([paths.certificate, paths.privateKey, paths.displayName, paths.preparedDevices].map((file) => rm(file, { force: true })));
|
|
168
|
+
await Promise.all([paths.certificate, paths.privateKey, paths.displayName, paths.preparedDevices, paths.iphoneCertificates, paths.androidCertificates].map((file) => rm(file, { recursive: true, force: true })));
|
|
169
|
+
}
|
|
170
|
+
export function certificateCommonName(certificatePem) {
|
|
171
|
+
const value = forge.pki.certificateFromPem(certificatePem).subject.getField("CN")?.value;
|
|
172
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
173
|
+
}
|
|
174
|
+
async function writeDeviceCertificate(folder, name, extension, contents) {
|
|
175
|
+
const fileName = `${name.replace(/[^A-Za-z0-9 ._-]+/g, "_").replace(/^[\s.]+|[\s.]+$/g, "") || "Droid CUA Root"}${extension}`;
|
|
176
|
+
const target = path.join(folder, fileName);
|
|
177
|
+
await rm(folder, { recursive: true, force: true });
|
|
178
|
+
await mkdir(folder, { recursive: true, mode: 0o700 });
|
|
179
|
+
await writeFile(target, contents, { mode: 0o600 });
|
|
180
|
+
return target;
|
|
181
|
+
}
|
|
182
|
+
export async function writeIphoneCertificate(certificatePem, name, directory) {
|
|
183
|
+
return writeDeviceCertificate(getCertificatePaths(directory).iphoneCertificates, name, ".cer", new X509Certificate(certificatePem).raw);
|
|
184
|
+
}
|
|
185
|
+
export async function writeAndroidCertificate(certificatePem, name, directory) {
|
|
186
|
+
return writeDeviceCertificate(getCertificatePaths(directory).androidCertificates, name, ".crt", new X509Certificate(certificatePem).toString());
|
|
167
187
|
}
|
|
168
188
|
export async function ensureCertificateAuthority(directory) {
|
|
169
189
|
const paths = getCertificatePaths(directory);
|
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { getCertificatePaths, loadCertificateAuthority } from "./certificate-authority.js";
|
|
7
|
+
import { macLanAddresses, NETWORK_PROXY_PORT } from "./local-proxy.js";
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
const MISSING = "__DROID_CERT_MISSING__";
|
|
9
10
|
const APEX_CERTS = "/apex/com.android.conscrypt/cacerts";
|
|
@@ -182,6 +183,43 @@ async function installMountedAndroid(adbPath, serial, ca, directory) {
|
|
|
182
183
|
throw new Error("The Droid CA is not visible from Android's application mount namespace.");
|
|
183
184
|
return { changed: true };
|
|
184
185
|
}
|
|
186
|
+
export function iphoneCertificateInstructions({ certificateName, addresses = macLanAddresses(), port = NETWORK_PROXY_PORT }) {
|
|
187
|
+
const server = addresses.length > 0 ? addresses.join(" or ") : "this Mac's Wi-Fi IP address";
|
|
188
|
+
return [
|
|
189
|
+
"1. Click Send to iPhone and choose AirDrop → your iPhone. Then on the iPhone: Settings → Profile Downloaded → Install.",
|
|
190
|
+
`2. Settings → General → About → Certificate Trust Settings → turn on full trust for ${certificateName}.`,
|
|
191
|
+
`3. On the same Wi-Fi as this Mac: Settings → Wi-Fi → (i) → Configure Proxy → Manual: server ${server}, port ${port}.`,
|
|
192
|
+
"4. Turn the iPhone proxy off when you're done, or the iPhone loses internet access while Droid isn't running.",
|
|
193
|
+
].join("\n\n");
|
|
194
|
+
}
|
|
195
|
+
export function iphoneCertificateSummary({ certificateName, addresses = macLanAddresses(), port = NETWORK_PROXY_PORT }) {
|
|
196
|
+
const proxy = addresses.length > 0 ? addresses.map((address) => `${address}:${port}`).join(" or ") : `this Mac's Wi-Fi IP address, port ${port}`;
|
|
197
|
+
return `iPhone network capture setup: trust ${certificateName}, proxy ${proxy}.`;
|
|
198
|
+
}
|
|
199
|
+
export async function pushAndroidPhoneCertificate(adbPath, serial, localPath) {
|
|
200
|
+
const remotePath = `/sdcard/Download/${path.basename(localPath)}`;
|
|
201
|
+
await adb(adbPath, serial, ["push", localPath, remotePath]);
|
|
202
|
+
return remotePath;
|
|
203
|
+
}
|
|
204
|
+
export function androidCertificateInstructions({ localPath, pushed, addresses = macLanAddresses(), port = NETWORK_PROXY_PORT }) {
|
|
205
|
+
const fileName = path.basename(localPath);
|
|
206
|
+
const server = addresses.length > 0 ? addresses.join(" or ") : "this Mac's Wi-Fi IP address";
|
|
207
|
+
const steps = [
|
|
208
|
+
...(pushed ? [] : [`Droid could not copy the certificate to the phone. Copy ${localPath} to the phone's Downloads folder.`]),
|
|
209
|
+
`On the Android phone: Settings → Security → Encryption & credentials → Install a certificate → CA certificate, then pick ${fileName} from Downloads. Names vary by vendor; search Settings for "CA certificate".`,
|
|
210
|
+
`On the same Wi-Fi as this Mac: Settings → Wi-Fi → Edit network → Proxy → Manual: hostname ${server}, port ${port}.`,
|
|
211
|
+
"Restart the tested app.",
|
|
212
|
+
"Turn the phone proxy off when you're done, or the phone loses internet access while Droid isn't running.",
|
|
213
|
+
];
|
|
214
|
+
return [
|
|
215
|
+
...steps.map((step, index) => `${index + 1}. ${step}`),
|
|
216
|
+
"Since Android 7, apps trust user-installed CAs only when their network security config allows it, typically debug or QA builds. Chrome trusts them.",
|
|
217
|
+
].join("\n\n");
|
|
218
|
+
}
|
|
219
|
+
export function androidCertificateSummary({ certificateName, addresses = macLanAddresses(), port = NETWORK_PROXY_PORT }) {
|
|
220
|
+
const proxy = addresses.length > 0 ? addresses.map((address) => `${address}:${port}`).join(" or ") : `this Mac's Wi-Fi IP address, port ${port}`;
|
|
221
|
+
return `Android phone network capture setup: install ${certificateName} as a CA certificate, proxy ${proxy}.`;
|
|
222
|
+
}
|
|
185
223
|
export async function inspectNetworkDevice({ platform, deviceId, adbPath = process.env.DROID_CUA_ANDROID_ADB_PATH || "adb" }) {
|
|
186
224
|
if (platform === "ios") {
|
|
187
225
|
if (process.platform !== "darwin")
|
|
@@ -2,11 +2,30 @@ import { generateKeyPairSync, randomBytes, randomUUID } from "node:crypto";
|
|
|
2
2
|
import http from "node:http";
|
|
3
3
|
import https from "node:https";
|
|
4
4
|
import net from "node:net";
|
|
5
|
+
import os from "node:os";
|
|
5
6
|
import forge from "node-forge";
|
|
6
7
|
import { loadCertificateAuthority } from "./certificate-authority.js";
|
|
7
8
|
import { BODY_CAPTURE_LIMIT } from "./network-capture.js";
|
|
8
9
|
export const NETWORK_PROXY_HOST = "127.0.0.1";
|
|
9
10
|
export const NETWORK_PROXY_PORT = 8765;
|
|
11
|
+
export const LAN_PROXY_HOST = "0.0.0.0";
|
|
12
|
+
export function macLanAddresses(interfaces = os.networkInterfaces()) {
|
|
13
|
+
return Object.values(interfaces).flat()
|
|
14
|
+
.filter((entry) => entry && (entry.family === "IPv4" || entry.family === 4) && !entry.internal && !entry.address.startsWith("169.254."))
|
|
15
|
+
.map((entry) => entry.address);
|
|
16
|
+
}
|
|
17
|
+
export function iphoneProxyInstruction(addresses = macLanAddresses(), port = NETWORK_PROXY_PORT) {
|
|
18
|
+
if (addresses.length === 0) {
|
|
19
|
+
throw new Error("This Mac has no network address an iPhone can reach. Connect the Mac and the iPhone to the same Wi-Fi network.");
|
|
20
|
+
}
|
|
21
|
+
return `On the iPhone, set Wi-Fi → Configure Proxy → Manual: server ${addresses.join(" or ")}, port ${port}.`;
|
|
22
|
+
}
|
|
23
|
+
export function androidProxyInstruction(addresses = macLanAddresses(), port = NETWORK_PROXY_PORT) {
|
|
24
|
+
if (addresses.length === 0) {
|
|
25
|
+
throw new Error("This Mac has no network address an Android phone can reach. Connect the Mac and the Android phone to the same Wi-Fi network.");
|
|
26
|
+
}
|
|
27
|
+
return `On the Android phone, set Wi-Fi → Edit network → Proxy → Manual: hostname ${addresses.join(" or ")}, port ${port}.`;
|
|
28
|
+
}
|
|
10
29
|
function destination(authority) {
|
|
11
30
|
const url = new URL(`http://${authority}`);
|
|
12
31
|
const port = Number(url.port || 443);
|
|
@@ -134,7 +153,8 @@ export function createNetworkProxy({ host = NETWORK_PROXY_HOST, port = NETWORK_P
|
|
|
134
153
|
response.end();
|
|
135
154
|
}
|
|
136
155
|
function isSelf(target) {
|
|
137
|
-
return target.port === server?.address()?.port &&
|
|
156
|
+
return target.port === server?.address()?.port &&
|
|
157
|
+
[host, "localhost", "127.0.0.1", LAN_PROXY_HOST, ...macLanAddresses()].includes(target.host);
|
|
138
158
|
}
|
|
139
159
|
function handleRequest(request, response) {
|
|
140
160
|
const emit = activeEvents;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createLiveNetworkQuery } from "./query-network.js";
|
|
2
2
|
import { createNetworkCapture } from "./network-capture.js";
|
|
3
|
-
import { createNetworkProxy } from "./local-proxy.js";
|
|
3
|
+
import { createNetworkProxy, NETWORK_PROXY_HOST } from "./local-proxy.js";
|
|
4
4
|
import { captureBrowserNetwork } from "./browser-capture.js";
|
|
5
5
|
/** @typedef {{ path: string, source: string, health: import("./network-capture.js").CaptureHealth, failure: Error | null }} CaptureResult */
|
|
6
6
|
export function createRunNetworkCapture({ createProxy = createNetworkProxy, createCapture = createNetworkCapture, onError = (_error) => { } } = {}) {
|
|
@@ -8,11 +8,22 @@ export function createRunNetworkCapture({ createProxy = createNetworkProxy, crea
|
|
|
8
8
|
let query = null;
|
|
9
9
|
let stopRecording = null;
|
|
10
10
|
let source = "proxy";
|
|
11
|
-
|
|
11
|
+
let proxyHost = NETWORK_PROXY_HOST;
|
|
12
|
+
let proxy = createProxy({ host: proxyHost, onError });
|
|
12
13
|
return {
|
|
13
14
|
get query() { return query; },
|
|
14
15
|
get address() { return proxy.address; },
|
|
15
|
-
|
|
16
|
+
get host() { return proxyHost; },
|
|
17
|
+
async startProxy({ host = NETWORK_PROXY_HOST } = {}) {
|
|
18
|
+
if (host !== proxyHost) {
|
|
19
|
+
if (capture)
|
|
20
|
+
throw new Error("Network capture is active.");
|
|
21
|
+
await proxy.stop();
|
|
22
|
+
proxyHost = host;
|
|
23
|
+
proxy = createProxy({ host, onError });
|
|
24
|
+
}
|
|
25
|
+
return proxy.start();
|
|
26
|
+
},
|
|
16
27
|
async stopProxy() {
|
|
17
28
|
if (capture)
|
|
18
29
|
throw new Error("Network capture is active.");
|
|
@@ -94,9 +105,9 @@ export function describeNetworkCapture({ health, failure, source = "proxy" }) {
|
|
|
94
105
|
: `Captured ${health.requests} browser requests; ${health.errors} capture or request errors.`;
|
|
95
106
|
}
|
|
96
107
|
if (health.requests === 0 && health.httpsConnects === 0)
|
|
97
|
-
return "No traffic observed; check proxy
|
|
108
|
+
return "No traffic observed; check the device proxy settings.";
|
|
98
109
|
if (health.httpsConnects > 0 && health.decryptedHttpsRequests === 0) {
|
|
99
|
-
return `HTTPS reached the proxy but no requests were decrypted (${health.tlsErrors} TLS errors). Check certificate trust.`;
|
|
110
|
+
return `HTTPS reached the proxy but no requests were decrypted (${health.tlsErrors} TLS errors). Check certificate trust on the device.`;
|
|
100
111
|
}
|
|
101
112
|
return `Captured ${health.requests} requests (${health.decryptedHttpsRequests} decrypted HTTPS); ${health.tlsErrors} TLS errors.`;
|
|
102
113
|
}
|
package/package.json
CHANGED
package/skill/droid-cua/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: droid-cua
|
|
3
3
|
description: Operate and improve Droid CUA `.dcua` test projects. Use when the user explicitly names Droid CUA or @loadmill/droid-cua, or asks to author, configure, run, diagnose, heal, stabilize, or speed up `.dcua` tests with the Droid CUA CLI or desktop app. Do not use for general web browsing, website development, browser automation, generic mobile work, or non-Droid test tasks.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "1.
|
|
5
|
+
version: "1.1.7"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Droid CUA
|
|
@@ -13,7 +13,7 @@ Use the existing `droid-cua` CLI and repository files. Do not invent commands, f
|
|
|
13
13
|
|
|
14
14
|
1. Inspect the active test project before creating files. Find existing `.dcua` tests, `context.md`, `test-data.md`, `.secrets`, `.env`, and `ci/droid-cua.json` without reading secret values. Do not search other projects or the user's home directory for credentials.
|
|
15
15
|
2. Determine the requested platform and target from the task or project. Ask the user only when the choice is unclear.
|
|
16
|
-
3. Check authentication and environment readiness before the first run. Read [project-and-auth.md](references/project-and-auth.md) for project boundaries and token handling. Read [onboarding-and-troubleshooting.md](references/onboarding-and-troubleshooting.md) when setup, dependency, device, simulator, browser, or connection work is needed.
|
|
16
|
+
3. Check authentication and environment readiness before the first run. Read [project-and-auth.md](references/project-and-auth.md) for project boundaries and token handling. Read [onboarding-and-troubleshooting.md](references/onboarding-and-troubleshooting.md) when setup, dependency, device, simulator, USB iPhone, browser, or connection work is needed.
|
|
17
17
|
4. Identify the intended coverage, starting state, scenario inputs, and cleanup; author or update the smallest useful test that preserves them. Read [authoring-tests.md](references/authoring-tests.md) before changing `.dcua`, context, test data, secrets, or config.
|
|
18
18
|
5. Before execution, present the proposed tests and target to the user, resolve any questions, and get their confirmation. Read [running-and-healing.md](references/running-and-healing.md) for the required pre-run check.
|
|
19
19
|
6. Run the confirmed, narrowest relevant test and monitor it to completion. Keep the user oriented during the run by surfacing meaningful live screenshots, not just terminal output.
|
|
@@ -108,7 +108,7 @@ Droid searches for the best matching Loadmill flow, starts it, and waits for it
|
|
|
108
108
|
|
|
109
109
|
## Validate captured network traffic
|
|
110
110
|
|
|
111
|
-
For local Chrome, Edge, iOS Simulator or Android Emulator runs with Network Capture enabled, describe HTTP expectations in natural language. Keep the action and its network verification distinct, and name enough of the endpoint and method to identify the intended traffic:
|
|
111
|
+
For local Chrome, Edge, iPhone, iOS Simulator, Android phone or Android Emulator runs with Network Capture enabled, describe HTTP expectations in natural language. Keep the action and its network verification distinct, and name enough of the endpoint and method to identify the intended traffic:
|
|
112
112
|
|
|
113
113
|
```text
|
|
114
114
|
Open the Home page.
|
|
@@ -84,6 +84,37 @@ Use `--platform ios --avd "<simulator name>"` for the run.
|
|
|
84
84
|
|
|
85
85
|
For network capture, use **Tools → Manage Certificates** to select the active CA, connect the simulator, and use **Tools → Install Network Certificate**. If no CA exists, installation generates a Droid CA. The CLI assumes the simulator already trusts the active CA. Configure the Mac's proxy manually, then enable desktop **Network Capture** or run a local mobile CLI test with `--capture-network`. Follow the approval rule above before changing simulator trust on the user's behalf.
|
|
86
86
|
|
|
87
|
+
## iPhone over USB
|
|
88
|
+
|
|
89
|
+
A real iPhone connected to the Mac by USB is a local, attended target: the user must keep it nearby and unlocked. It needs macOS, Xcode that supports the iPhone's iOS, the XCUITest driver, and the user's Apple ID in Xcode on their company's paid Apple Developer team (Developer role with Access to Certificates, Identifiers & Profiles). Free Apple IDs are not supported. Simulators are not needed.
|
|
90
|
+
|
|
91
|
+
Check readiness without starting a test:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
droid-cua ios-device check
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Each line is one check: `✔` passed, `✖` failed, `·` not checked yet because an earlier check failed, and `!` a non-blocking reminder (Auto-Lock, test-only iPhone). The `Fix:` line under a `✖` is the exact remediation. Work on the first `✖` only, then rerun the check. Exit `0` means ready, and the last line gives the `--avd ios-device:<udid>` token to run with. Add `--udid <udid>` when several iPhones are connected.
|
|
98
|
+
|
|
99
|
+
Interpret failures:
|
|
100
|
+
|
|
101
|
+
- **Xcode installed** or **Device components**: the user must install or update the Xcode named in the message and open it once (it asks for a Mac admin password). When the message says Xcode isn't in the Applications folder, the user must install it or move it to `/Applications`. When it says the Command Line Tools are selected or the license isn't accepted, the `Fix:` line gives the exact `sudo xcode-select -s` or `sudo xcodebuild -license accept` command; get approval before running either.
|
|
102
|
+
- **iOS platform installed**: Xcode lacks the iOS platform for its SDK (Xcode 26 ships without it), so it can't build for an iPhone or fetch the user's Apple teams. The user installs it from Xcode > Settings > Components, or runs `xcodebuild -downloadPlatform iOS`. It's about 10 GB, so get approval before running the command and expect it to take a while.
|
|
103
|
+
- **Apple team access**: on a fresh Mac the check fetches the user's teams from Apple with a short Xcode build within the same run. "No Apple account is signed into Xcode" means the user must sign in (Xcode > Settings > Apple Accounts). "Signed into Xcode, but none of your accounts is in a paid Apple Developer team" means the user must ask their Apple team admin for the Developer role with Access to Certificates, Identifiers & Profiles. With several teams, ask the user which team to use and pass `--ios-team <TEAMID>`; never choose a team for them.
|
|
104
|
+
- **iPhone connected**, **iPhone trusts this Mac**, **Developer Mode**, **iPhone unlocked**: steps only the user can do on the phone. Relay the `Fix:` text, wait for them to confirm, then rerun the check. Turning on Developer Mode restarts the iPhone.
|
|
105
|
+
- **Signing ready** and **Helper app (WebDriverAgent)**: droid-cua fixes these itself. Get approval before the first `droid-cua ios-device check --fix`: it runs a one-time Xcode signing build (about a minute) that can create the user's Apple Development certificate and register the iPhone on their company team. A test run also applies these fixes automatically.
|
|
106
|
+
- **Signing ready** failing with "There's no usable Apple Development certificate for your team on this Mac": a rerun won't help. Relay the `Fix:` line: the user creates an Apple Development certificate for the team in Xcode (Xcode > Settings > Apple Accounts > select the account > select the team > Manage Certificates > + > Apple Development) or on the Apple Developer website (developer.apple.com > Certificates, Identifiers & Profiles > Certificates), or asks their Apple Developer team admin for one if they don't have permission, then reruns the fix. Never revoke a certificate yourself or suggest revoking one.
|
|
107
|
+
|
|
108
|
+
Never restart, reset, or change settings on the user's iPhone yourself. Do not run `xcodebuild`, `codesign`, or `devicectl` to work around a failing check; use `ios-device check`.
|
|
109
|
+
|
|
110
|
+
### iPhone network capture
|
|
111
|
+
|
|
112
|
+
Network capture on an iPhone needs manual setup on the phone, done by the user. The Mac and iPhone must share a Wi-Fi network that allows device-to-device traffic (guest and many corporate networks block it). With an iPhone connected, desktop **Tools → Install Network Certificate** shows the steps instead of installing anything: its **Send to iPhone** button opens the macOS share menu with a `.cer` copy of the CA (named after the certificate's common name, such as `Droid CUA Root fbd877ab.cer`, in `~/.droid-cua/network/iphone/`) to AirDrop to the iPhone; the user installs it from Settings → Profile Downloaded, then turns on full trust for that common name in Settings → General → About → Certificate Trust Settings. The user then sets Wi-Fi → Configure Proxy → Manual to the Mac address and port `8765` that Droid prints, and turns the proxy off after testing, since the iPhone loses internet access through it while Droid is not running. Apps that pin certificates still work through the proxy, but their HTTPS is not decrypted. Relay these steps; never change the iPhone's certificate, trust, or proxy settings yourself.
|
|
113
|
+
|
|
114
|
+
### Android phone network capture
|
|
115
|
+
|
|
116
|
+
A physical Android phone connected over ADB also needs manual setup by the user, on a Wi-Fi network shared with the Mac that allows device-to-device traffic. With the phone connected, desktop **Tools → Install Network Certificate** writes a `.crt` copy of the CA named after its common name (such as `Droid CUA Root fbd877ab.crt`, in `~/.droid-cua/network/android/`), copies it to the phone's Downloads folder, and shows the steps: Settings → Security → Encryption & credentials → Install a certificate → CA certificate (names vary by vendor), set Wi-Fi → Edit network → Proxy → Manual to the Mac address and port `8765` that Droid prints, then restart the tested app. If the copy fails, the dialog names the local file for the user to copy. Since Android 7, apps trust user CAs only when their network security config allows it (`<certificates src="user"/>` or debug overrides), so debug and QA builds usually work and Play Store builds usually do not; Chrome trusts user CAs. Rooted-phone system trust is not supported. Relay these steps; never change the phone's certificates or settings yourself, and remind the user to turn the proxy off after testing.
|
|
117
|
+
|
|
87
118
|
## Web
|
|
88
119
|
|
|
89
120
|
Droid CUA automatically discovers Chrome or Edge on macOS and Windows. Check the standard application locations, then run the requested test with `--target web --browser <chrome|edge> --session-mode fresh`. A successful Playwright launch is the runtime verification; do not create a persistent setup-only test.
|
|
@@ -22,7 +22,11 @@ Include `--report reports/<test-name>.html` for normal test runs unless the user
|
|
|
22
22
|
|
|
23
23
|
For macOS and Windows desktop tests in application mode, the desktop app shows a system notice for three seconds before Droid takes control. The three-second delay is fixed; once the test starts, use Stop in Droid to end it. Avoid using the mouse or keyboard during the run. Droid brings the selected target window to the foreground before the first capture. When the run ends, the target stays in front and a second system notice says Droid has finished controlling the desktop. Open Droid to review the result. Some Windows apps return an all-black window capture; Droid brings the target back to the foreground before cropping the visible desktop to its visible frame, excluding the invisible Windows resize border. The fallback logs an all-black capture warning and crops before resizing to preserve detail. A visible-frame lookup warning means Droid is using the outer window bounds, which may include invisible resize borders. Keep the target visible and unobstructed on the primary display. In accessibility input mode, bring the target to the foreground yourself if the fallback requests it; Droid does not focus it for screenshots. If a screenshot shows another app or black off-screen edges, check whether the target could be focused and fully displayed before changing the `.dcua` instructions.
|
|
24
24
|
|
|
25
|
-
The local network proxy is off by default. Use `--network-proxy` only when the user asks for local mobile proxy forwarding and has prepared device routing. This flag starts an idle forwarding listener; it does not capture traffic or enable network assertions. Use `--capture-network` only when the user asks for captured traffic or network assertions for local Chrome, Edge, iOS Simulator or Android Emulator tests. It writes one JSONL log per test, beside `--report` output when enabled or under `logs/` otherwise. For mobile capture it starts the proxy; the active CA must exist and the device must trust it. Configure mobile proxy routing manually before the run. Manual setup and cleanup are the accepted V1 workflow. Clear that routing after use; if Droid exits or crashes, restart it or clear the proxy to restore connectivity. Captures stop recording at 100 MiB with a CAPTURE_LIMIT marker while traffic continues forwarding. Treat that partial artifact as incomplete evidence; live queries cannot establish absence after the limit. Capture is best effort and WebSocket messages are unsupported. Captures may contain credentials or sensitive data despite partial filtering, so do not print their contents or share the artifact without the user's direction. Report the printed log path and capture health. Network-enabled runs expose `query_network` for focused questions and cross-request assertions; its helper answers include flow references. Distinguish an application mismatch from an assertion that could not be established because capture evidence was insufficient. Absence checks need a completed observation window and working capture evidence; never claim that no request occurred solely because none was captured.
|
|
25
|
+
The local network proxy is off by default. Use `--network-proxy` only when the user asks for local mobile proxy forwarding and has prepared device routing. This flag starts an idle forwarding listener; it does not capture traffic or enable network assertions. Use `--capture-network` only when the user asks for captured traffic or network assertions for local Chrome, Edge, iPhone, iOS Simulator, Android phone or Android Emulator tests. It writes one JSONL log per test, beside `--report` output when enabled or under `logs/` otherwise. For mobile capture it starts the proxy; the active CA must exist and the device must trust it. Configure mobile proxy routing manually before the run. Manual setup and cleanup are the accepted V1 workflow. Clear that routing after use; if Droid exits or crashes, restart it or clear the proxy to restore connectivity. Captures stop recording at 100 MiB with a CAPTURE_LIMIT marker while traffic continues forwarding. Treat that partial artifact as incomplete evidence; live queries cannot establish absence after the limit. Capture is best effort and WebSocket messages are unsupported. Captures may contain credentials or sensitive data despite partial filtering, so do not print their contents or share the artifact without the user's direction. Report the printed log path and capture health. Network-enabled runs expose `query_network` for focused questions and cross-request assertions; its helper answers include flow references. Distinguish an application mismatch from an assertion that could not be established because capture evidence was insufficient. Absence checks need a completed observation window and working capture evidence; never claim that no request occurred solely because none was captured.
|
|
26
|
+
|
|
27
|
+
For a local iPhone (`--avd ios-device:<udid|auto>`), the proxy listens on all Mac addresses and the CLI prints the line to relay, such as `On the iPhone, set Wi-Fi → Configure Proxy → Manual: server 192.168.1.23, port 8765.` If it fails because the Mac has no network address an iPhone can reach, ask the user to join the Mac and iPhone to the same Wi-Fi network. "No traffic observed" on an iPhone usually means its proxy is not set, or the Wi-Fi blocks device-to-device traffic; "no requests were decrypted" means the Droid CA is not installed with full trust on the iPhone or the app pins its certificates. Pinned apps cannot be decrypted; report that rather than retrying.
|
|
28
|
+
|
|
29
|
+
A physical Android phone connected over ADB (any serial other than `emulator-<port>`) works the same way: the CLI prints a line such as `On the Android phone, set Wi-Fi → Edit network → Proxy → Manual: hostname 192.168.1.23, port 8765.` to relay. "No requests were decrypted" on an Android phone usually means the Droid CA is not installed as a user CA certificate, or the app's network security config does not trust user CAs (common for Play Store builds; debug and QA builds usually opt in). Report that rather than retrying.
|
|
26
30
|
|
|
27
31
|
Local Chrome and Edge capture through Playwright; no certificate or proxy setup is needed. Keep the same assertions, query tool and report workflow. Capture begins at run start and includes frames and popups in that browser context. CLI `--url` navigation is captured. Earlier traffic from an already-open page is not recovered: include navigation in the test when its initial load matters, rather than silently reloading or changing the browser profile. Browser capture observes normal cache and service-worker behavior. Worker-originated requests and responses served by a worker are identified in query metadata; an observed exchange is not proof of a new server request. Pending or unavailable bodies remain incomplete evidence. Cloud browsers, WebSockets, streaming-message inspection and HAR export are outside this capture support. A browser run does not depend on the mobile proxy being available.
|
|
28
32
|
|
|
@@ -44,6 +48,9 @@ droid-cua run tests/example.dcua --avd avd:<avd-name> --config ci/droid-cua.json
|
|
|
44
48
|
# iOS Simulator
|
|
45
49
|
droid-cua run tests/example.dcua --llm-provider loadmill --cua-model loadmill-pulse --platform ios --avd "<simulator-name>" --debug --report reports/example.html
|
|
46
50
|
|
|
51
|
+
# iPhone connected by USB (run `droid-cua ios-device check` first)
|
|
52
|
+
droid-cua run tests/example.dcua --llm-provider loadmill --cua-model loadmill-pulse --avd ios-device:<udid> --debug --report reports/example.html
|
|
53
|
+
|
|
47
54
|
# Local web
|
|
48
55
|
droid-cua run tests/example.dcua --llm-provider loadmill --cua-model loadmill-pulse --target web --browser chrome --session-mode fresh --debug --report reports/example.html
|
|
49
56
|
|
|
@@ -62,6 +69,8 @@ droid-cua run tests/auth/login.dcua tests/checkout/purchase.dcua admin/refund.dc
|
|
|
62
69
|
|
|
63
70
|
Use `.ipa` and `--platform ios` for supported cloud iOS runs. Preserve existing config flags rather than replacing them with defaults.
|
|
64
71
|
|
|
72
|
+
For an iPhone connected by USB, confirm `droid-cua ios-device check` passes before the first run (see [onboarding-and-troubleshooting.md](onboarding-and-troubleshooting.md)). Use `--avd ios-device:<udid>` from the check's last line, or `ios-device:auto` when only one iPhone is connected; `--platform` is not needed. Add `--ios-team <TEAMID>` only when the user belongs to several paid Apple teams. There is no `--app` for a local iPhone: the test opens an app already installed on it. In the run plan, tell the user the test runs on their own iPhone with the apps and data already on it, and that they need to keep it unlocked and nearby. During the run, `Enter your passcode on the iPhone to allow automation` and `iPhone locked — unlock to continue` are waits for the user, not failures: tell the user right away and keep monitoring.
|
|
73
|
+
|
|
65
74
|
## Confirm the run with the user
|
|
66
75
|
|
|
67
76
|
Before starting any test command, send the user a short, plain-language run plan and wait for explicit confirmation. Treat this as a product conversation, not a command preview. Do not infer that a request to run a test also confirms an automatically selected device, browser, or batch of tests. For an explicitly requested author-run-improve workflow, the plan may ask approval for the baseline and the evidence-driven edit/rerun and confidence runs needed to reach a readiness decision; follow [iterative-improvement.md](iterative-improvement.md).
|
|
@@ -138,7 +147,7 @@ Keep this retrospective concise and lead with the result and improvement. Do not
|
|
|
138
147
|
## Classify the first divergence
|
|
139
148
|
|
|
140
149
|
1. **Setup or authentication**: missing/invalid provider credential, dependency, license, browser, or executable. Return to onboarding and rerun only the failed check.
|
|
141
|
-
2. **Target connectivity**: no device, unauthorized/offline ADB state, unavailable simulator, cloud credential/device/app problem, or lost session. Restore the same target, then rerun.
|
|
150
|
+
2. **Target connectivity**: no device, unauthorized/offline ADB state, unavailable simulator, iPhone unplugged/untrusted/locked or its helper app not starting, cloud credential/device/app problem, or lost session. Restore the same target (for an iPhone, rerun `droid-cua ios-device check`), then rerun.
|
|
142
151
|
3. **Test ambiguity or stale test knowledge**: instruction has multiple interpretations, relies on an unstable label, lacks a starting condition, or context contradicts the visible product. A minimal test/context correction is allowed.
|
|
143
152
|
4. **Application regression**: the app crashes, displays an error, violates an explicit requirement, or consistently fails a stable assertion after correct setup. Stop and report a bug with evidence.
|
|
144
153
|
5. **Transient execution**: temporary network, service, model, or timing failure without evidence of a deterministic product/test issue. Retry the unchanged targeted test once.
|