@validate.qa/runner 1.0.15 → 1.0.16
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/dist/cli.js +301 -27
- package/dist/cli.mjs +301 -27
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -66792,16 +66792,16 @@ ${statePacket}` },
|
|
|
66792
66792
|
}
|
|
66793
66793
|
const screen = screenName();
|
|
66794
66794
|
if (accessibilityId) {
|
|
66795
|
-
const bundle2 = { xpath: `//*[@content-desc=${
|
|
66795
|
+
const bundle2 = { xpath: `//*[@content-desc=${xpathLiteral2(accessibilityId)} or @name=${xpathLiteral2(accessibilityId)}]`, className: "", accessibilityId };
|
|
66796
66796
|
recordAction({ type: "ASSERT_VISIBLE", selector: accessibilityId, metadata: { screenName: screen, targetLabel: accessibilityId, locatorBundle: bundle2 } });
|
|
66797
66797
|
return { ok: true, label: accessibilityId };
|
|
66798
66798
|
}
|
|
66799
66799
|
const safeText = text;
|
|
66800
|
-
const bundle = { xpath: `//*[@text=${
|
|
66800
|
+
const bundle = { xpath: `//*[@text=${xpathLiteral2(safeText)} or @label=${xpathLiteral2(safeText)}]`, className: "", text: safeText };
|
|
66801
66801
|
recordAction({ type: "ASSERT_VISIBLE", selector: bundle.xpath, metadata: { screenName: screen, targetLabel: safeText, locatorBundle: bundle } });
|
|
66802
66802
|
return { ok: true, label: safeText };
|
|
66803
66803
|
}
|
|
66804
|
-
function
|
|
66804
|
+
function xpathLiteral2(value) {
|
|
66805
66805
|
if (!value.includes("'"))
|
|
66806
66806
|
return `'${value}'`;
|
|
66807
66807
|
if (!value.includes('"'))
|
|
@@ -328118,7 +328118,7 @@ var require_package4 = __commonJS({
|
|
|
328118
328118
|
"package.json"(exports2, module2) {
|
|
328119
328119
|
module2.exports = {
|
|
328120
328120
|
name: "@validate.qa/runner",
|
|
328121
|
-
version: "1.0.
|
|
328121
|
+
version: "1.0.16",
|
|
328122
328122
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
328123
328123
|
bin: {
|
|
328124
328124
|
"validate-runner": "dist/cli.js"
|
|
@@ -329723,7 +329723,7 @@ async function getWindowSize(sessionId, retries = 3) {
|
|
|
329723
329723
|
}
|
|
329724
329724
|
throw new Error("Failed to get window size: invalid dimensions returned");
|
|
329725
329725
|
}
|
|
329726
|
-
function realDeviceCapabilities(platform3) {
|
|
329726
|
+
function realDeviceCapabilities(platform3, portOffset = 0) {
|
|
329727
329727
|
const env = process.env;
|
|
329728
329728
|
const caps = {};
|
|
329729
329729
|
const num = (v) => {
|
|
@@ -329731,6 +329731,7 @@ function realDeviceCapabilities(platform3) {
|
|
|
329731
329731
|
const n = Number.parseInt(v, 10);
|
|
329732
329732
|
return Number.isFinite(n) ? n : void 0;
|
|
329733
329733
|
};
|
|
329734
|
+
const offset = Number.isFinite(portOffset) && portOffset > 0 ? Math.floor(portOffset) : 0;
|
|
329734
329735
|
if (platform3 === "IOS") {
|
|
329735
329736
|
if (env.APPIUM_XCODE_ORG_ID) {
|
|
329736
329737
|
caps["appium:xcodeOrgId"] = env.APPIUM_XCODE_ORG_ID;
|
|
@@ -329740,15 +329741,15 @@ function realDeviceCapabilities(platform3) {
|
|
|
329740
329741
|
}
|
|
329741
329742
|
if (env.APPIUM_UPDATED_WDA_BUNDLE_ID) caps["appium:updatedWDABundleId"] = env.APPIUM_UPDATED_WDA_BUNDLE_ID;
|
|
329742
329743
|
const wdaPort = num(env.APPIUM_WDA_LOCAL_PORT);
|
|
329743
|
-
if (wdaPort !== void 0) caps["appium:wdaLocalPort"] = wdaPort;
|
|
329744
|
+
if (wdaPort !== void 0) caps["appium:wdaLocalPort"] = wdaPort + offset;
|
|
329744
329745
|
} else {
|
|
329745
329746
|
const systemPort = num(env.APPIUM_SYSTEM_PORT);
|
|
329746
|
-
if (systemPort !== void 0) caps["appium:systemPort"] = systemPort;
|
|
329747
|
+
if (systemPort !== void 0) caps["appium:systemPort"] = systemPort + offset;
|
|
329747
329748
|
}
|
|
329748
329749
|
return caps;
|
|
329749
329750
|
}
|
|
329750
329751
|
async function createSession(target) {
|
|
329751
|
-
const realCaps = target.isPhysical ? realDeviceCapabilities(target.platform) : {};
|
|
329752
|
+
const realCaps = target.isPhysical ? realDeviceCapabilities(target.platform, target.portOffset) : {};
|
|
329752
329753
|
const capabilities = target.platform === "IOS" ? {
|
|
329753
329754
|
platformName: "iOS",
|
|
329754
329755
|
"appium:automationName": "XCUITest",
|
|
@@ -330023,6 +330024,46 @@ function buildLocatorStrategies(target) {
|
|
|
330023
330024
|
(strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
|
|
330024
330025
|
);
|
|
330025
330026
|
}
|
|
330027
|
+
function dedupeStrategies(strategies) {
|
|
330028
|
+
return strategies.filter(
|
|
330029
|
+
(strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
|
|
330030
|
+
);
|
|
330031
|
+
}
|
|
330032
|
+
function xpathLiteral(value) {
|
|
330033
|
+
if (!value.includes("'")) return `'${value}'`;
|
|
330034
|
+
if (!value.includes('"')) return `"${value}"`;
|
|
330035
|
+
const parts = value.split("'").map((p) => `'${p}'`);
|
|
330036
|
+
return `concat(${parts.join(`, "'", `)})`;
|
|
330037
|
+
}
|
|
330038
|
+
function identityTextXpath(value) {
|
|
330039
|
+
const lit = xpathLiteral(value);
|
|
330040
|
+
return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
|
|
330041
|
+
}
|
|
330042
|
+
function isIdentityStrategy(strategy) {
|
|
330043
|
+
if (strategy.using === "accessibility id" || strategy.using === "id") return true;
|
|
330044
|
+
if (strategy.using === "xpath") return strategy.value.includes("@");
|
|
330045
|
+
return false;
|
|
330046
|
+
}
|
|
330047
|
+
function buildIdentityLocatorStrategies(target) {
|
|
330048
|
+
if (!target) return [];
|
|
330049
|
+
const strategies = [];
|
|
330050
|
+
const bundle = target.locatorBundle;
|
|
330051
|
+
if (bundle) {
|
|
330052
|
+
if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
|
|
330053
|
+
if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
|
|
330054
|
+
for (const value of [bundle.text, bundle.contentDesc]) {
|
|
330055
|
+
const trimmed = value?.trim();
|
|
330056
|
+
if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
|
|
330057
|
+
}
|
|
330058
|
+
if (bundle.xpath && bundle.xpath.includes("@")) strategies.push({ using: "xpath", value: bundle.xpath });
|
|
330059
|
+
}
|
|
330060
|
+
for (const value of [target.primary, ...target.fallbacks ?? []]) {
|
|
330061
|
+
for (const strategy of locatorStrategyForValue(value)) {
|
|
330062
|
+
if (isIdentityStrategy(strategy)) strategies.push(strategy);
|
|
330063
|
+
}
|
|
330064
|
+
}
|
|
330065
|
+
return dedupeStrategies(strategies);
|
|
330066
|
+
}
|
|
330026
330067
|
function readStepTimeout(step) {
|
|
330027
330068
|
const value = step.metadata?.timeout;
|
|
330028
330069
|
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
@@ -330030,10 +330071,12 @@ function readStepTimeout(step) {
|
|
|
330030
330071
|
}
|
|
330031
330072
|
return void 0;
|
|
330032
330073
|
}
|
|
330033
|
-
async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS) {
|
|
330034
|
-
const strategies = buildLocatorStrategies(target);
|
|
330074
|
+
async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
|
|
330075
|
+
const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target) : buildLocatorStrategies(target);
|
|
330035
330076
|
if (strategies.length === 0) {
|
|
330036
|
-
throw new Error(
|
|
330077
|
+
throw new Error(
|
|
330078
|
+
opts?.identityOnly ? "No identity locator (accessibility id / resource id / text) available for this step \u2014 cannot verify the screen" : "No locator strategies available for this step"
|
|
330079
|
+
);
|
|
330037
330080
|
}
|
|
330038
330081
|
const startTime = Date.now();
|
|
330039
330082
|
let lastError = "";
|
|
@@ -330142,7 +330185,7 @@ async function executeTap(sessionId, step) {
|
|
|
330142
330185
|
});
|
|
330143
330186
|
return;
|
|
330144
330187
|
} catch (error2) {
|
|
330145
|
-
if (!bounds) throw error2;
|
|
330188
|
+
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
|
|
330146
330189
|
}
|
|
330147
330190
|
}
|
|
330148
330191
|
if (!bounds) {
|
|
@@ -330175,7 +330218,7 @@ async function executeType(sessionId, step, context) {
|
|
|
330175
330218
|
if (!lastInputError) return;
|
|
330176
330219
|
elementId = null;
|
|
330177
330220
|
} catch (error2) {
|
|
330178
|
-
if (!bounds) throw error2;
|
|
330221
|
+
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
|
|
330179
330222
|
lastInputError = error2;
|
|
330180
330223
|
}
|
|
330181
330224
|
}
|
|
@@ -330230,7 +330273,7 @@ async function executeClear(sessionId, step) {
|
|
|
330230
330273
|
try {
|
|
330231
330274
|
elementId = await findElement(sessionId, step.target, readStepTimeout(step));
|
|
330232
330275
|
} catch (error2) {
|
|
330233
|
-
if (!bounds) throw error2;
|
|
330276
|
+
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
|
|
330234
330277
|
}
|
|
330235
330278
|
}
|
|
330236
330279
|
if (!elementId && bounds) {
|
|
@@ -330301,10 +330344,10 @@ async function executeHome(sessionId, platform3) {
|
|
|
330301
330344
|
}
|
|
330302
330345
|
async function executeWaitForElement(sessionId, step) {
|
|
330303
330346
|
const timeout = readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS;
|
|
330304
|
-
await findElement(sessionId, step.target, timeout);
|
|
330347
|
+
await findElement(sessionId, step.target, timeout, { identityOnly: true });
|
|
330305
330348
|
}
|
|
330306
330349
|
async function executeAssertVisible(sessionId, step) {
|
|
330307
|
-
const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
|
|
330350
|
+
const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
|
|
330308
330351
|
const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
|
|
330309
330352
|
if (!result?.value) {
|
|
330310
330353
|
throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
|
|
@@ -330312,7 +330355,7 @@ async function executeAssertVisible(sessionId, step) {
|
|
|
330312
330355
|
}
|
|
330313
330356
|
async function executeAssertText(sessionId, step) {
|
|
330314
330357
|
if (!step.assertion) throw new Error("assertText step requires an assertion value");
|
|
330315
|
-
const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
|
|
330358
|
+
const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
|
|
330316
330359
|
const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
|
|
330317
330360
|
const actualText = typeof result?.value === "string" ? result.value : "";
|
|
330318
330361
|
const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
|
|
@@ -330347,6 +330390,7 @@ async function executeMobileTest(options) {
|
|
|
330347
330390
|
const screenshots = [];
|
|
330348
330391
|
let apiCalls = [];
|
|
330349
330392
|
let sessionId = null;
|
|
330393
|
+
let selectedDevice;
|
|
330350
330394
|
let capture = null;
|
|
330351
330395
|
let captureStopped = false;
|
|
330352
330396
|
let recordingStarted = false;
|
|
@@ -330394,7 +330438,6 @@ async function executeMobileTest(options) {
|
|
|
330394
330438
|
`No ${platformFilter} ${platformFilter === "IOS" ? "simulator" : "emulator"} booted. Start one via ${platformFilter === "IOS" ? "Simulator.app" : "Android Studio"} first.`
|
|
330395
330439
|
);
|
|
330396
330440
|
}
|
|
330397
|
-
let selectedDevice;
|
|
330398
330441
|
if (options.target.assignedDeviceId) {
|
|
330399
330442
|
selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
|
|
330400
330443
|
if (!selectedDevice) {
|
|
@@ -330435,13 +330478,15 @@ async function executeMobileTest(options) {
|
|
|
330435
330478
|
log2(`Warning: Could not retrieve installed apps list. Proceeding with test execution.`);
|
|
330436
330479
|
}
|
|
330437
330480
|
log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
|
|
330481
|
+
const leasedPortOffset = Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id));
|
|
330438
330482
|
try {
|
|
330439
330483
|
sessionId = await createSessionWithRetry({
|
|
330440
330484
|
appId: options.target.appId,
|
|
330441
330485
|
platform: options.target.platform,
|
|
330442
330486
|
deviceId: selectedDevice.id,
|
|
330443
330487
|
platformVersion: options.target.platformVersion ?? selectedDevice.platformVersion,
|
|
330444
|
-
isPhysical: selectedDevice.isPhysical
|
|
330488
|
+
isPhysical: selectedDevice.isPhysical,
|
|
330489
|
+
portOffset: leasedPortOffset
|
|
330445
330490
|
}, (msg) => log2(msg));
|
|
330446
330491
|
} catch (err) {
|
|
330447
330492
|
const base2 = err instanceof Error ? err.message : String(err);
|
|
@@ -330457,6 +330502,12 @@ async function executeMobileTest(options) {
|
|
|
330457
330502
|
throw err;
|
|
330458
330503
|
}
|
|
330459
330504
|
log2(`Session created: ${sessionId}`);
|
|
330505
|
+
if (selectedDevice.id) {
|
|
330506
|
+
try {
|
|
330507
|
+
options.onSessionReady?.(sessionId, selectedDevice.id, options.target.platform);
|
|
330508
|
+
} catch {
|
|
330509
|
+
}
|
|
330510
|
+
}
|
|
330460
330511
|
recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
|
|
330461
330512
|
log2("Opening a fresh app instance for the test...");
|
|
330462
330513
|
await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
|
|
@@ -330469,8 +330520,26 @@ async function executeMobileTest(options) {
|
|
|
330469
330520
|
}
|
|
330470
330521
|
}
|
|
330471
330522
|
await restoreTargetAppIfExternal(sessionId, options.target, log2, "session start", false);
|
|
330523
|
+
let entryDrift = false;
|
|
330524
|
+
const entryStep = options.steps.find(
|
|
330525
|
+
(s) => s.action !== "launchApp" && buildIdentityLocatorStrategies(s.target).length > 0
|
|
330526
|
+
);
|
|
330527
|
+
if (entryStep) {
|
|
330528
|
+
try {
|
|
330529
|
+
await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
|
|
330530
|
+
} catch (err) {
|
|
330531
|
+
if (isTransportError(err)) throw err;
|
|
330532
|
+
entryDrift = true;
|
|
330533
|
+
const driftMsg = `App started on an unexpected screen \u2014 the recorded flow's first element ("${entryStep.target?.primary ?? entryStep.description}") is not present, so the replay was aborted before it could blind-tap stale coordinates (screen drift; the app may be on onboarding or a different screen than when the test was recorded).`;
|
|
330534
|
+
log2(` \u2717 Entry-screen drift: ${driftMsg}`);
|
|
330535
|
+
stepResults.push({ stepOrder: entryStep.order, status: "failed", duration: 0, error: driftMsg });
|
|
330536
|
+
const driftShot = await takeScreenshot(sessionId).catch(() => null);
|
|
330537
|
+
screenshots.push(driftShot ?? "");
|
|
330538
|
+
}
|
|
330539
|
+
}
|
|
330472
330540
|
let environmental = null;
|
|
330473
330541
|
for (const step of options.steps) {
|
|
330542
|
+
if (entryDrift) break;
|
|
330474
330543
|
const stepStart = Date.now();
|
|
330475
330544
|
log2(`Step ${step.order}: ${step.action} - ${step.description}`);
|
|
330476
330545
|
let status = "passed";
|
|
@@ -330548,7 +330617,7 @@ async function executeMobileTest(options) {
|
|
|
330548
330617
|
break;
|
|
330549
330618
|
}
|
|
330550
330619
|
}
|
|
330551
|
-
const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
|
|
330620
|
+
const allPassed = !environmental && stepResults.length > 0 && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
|
|
330552
330621
|
const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
|
|
330553
330622
|
await stopCapture();
|
|
330554
330623
|
await stopRecording();
|
|
@@ -330561,6 +330630,8 @@ async function executeMobileTest(options) {
|
|
|
330561
330630
|
logs: logLines.join("\n"),
|
|
330562
330631
|
error: firstError,
|
|
330563
330632
|
duration: Date.now() - startTime,
|
|
330633
|
+
deviceId: selectedDevice?.id,
|
|
330634
|
+
deviceName: selectedDevice?.name,
|
|
330564
330635
|
environmental: environmental ?? void 0
|
|
330565
330636
|
};
|
|
330566
330637
|
} catch (err) {
|
|
@@ -330577,6 +330648,8 @@ async function executeMobileTest(options) {
|
|
|
330577
330648
|
logs: logLines.join("\n"),
|
|
330578
330649
|
error: errorMsg,
|
|
330579
330650
|
duration: Date.now() - startTime,
|
|
330651
|
+
deviceId: selectedDevice?.id,
|
|
330652
|
+
deviceName: selectedDevice?.name,
|
|
330580
330653
|
// A throw before/around the step loop is a setup/environment problem
|
|
330581
330654
|
// (no device booted, app not installed, session could not be created,
|
|
330582
330655
|
// Appium unreachable) — never a genuine test assertion failure.
|
|
@@ -330586,6 +330659,12 @@ async function executeMobileTest(options) {
|
|
|
330586
330659
|
await stopCapture();
|
|
330587
330660
|
await stopRecording();
|
|
330588
330661
|
if (sessionId) {
|
|
330662
|
+
if (selectedDevice?.id) {
|
|
330663
|
+
try {
|
|
330664
|
+
options.onSessionEnding?.(selectedDevice.id);
|
|
330665
|
+
} catch {
|
|
330666
|
+
}
|
|
330667
|
+
}
|
|
330589
330668
|
try {
|
|
330590
330669
|
log2("Closing the app on the device...");
|
|
330591
330670
|
await terminateTargetApp(sessionId, options.target.platform, options.target.appId);
|
|
@@ -330644,7 +330723,9 @@ async function createMobileDiscoverySession(target, options) {
|
|
|
330644
330723
|
platform: target.platform,
|
|
330645
330724
|
deviceId: selectedDevice.id,
|
|
330646
330725
|
platformVersion: target.platformVersion ?? selectedDevice.platformVersion,
|
|
330647
|
-
isPhysical: selectedDevice.isPhysical
|
|
330726
|
+
isPhysical: selectedDevice.isPhysical,
|
|
330727
|
+
// Distinct control port per concurrent physical device (no-op for emulators/sims).
|
|
330728
|
+
portOffset: Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id))
|
|
330648
330729
|
});
|
|
330649
330730
|
if (target.launchMode === "DEEP_LINK" && target.deeplink) {
|
|
330650
330731
|
try {
|
|
@@ -330656,6 +330737,7 @@ async function createMobileDiscoverySession(target, options) {
|
|
|
330656
330737
|
return {
|
|
330657
330738
|
sessionId,
|
|
330658
330739
|
deviceId: selectedDevice.id,
|
|
330740
|
+
deviceName: selectedDevice.name,
|
|
330659
330741
|
isPhysical: selectedDevice.isPhysical === true,
|
|
330660
330742
|
platform: target.platform,
|
|
330661
330743
|
appId: target.appId,
|
|
@@ -331381,7 +331463,146 @@ async function handleSessionStop(_req, res) {
|
|
|
331381
331463
|
stopSessionReaper();
|
|
331382
331464
|
sendJSON(res, 200, { ok: true });
|
|
331383
331465
|
}
|
|
331466
|
+
var runnerChannels = /* @__PURE__ */ new Map();
|
|
331467
|
+
function readDeviceIdParam(req) {
|
|
331468
|
+
try {
|
|
331469
|
+
const url = new URL(req.url || "", "http://localhost");
|
|
331470
|
+
const raw = url.searchParams.get("deviceId");
|
|
331471
|
+
if (!raw) return null;
|
|
331472
|
+
const trimmed = raw.trim();
|
|
331473
|
+
if (!trimmed || trimmed.length > MAX_DEVICE_ID_LENGTH || !DEVICE_ID_PATTERN.test(trimmed)) return null;
|
|
331474
|
+
return trimmed;
|
|
331475
|
+
} catch {
|
|
331476
|
+
return null;
|
|
331477
|
+
}
|
|
331478
|
+
}
|
|
331479
|
+
async function isSessionAlive(sessionId) {
|
|
331480
|
+
try {
|
|
331481
|
+
validateSessionId(sessionId);
|
|
331482
|
+
await appiumRequest2(`/session/${sessionId}/window/rect`);
|
|
331483
|
+
return true;
|
|
331484
|
+
} catch (err) {
|
|
331485
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
331486
|
+
return !/no such session|invalid session id|session is either terminated|deleted session/i.test(msg);
|
|
331487
|
+
}
|
|
331488
|
+
}
|
|
331489
|
+
function writeFrameToChannelClient(ch, client, frame) {
|
|
331490
|
+
try {
|
|
331491
|
+
const buffered = typeof client.writableLength === "number" ? client.writableLength : typeof client.socket?.writableLength === "number" ? client.socket.writableLength : 0;
|
|
331492
|
+
if (buffered > SSE_MAX_BUFFERED_BYTES) {
|
|
331493
|
+
try {
|
|
331494
|
+
client.end();
|
|
331495
|
+
} catch {
|
|
331496
|
+
}
|
|
331497
|
+
ch.clients.delete(client);
|
|
331498
|
+
stopChannelPollingIfIdle(ch);
|
|
331499
|
+
return;
|
|
331500
|
+
}
|
|
331501
|
+
client.write(`data: ${JSON.stringify(frame)}
|
|
331502
|
+
|
|
331503
|
+
`);
|
|
331504
|
+
} catch {
|
|
331505
|
+
}
|
|
331506
|
+
}
|
|
331507
|
+
async function pollRunnerChannel(ch) {
|
|
331508
|
+
if (ch.clients.size === 0 || ch.polling) return;
|
|
331509
|
+
ch.polling = true;
|
|
331510
|
+
try {
|
|
331511
|
+
validateSessionId(ch.sessionId);
|
|
331512
|
+
const [screenshot, source] = await Promise.all([
|
|
331513
|
+
getScreenshot(`/session/${ch.sessionId}`),
|
|
331514
|
+
getPageSource(`/session/${ch.sessionId}`)
|
|
331515
|
+
]);
|
|
331516
|
+
if (!screenshot && !source) {
|
|
331517
|
+
ch.nullTicks++;
|
|
331518
|
+
if (ch.nullTicks >= MIRROR_DEAD_SESSION_TICKS) {
|
|
331519
|
+
const alive = await isSessionAlive(ch.sessionId);
|
|
331520
|
+
if (!alive) {
|
|
331521
|
+
closeRunnerChannel(ch);
|
|
331522
|
+
return;
|
|
331523
|
+
}
|
|
331524
|
+
ch.nullTicks = 0;
|
|
331525
|
+
}
|
|
331526
|
+
} else {
|
|
331527
|
+
ch.nullTicks = 0;
|
|
331528
|
+
}
|
|
331529
|
+
const frame = { timestamp: Date.now(), screenshot: screenshot ?? null, source: source ?? null };
|
|
331530
|
+
ch.lastFrame = frame;
|
|
331531
|
+
for (const client of ch.clients) writeFrameToChannelClient(ch, client, frame);
|
|
331532
|
+
} catch {
|
|
331533
|
+
} finally {
|
|
331534
|
+
ch.polling = false;
|
|
331535
|
+
}
|
|
331536
|
+
}
|
|
331537
|
+
function ensureChannelPolling(ch) {
|
|
331538
|
+
if (ch.pollTimer) return;
|
|
331539
|
+
ch.pollTimer = setInterval(() => {
|
|
331540
|
+
void pollRunnerChannel(ch);
|
|
331541
|
+
}, MOBILE_SCREENSHOT_INTERVAL_MS);
|
|
331542
|
+
ch.pollTimer.unref?.();
|
|
331543
|
+
}
|
|
331544
|
+
function stopChannelPollingIfIdle(ch) {
|
|
331545
|
+
if (ch.clients.size === 0 && ch.pollTimer) {
|
|
331546
|
+
clearInterval(ch.pollTimer);
|
|
331547
|
+
ch.pollTimer = null;
|
|
331548
|
+
}
|
|
331549
|
+
}
|
|
331550
|
+
function closeRunnerChannel(ch) {
|
|
331551
|
+
if (ch.pollTimer) {
|
|
331552
|
+
clearInterval(ch.pollTimer);
|
|
331553
|
+
ch.pollTimer = null;
|
|
331554
|
+
}
|
|
331555
|
+
for (const client of ch.clients) {
|
|
331556
|
+
try {
|
|
331557
|
+
client.end();
|
|
331558
|
+
} catch {
|
|
331559
|
+
}
|
|
331560
|
+
}
|
|
331561
|
+
ch.clients.clear();
|
|
331562
|
+
ch.lastFrame = null;
|
|
331563
|
+
runnerChannels.delete(ch.deviceId);
|
|
331564
|
+
}
|
|
331565
|
+
function closeAllRunnerChannels() {
|
|
331566
|
+
for (const ch of [...runnerChannels.values()]) closeRunnerChannel(ch);
|
|
331567
|
+
}
|
|
331568
|
+
async function serveRunnerMirror(req, res, ch) {
|
|
331569
|
+
res.writeHead(200, {
|
|
331570
|
+
"Content-Type": "text/event-stream",
|
|
331571
|
+
"Cache-Control": "no-cache",
|
|
331572
|
+
"Connection": "keep-alive",
|
|
331573
|
+
"Access-Control-Allow-Origin": getAllowedOrigin(req),
|
|
331574
|
+
"X-Content-Type-Options": "nosniff"
|
|
331575
|
+
});
|
|
331576
|
+
ch.clients.add(res);
|
|
331577
|
+
ensureChannelPolling(ch);
|
|
331578
|
+
if (ch.lastFrame) {
|
|
331579
|
+
writeFrameToChannelClient(ch, res, ch.lastFrame);
|
|
331580
|
+
} else {
|
|
331581
|
+
await pollRunnerChannel(ch);
|
|
331582
|
+
}
|
|
331583
|
+
req.on("close", () => {
|
|
331584
|
+
ch.clients.delete(res);
|
|
331585
|
+
stopChannelPollingIfIdle(ch);
|
|
331586
|
+
});
|
|
331587
|
+
}
|
|
331384
331588
|
async function handleMirror(req, res) {
|
|
331589
|
+
const deviceId = readDeviceIdParam(req);
|
|
331590
|
+
if (deviceId) {
|
|
331591
|
+
const ch = runnerChannels.get(deviceId);
|
|
331592
|
+
if (ch) {
|
|
331593
|
+
await serveRunnerMirror(req, res, ch);
|
|
331594
|
+
return;
|
|
331595
|
+
}
|
|
331596
|
+
sendJSON(res, 404, { error: `No live mirror for device ${deviceId}.` });
|
|
331597
|
+
return;
|
|
331598
|
+
}
|
|
331599
|
+
if (!state.appiumSessionId && runnerChannels.size === 1) {
|
|
331600
|
+
const soleChannel = runnerChannels.values().next().value;
|
|
331601
|
+
if (soleChannel) {
|
|
331602
|
+
await serveRunnerMirror(req, res, soleChannel);
|
|
331603
|
+
return;
|
|
331604
|
+
}
|
|
331605
|
+
}
|
|
331385
331606
|
if (!state.appiumSessionId) {
|
|
331386
331607
|
sendJSON(res, 400, { error: "No active Appium session. Call /bridge/session/start first." });
|
|
331387
331608
|
return;
|
|
@@ -331828,6 +332049,7 @@ async function startMobileBridge(options) {
|
|
|
331828
332049
|
}
|
|
331829
332050
|
async function stopMobileBridge() {
|
|
331830
332051
|
stopAllMirrorClients();
|
|
332052
|
+
closeAllRunnerChannels();
|
|
331831
332053
|
stopSessionReaper();
|
|
331832
332054
|
clearBridgePidFile();
|
|
331833
332055
|
if (state.appiumSessionId) {
|
|
@@ -331876,8 +332098,31 @@ function getBridgeState() {
|
|
|
331876
332098
|
function setExecutorBusy(busy) {
|
|
331877
332099
|
executorBusyCount = busy ? executorBusyCount + 1 : Math.max(0, executorBusyCount - 1);
|
|
331878
332100
|
}
|
|
331879
|
-
function attachMirrorSession(sessionId, platform3) {
|
|
332101
|
+
function attachMirrorSession(sessionId, platform3, deviceId) {
|
|
331880
332102
|
validateSessionId(sessionId);
|
|
332103
|
+
if (deviceId) {
|
|
332104
|
+
const existing = runnerChannels.get(deviceId);
|
|
332105
|
+
if (existing && existing.sessionId !== sessionId) closeRunnerChannel(existing);
|
|
332106
|
+
const ch = runnerChannels.get(deviceId) ?? {
|
|
332107
|
+
deviceId,
|
|
332108
|
+
sessionId,
|
|
332109
|
+
platform: platform3,
|
|
332110
|
+
clients: /* @__PURE__ */ new Set(),
|
|
332111
|
+
lastFrame: null,
|
|
332112
|
+
pollTimer: null,
|
|
332113
|
+
nullTicks: 0,
|
|
332114
|
+
polling: false
|
|
332115
|
+
};
|
|
332116
|
+
ch.sessionId = sessionId;
|
|
332117
|
+
ch.platform = platform3;
|
|
332118
|
+
ch.nullTicks = 0;
|
|
332119
|
+
runnerChannels.set(deviceId, ch);
|
|
332120
|
+
if (ch.clients.size > 0) {
|
|
332121
|
+
ensureChannelPolling(ch);
|
|
332122
|
+
void pollRunnerChannel(ch);
|
|
332123
|
+
}
|
|
332124
|
+
return;
|
|
332125
|
+
}
|
|
331881
332126
|
if (state.appiumSessionId && state.appiumSessionId !== sessionId) {
|
|
331882
332127
|
stopAllMirrorClients();
|
|
331883
332128
|
}
|
|
@@ -331892,8 +332137,12 @@ function attachMirrorSession(sessionId, platform3) {
|
|
|
331892
332137
|
void pollAndBroadcastFrame();
|
|
331893
332138
|
}
|
|
331894
332139
|
}
|
|
331895
|
-
function detachMirrorSession(
|
|
331896
|
-
if (
|
|
332140
|
+
function detachMirrorSession(deviceId) {
|
|
332141
|
+
if (deviceId) {
|
|
332142
|
+
const ch = runnerChannels.get(deviceId);
|
|
332143
|
+
if (ch) closeRunnerChannel(ch);
|
|
332144
|
+
return;
|
|
332145
|
+
}
|
|
331897
332146
|
state.appiumSessionId = null;
|
|
331898
332147
|
state.platform = null;
|
|
331899
332148
|
mirrorSessionOwner = null;
|
|
@@ -334079,7 +334328,8 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334079
334328
|
testName: ctx.featureName ?? "Mobile discovery",
|
|
334080
334329
|
note: `${platform3} mobile discovery`,
|
|
334081
334330
|
surface: "mobile",
|
|
334082
|
-
mobilePlatform: platform3
|
|
334331
|
+
mobilePlatform: platform3,
|
|
334332
|
+
...assignedDeviceId ? { deviceId: assignedDeviceId } : {}
|
|
334083
334333
|
});
|
|
334084
334334
|
requestLiveBrowserHeartbeat(true);
|
|
334085
334335
|
mobileOnLog(`Mobile discovery (${ctx.mode}) \u2014 ${platform3} / ${mobileTarget.appId}`);
|
|
@@ -334097,6 +334347,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334097
334347
|
deeplink: mobileTarget.deeplink
|
|
334098
334348
|
}, {
|
|
334099
334349
|
onDeviceSelected: async (device) => {
|
|
334350
|
+
liveBrowserHandle.patch({ deviceId: device.deviceId, deviceName: device.name });
|
|
334100
334351
|
try {
|
|
334101
334352
|
capture = await startMobileNetworkCapture({
|
|
334102
334353
|
platform: platform3,
|
|
@@ -334115,7 +334366,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334115
334366
|
}
|
|
334116
334367
|
});
|
|
334117
334368
|
mobileOnLog(`Appium session created: ${session.sessionId} on device ${session.deviceId}`);
|
|
334118
|
-
attachMirrorSession(session.sessionId, platform3);
|
|
334369
|
+
attachMirrorSession(session.sessionId, platform3, session.deviceId);
|
|
334119
334370
|
const driver = new MobileBridgeDriver({
|
|
334120
334371
|
sessionId: session.sessionId,
|
|
334121
334372
|
platform: platform3,
|
|
@@ -334159,7 +334410,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334159
334410
|
}
|
|
334160
334411
|
}
|
|
334161
334412
|
if (session) {
|
|
334162
|
-
detachMirrorSession(session.
|
|
334413
|
+
detachMirrorSession(session.deviceId);
|
|
334163
334414
|
try {
|
|
334164
334415
|
await session.stop();
|
|
334165
334416
|
} catch {
|
|
@@ -334436,6 +334687,14 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334436
334687
|
return;
|
|
334437
334688
|
}
|
|
334438
334689
|
log.info(`\u{1F4F1} Mobile test - ${target.platform} / ${target.appId}`);
|
|
334690
|
+
const leasedDeviceName = assignedDeviceId ? getCachedDeviceSnapshot().find((d) => d.id === assignedDeviceId)?.name : void 0;
|
|
334691
|
+
liveBrowserHandle.patch({
|
|
334692
|
+
surface: "mobile",
|
|
334693
|
+
mobilePlatform: target.platform,
|
|
334694
|
+
...assignedDeviceId ? { deviceId: assignedDeviceId } : {},
|
|
334695
|
+
...leasedDeviceName ? { deviceName: leasedDeviceName } : {}
|
|
334696
|
+
});
|
|
334697
|
+
requestLiveBrowserHeartbeat(true);
|
|
334439
334698
|
const mobileSteps = run2.steps ?? [];
|
|
334440
334699
|
if (mobileSteps.length === 0) {
|
|
334441
334700
|
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
@@ -334464,6 +334723,15 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334464
334723
|
assignedDeviceId,
|
|
334465
334724
|
launchMode: target.launchMode,
|
|
334466
334725
|
deeplink: target.deeplink
|
|
334726
|
+
},
|
|
334727
|
+
// Stream this run to its OWN per-device dashboard tile (keyed by the
|
|
334728
|
+
// leased device id) so N parallel Appium runs each show a live mirror.
|
|
334729
|
+
onSessionReady: (sessionId, deviceId, sessionPlatform) => {
|
|
334730
|
+
attachMirrorSession(sessionId, sessionPlatform, deviceId);
|
|
334731
|
+
requestLiveBrowserHeartbeat(true);
|
|
334732
|
+
},
|
|
334733
|
+
onSessionEnding: (deviceId) => {
|
|
334734
|
+
detachMirrorSession(deviceId);
|
|
334467
334735
|
}
|
|
334468
334736
|
});
|
|
334469
334737
|
attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
|
|
@@ -334479,6 +334747,12 @@ ${result2.logs}`);
|
|
|
334479
334747
|
if (attemptLogs.length > 1) {
|
|
334480
334748
|
result2 = { ...result2, logs: attemptLogs.join("\n\n") };
|
|
334481
334749
|
}
|
|
334750
|
+
if (result2.deviceId || result2.deviceName) {
|
|
334751
|
+
liveBrowserHandle.patch({
|
|
334752
|
+
...result2.deviceId ? { deviceId: result2.deviceId } : {},
|
|
334753
|
+
...result2.deviceName ? { deviceName: result2.deviceName } : {}
|
|
334754
|
+
});
|
|
334755
|
+
}
|
|
334482
334756
|
const duration2 = Date.now() - startTime;
|
|
334483
334757
|
const status = result2.environmental ? "ERROR" : result2.passed ? "PASSED" : "FAILED";
|
|
334484
334758
|
const reportedError = result2.environmental ? `CONFIG_ISSUE: ${result2.environmental}` : result2.error ?? void 0;
|
package/dist/cli.mjs
CHANGED
|
@@ -66797,16 +66797,16 @@ ${statePacket}` },
|
|
|
66797
66797
|
}
|
|
66798
66798
|
const screen = screenName();
|
|
66799
66799
|
if (accessibilityId) {
|
|
66800
|
-
const bundle2 = { xpath: `//*[@content-desc=${
|
|
66800
|
+
const bundle2 = { xpath: `//*[@content-desc=${xpathLiteral2(accessibilityId)} or @name=${xpathLiteral2(accessibilityId)}]`, className: "", accessibilityId };
|
|
66801
66801
|
recordAction({ type: "ASSERT_VISIBLE", selector: accessibilityId, metadata: { screenName: screen, targetLabel: accessibilityId, locatorBundle: bundle2 } });
|
|
66802
66802
|
return { ok: true, label: accessibilityId };
|
|
66803
66803
|
}
|
|
66804
66804
|
const safeText = text;
|
|
66805
|
-
const bundle = { xpath: `//*[@text=${
|
|
66805
|
+
const bundle = { xpath: `//*[@text=${xpathLiteral2(safeText)} or @label=${xpathLiteral2(safeText)}]`, className: "", text: safeText };
|
|
66806
66806
|
recordAction({ type: "ASSERT_VISIBLE", selector: bundle.xpath, metadata: { screenName: screen, targetLabel: safeText, locatorBundle: bundle } });
|
|
66807
66807
|
return { ok: true, label: safeText };
|
|
66808
66808
|
}
|
|
66809
|
-
function
|
|
66809
|
+
function xpathLiteral2(value) {
|
|
66810
66810
|
if (!value.includes("'"))
|
|
66811
66811
|
return `'${value}'`;
|
|
66812
66812
|
if (!value.includes('"'))
|
|
@@ -328123,7 +328123,7 @@ var require_package4 = __commonJS({
|
|
|
328123
328123
|
"package.json"(exports, module) {
|
|
328124
328124
|
module.exports = {
|
|
328125
328125
|
name: "@validate.qa/runner",
|
|
328126
|
-
version: "1.0.
|
|
328126
|
+
version: "1.0.16",
|
|
328127
328127
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
328128
328128
|
bin: {
|
|
328129
328129
|
"validate-runner": "dist/cli.js"
|
|
@@ -329731,7 +329731,7 @@ async function getWindowSize(sessionId, retries = 3) {
|
|
|
329731
329731
|
}
|
|
329732
329732
|
throw new Error("Failed to get window size: invalid dimensions returned");
|
|
329733
329733
|
}
|
|
329734
|
-
function realDeviceCapabilities(platform3) {
|
|
329734
|
+
function realDeviceCapabilities(platform3, portOffset = 0) {
|
|
329735
329735
|
const env = process.env;
|
|
329736
329736
|
const caps = {};
|
|
329737
329737
|
const num = (v) => {
|
|
@@ -329739,6 +329739,7 @@ function realDeviceCapabilities(platform3) {
|
|
|
329739
329739
|
const n = Number.parseInt(v, 10);
|
|
329740
329740
|
return Number.isFinite(n) ? n : void 0;
|
|
329741
329741
|
};
|
|
329742
|
+
const offset = Number.isFinite(portOffset) && portOffset > 0 ? Math.floor(portOffset) : 0;
|
|
329742
329743
|
if (platform3 === "IOS") {
|
|
329743
329744
|
if (env.APPIUM_XCODE_ORG_ID) {
|
|
329744
329745
|
caps["appium:xcodeOrgId"] = env.APPIUM_XCODE_ORG_ID;
|
|
@@ -329748,15 +329749,15 @@ function realDeviceCapabilities(platform3) {
|
|
|
329748
329749
|
}
|
|
329749
329750
|
if (env.APPIUM_UPDATED_WDA_BUNDLE_ID) caps["appium:updatedWDABundleId"] = env.APPIUM_UPDATED_WDA_BUNDLE_ID;
|
|
329750
329751
|
const wdaPort = num(env.APPIUM_WDA_LOCAL_PORT);
|
|
329751
|
-
if (wdaPort !== void 0) caps["appium:wdaLocalPort"] = wdaPort;
|
|
329752
|
+
if (wdaPort !== void 0) caps["appium:wdaLocalPort"] = wdaPort + offset;
|
|
329752
329753
|
} else {
|
|
329753
329754
|
const systemPort = num(env.APPIUM_SYSTEM_PORT);
|
|
329754
|
-
if (systemPort !== void 0) caps["appium:systemPort"] = systemPort;
|
|
329755
|
+
if (systemPort !== void 0) caps["appium:systemPort"] = systemPort + offset;
|
|
329755
329756
|
}
|
|
329756
329757
|
return caps;
|
|
329757
329758
|
}
|
|
329758
329759
|
async function createSession(target) {
|
|
329759
|
-
const realCaps = target.isPhysical ? realDeviceCapabilities(target.platform) : {};
|
|
329760
|
+
const realCaps = target.isPhysical ? realDeviceCapabilities(target.platform, target.portOffset) : {};
|
|
329760
329761
|
const capabilities = target.platform === "IOS" ? {
|
|
329761
329762
|
platformName: "iOS",
|
|
329762
329763
|
"appium:automationName": "XCUITest",
|
|
@@ -330031,6 +330032,46 @@ function buildLocatorStrategies(target) {
|
|
|
330031
330032
|
(strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
|
|
330032
330033
|
);
|
|
330033
330034
|
}
|
|
330035
|
+
function dedupeStrategies(strategies) {
|
|
330036
|
+
return strategies.filter(
|
|
330037
|
+
(strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
|
|
330038
|
+
);
|
|
330039
|
+
}
|
|
330040
|
+
function xpathLiteral(value) {
|
|
330041
|
+
if (!value.includes("'")) return `'${value}'`;
|
|
330042
|
+
if (!value.includes('"')) return `"${value}"`;
|
|
330043
|
+
const parts = value.split("'").map((p) => `'${p}'`);
|
|
330044
|
+
return `concat(${parts.join(`, "'", `)})`;
|
|
330045
|
+
}
|
|
330046
|
+
function identityTextXpath(value) {
|
|
330047
|
+
const lit = xpathLiteral(value);
|
|
330048
|
+
return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
|
|
330049
|
+
}
|
|
330050
|
+
function isIdentityStrategy(strategy) {
|
|
330051
|
+
if (strategy.using === "accessibility id" || strategy.using === "id") return true;
|
|
330052
|
+
if (strategy.using === "xpath") return strategy.value.includes("@");
|
|
330053
|
+
return false;
|
|
330054
|
+
}
|
|
330055
|
+
function buildIdentityLocatorStrategies(target) {
|
|
330056
|
+
if (!target) return [];
|
|
330057
|
+
const strategies = [];
|
|
330058
|
+
const bundle = target.locatorBundle;
|
|
330059
|
+
if (bundle) {
|
|
330060
|
+
if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
|
|
330061
|
+
if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
|
|
330062
|
+
for (const value of [bundle.text, bundle.contentDesc]) {
|
|
330063
|
+
const trimmed = value?.trim();
|
|
330064
|
+
if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
|
|
330065
|
+
}
|
|
330066
|
+
if (bundle.xpath && bundle.xpath.includes("@")) strategies.push({ using: "xpath", value: bundle.xpath });
|
|
330067
|
+
}
|
|
330068
|
+
for (const value of [target.primary, ...target.fallbacks ?? []]) {
|
|
330069
|
+
for (const strategy of locatorStrategyForValue(value)) {
|
|
330070
|
+
if (isIdentityStrategy(strategy)) strategies.push(strategy);
|
|
330071
|
+
}
|
|
330072
|
+
}
|
|
330073
|
+
return dedupeStrategies(strategies);
|
|
330074
|
+
}
|
|
330034
330075
|
function readStepTimeout(step) {
|
|
330035
330076
|
const value = step.metadata?.timeout;
|
|
330036
330077
|
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
@@ -330038,10 +330079,12 @@ function readStepTimeout(step) {
|
|
|
330038
330079
|
}
|
|
330039
330080
|
return void 0;
|
|
330040
330081
|
}
|
|
330041
|
-
async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS) {
|
|
330042
|
-
const strategies = buildLocatorStrategies(target);
|
|
330082
|
+
async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
|
|
330083
|
+
const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target) : buildLocatorStrategies(target);
|
|
330043
330084
|
if (strategies.length === 0) {
|
|
330044
|
-
throw new Error(
|
|
330085
|
+
throw new Error(
|
|
330086
|
+
opts?.identityOnly ? "No identity locator (accessibility id / resource id / text) available for this step \u2014 cannot verify the screen" : "No locator strategies available for this step"
|
|
330087
|
+
);
|
|
330045
330088
|
}
|
|
330046
330089
|
const startTime = Date.now();
|
|
330047
330090
|
let lastError = "";
|
|
@@ -330150,7 +330193,7 @@ async function executeTap(sessionId, step) {
|
|
|
330150
330193
|
});
|
|
330151
330194
|
return;
|
|
330152
330195
|
} catch (error2) {
|
|
330153
|
-
if (!bounds) throw error2;
|
|
330196
|
+
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
|
|
330154
330197
|
}
|
|
330155
330198
|
}
|
|
330156
330199
|
if (!bounds) {
|
|
@@ -330183,7 +330226,7 @@ async function executeType(sessionId, step, context) {
|
|
|
330183
330226
|
if (!lastInputError) return;
|
|
330184
330227
|
elementId = null;
|
|
330185
330228
|
} catch (error2) {
|
|
330186
|
-
if (!bounds) throw error2;
|
|
330229
|
+
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
|
|
330187
330230
|
lastInputError = error2;
|
|
330188
330231
|
}
|
|
330189
330232
|
}
|
|
@@ -330238,7 +330281,7 @@ async function executeClear(sessionId, step) {
|
|
|
330238
330281
|
try {
|
|
330239
330282
|
elementId = await findElement(sessionId, step.target, readStepTimeout(step));
|
|
330240
330283
|
} catch (error2) {
|
|
330241
|
-
if (!bounds) throw error2;
|
|
330284
|
+
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
|
|
330242
330285
|
}
|
|
330243
330286
|
}
|
|
330244
330287
|
if (!elementId && bounds) {
|
|
@@ -330309,10 +330352,10 @@ async function executeHome(sessionId, platform3) {
|
|
|
330309
330352
|
}
|
|
330310
330353
|
async function executeWaitForElement(sessionId, step) {
|
|
330311
330354
|
const timeout = readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS;
|
|
330312
|
-
await findElement(sessionId, step.target, timeout);
|
|
330355
|
+
await findElement(sessionId, step.target, timeout, { identityOnly: true });
|
|
330313
330356
|
}
|
|
330314
330357
|
async function executeAssertVisible(sessionId, step) {
|
|
330315
|
-
const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
|
|
330358
|
+
const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
|
|
330316
330359
|
const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
|
|
330317
330360
|
if (!result?.value) {
|
|
330318
330361
|
throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
|
|
@@ -330320,7 +330363,7 @@ async function executeAssertVisible(sessionId, step) {
|
|
|
330320
330363
|
}
|
|
330321
330364
|
async function executeAssertText(sessionId, step) {
|
|
330322
330365
|
if (!step.assertion) throw new Error("assertText step requires an assertion value");
|
|
330323
|
-
const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
|
|
330366
|
+
const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
|
|
330324
330367
|
const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
|
|
330325
330368
|
const actualText = typeof result?.value === "string" ? result.value : "";
|
|
330326
330369
|
const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
|
|
@@ -330355,6 +330398,7 @@ async function executeMobileTest(options) {
|
|
|
330355
330398
|
const screenshots = [];
|
|
330356
330399
|
let apiCalls = [];
|
|
330357
330400
|
let sessionId = null;
|
|
330401
|
+
let selectedDevice;
|
|
330358
330402
|
let capture = null;
|
|
330359
330403
|
let captureStopped = false;
|
|
330360
330404
|
let recordingStarted = false;
|
|
@@ -330402,7 +330446,6 @@ async function executeMobileTest(options) {
|
|
|
330402
330446
|
`No ${platformFilter} ${platformFilter === "IOS" ? "simulator" : "emulator"} booted. Start one via ${platformFilter === "IOS" ? "Simulator.app" : "Android Studio"} first.`
|
|
330403
330447
|
);
|
|
330404
330448
|
}
|
|
330405
|
-
let selectedDevice;
|
|
330406
330449
|
if (options.target.assignedDeviceId) {
|
|
330407
330450
|
selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
|
|
330408
330451
|
if (!selectedDevice) {
|
|
@@ -330443,13 +330486,15 @@ async function executeMobileTest(options) {
|
|
|
330443
330486
|
log2(`Warning: Could not retrieve installed apps list. Proceeding with test execution.`);
|
|
330444
330487
|
}
|
|
330445
330488
|
log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
|
|
330489
|
+
const leasedPortOffset = Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id));
|
|
330446
330490
|
try {
|
|
330447
330491
|
sessionId = await createSessionWithRetry({
|
|
330448
330492
|
appId: options.target.appId,
|
|
330449
330493
|
platform: options.target.platform,
|
|
330450
330494
|
deviceId: selectedDevice.id,
|
|
330451
330495
|
platformVersion: options.target.platformVersion ?? selectedDevice.platformVersion,
|
|
330452
|
-
isPhysical: selectedDevice.isPhysical
|
|
330496
|
+
isPhysical: selectedDevice.isPhysical,
|
|
330497
|
+
portOffset: leasedPortOffset
|
|
330453
330498
|
}, (msg) => log2(msg));
|
|
330454
330499
|
} catch (err) {
|
|
330455
330500
|
const base2 = err instanceof Error ? err.message : String(err);
|
|
@@ -330465,6 +330510,12 @@ async function executeMobileTest(options) {
|
|
|
330465
330510
|
throw err;
|
|
330466
330511
|
}
|
|
330467
330512
|
log2(`Session created: ${sessionId}`);
|
|
330513
|
+
if (selectedDevice.id) {
|
|
330514
|
+
try {
|
|
330515
|
+
options.onSessionReady?.(sessionId, selectedDevice.id, options.target.platform);
|
|
330516
|
+
} catch {
|
|
330517
|
+
}
|
|
330518
|
+
}
|
|
330468
330519
|
recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
|
|
330469
330520
|
log2("Opening a fresh app instance for the test...");
|
|
330470
330521
|
await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
|
|
@@ -330477,8 +330528,26 @@ async function executeMobileTest(options) {
|
|
|
330477
330528
|
}
|
|
330478
330529
|
}
|
|
330479
330530
|
await restoreTargetAppIfExternal(sessionId, options.target, log2, "session start", false);
|
|
330531
|
+
let entryDrift = false;
|
|
330532
|
+
const entryStep = options.steps.find(
|
|
330533
|
+
(s) => s.action !== "launchApp" && buildIdentityLocatorStrategies(s.target).length > 0
|
|
330534
|
+
);
|
|
330535
|
+
if (entryStep) {
|
|
330536
|
+
try {
|
|
330537
|
+
await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
|
|
330538
|
+
} catch (err) {
|
|
330539
|
+
if (isTransportError(err)) throw err;
|
|
330540
|
+
entryDrift = true;
|
|
330541
|
+
const driftMsg = `App started on an unexpected screen \u2014 the recorded flow's first element ("${entryStep.target?.primary ?? entryStep.description}") is not present, so the replay was aborted before it could blind-tap stale coordinates (screen drift; the app may be on onboarding or a different screen than when the test was recorded).`;
|
|
330542
|
+
log2(` \u2717 Entry-screen drift: ${driftMsg}`);
|
|
330543
|
+
stepResults.push({ stepOrder: entryStep.order, status: "failed", duration: 0, error: driftMsg });
|
|
330544
|
+
const driftShot = await takeScreenshot(sessionId).catch(() => null);
|
|
330545
|
+
screenshots.push(driftShot ?? "");
|
|
330546
|
+
}
|
|
330547
|
+
}
|
|
330480
330548
|
let environmental = null;
|
|
330481
330549
|
for (const step of options.steps) {
|
|
330550
|
+
if (entryDrift) break;
|
|
330482
330551
|
const stepStart = Date.now();
|
|
330483
330552
|
log2(`Step ${step.order}: ${step.action} - ${step.description}`);
|
|
330484
330553
|
let status = "passed";
|
|
@@ -330556,7 +330625,7 @@ async function executeMobileTest(options) {
|
|
|
330556
330625
|
break;
|
|
330557
330626
|
}
|
|
330558
330627
|
}
|
|
330559
|
-
const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
|
|
330628
|
+
const allPassed = !environmental && stepResults.length > 0 && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
|
|
330560
330629
|
const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
|
|
330561
330630
|
await stopCapture();
|
|
330562
330631
|
await stopRecording();
|
|
@@ -330569,6 +330638,8 @@ async function executeMobileTest(options) {
|
|
|
330569
330638
|
logs: logLines.join("\n"),
|
|
330570
330639
|
error: firstError,
|
|
330571
330640
|
duration: Date.now() - startTime,
|
|
330641
|
+
deviceId: selectedDevice?.id,
|
|
330642
|
+
deviceName: selectedDevice?.name,
|
|
330572
330643
|
environmental: environmental ?? void 0
|
|
330573
330644
|
};
|
|
330574
330645
|
} catch (err) {
|
|
@@ -330585,6 +330656,8 @@ async function executeMobileTest(options) {
|
|
|
330585
330656
|
logs: logLines.join("\n"),
|
|
330586
330657
|
error: errorMsg,
|
|
330587
330658
|
duration: Date.now() - startTime,
|
|
330659
|
+
deviceId: selectedDevice?.id,
|
|
330660
|
+
deviceName: selectedDevice?.name,
|
|
330588
330661
|
// A throw before/around the step loop is a setup/environment problem
|
|
330589
330662
|
// (no device booted, app not installed, session could not be created,
|
|
330590
330663
|
// Appium unreachable) — never a genuine test assertion failure.
|
|
@@ -330594,6 +330667,12 @@ async function executeMobileTest(options) {
|
|
|
330594
330667
|
await stopCapture();
|
|
330595
330668
|
await stopRecording();
|
|
330596
330669
|
if (sessionId) {
|
|
330670
|
+
if (selectedDevice?.id) {
|
|
330671
|
+
try {
|
|
330672
|
+
options.onSessionEnding?.(selectedDevice.id);
|
|
330673
|
+
} catch {
|
|
330674
|
+
}
|
|
330675
|
+
}
|
|
330597
330676
|
try {
|
|
330598
330677
|
log2("Closing the app on the device...");
|
|
330599
330678
|
await terminateTargetApp(sessionId, options.target.platform, options.target.appId);
|
|
@@ -330652,7 +330731,9 @@ async function createMobileDiscoverySession(target, options) {
|
|
|
330652
330731
|
platform: target.platform,
|
|
330653
330732
|
deviceId: selectedDevice.id,
|
|
330654
330733
|
platformVersion: target.platformVersion ?? selectedDevice.platformVersion,
|
|
330655
|
-
isPhysical: selectedDevice.isPhysical
|
|
330734
|
+
isPhysical: selectedDevice.isPhysical,
|
|
330735
|
+
// Distinct control port per concurrent physical device (no-op for emulators/sims).
|
|
330736
|
+
portOffset: Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id))
|
|
330656
330737
|
});
|
|
330657
330738
|
if (target.launchMode === "DEEP_LINK" && target.deeplink) {
|
|
330658
330739
|
try {
|
|
@@ -330664,6 +330745,7 @@ async function createMobileDiscoverySession(target, options) {
|
|
|
330664
330745
|
return {
|
|
330665
330746
|
sessionId,
|
|
330666
330747
|
deviceId: selectedDevice.id,
|
|
330748
|
+
deviceName: selectedDevice.name,
|
|
330667
330749
|
isPhysical: selectedDevice.isPhysical === true,
|
|
330668
330750
|
platform: target.platform,
|
|
330669
330751
|
appId: target.appId,
|
|
@@ -331389,7 +331471,146 @@ async function handleSessionStop(_req, res) {
|
|
|
331389
331471
|
stopSessionReaper();
|
|
331390
331472
|
sendJSON(res, 200, { ok: true });
|
|
331391
331473
|
}
|
|
331474
|
+
var runnerChannels = /* @__PURE__ */ new Map();
|
|
331475
|
+
function readDeviceIdParam(req) {
|
|
331476
|
+
try {
|
|
331477
|
+
const url = new URL(req.url || "", "http://localhost");
|
|
331478
|
+
const raw = url.searchParams.get("deviceId");
|
|
331479
|
+
if (!raw) return null;
|
|
331480
|
+
const trimmed = raw.trim();
|
|
331481
|
+
if (!trimmed || trimmed.length > MAX_DEVICE_ID_LENGTH || !DEVICE_ID_PATTERN.test(trimmed)) return null;
|
|
331482
|
+
return trimmed;
|
|
331483
|
+
} catch {
|
|
331484
|
+
return null;
|
|
331485
|
+
}
|
|
331486
|
+
}
|
|
331487
|
+
async function isSessionAlive(sessionId) {
|
|
331488
|
+
try {
|
|
331489
|
+
validateSessionId(sessionId);
|
|
331490
|
+
await appiumRequest2(`/session/${sessionId}/window/rect`);
|
|
331491
|
+
return true;
|
|
331492
|
+
} catch (err) {
|
|
331493
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
331494
|
+
return !/no such session|invalid session id|session is either terminated|deleted session/i.test(msg);
|
|
331495
|
+
}
|
|
331496
|
+
}
|
|
331497
|
+
function writeFrameToChannelClient(ch, client, frame) {
|
|
331498
|
+
try {
|
|
331499
|
+
const buffered = typeof client.writableLength === "number" ? client.writableLength : typeof client.socket?.writableLength === "number" ? client.socket.writableLength : 0;
|
|
331500
|
+
if (buffered > SSE_MAX_BUFFERED_BYTES) {
|
|
331501
|
+
try {
|
|
331502
|
+
client.end();
|
|
331503
|
+
} catch {
|
|
331504
|
+
}
|
|
331505
|
+
ch.clients.delete(client);
|
|
331506
|
+
stopChannelPollingIfIdle(ch);
|
|
331507
|
+
return;
|
|
331508
|
+
}
|
|
331509
|
+
client.write(`data: ${JSON.stringify(frame)}
|
|
331510
|
+
|
|
331511
|
+
`);
|
|
331512
|
+
} catch {
|
|
331513
|
+
}
|
|
331514
|
+
}
|
|
331515
|
+
async function pollRunnerChannel(ch) {
|
|
331516
|
+
if (ch.clients.size === 0 || ch.polling) return;
|
|
331517
|
+
ch.polling = true;
|
|
331518
|
+
try {
|
|
331519
|
+
validateSessionId(ch.sessionId);
|
|
331520
|
+
const [screenshot, source] = await Promise.all([
|
|
331521
|
+
getScreenshot(`/session/${ch.sessionId}`),
|
|
331522
|
+
getPageSource(`/session/${ch.sessionId}`)
|
|
331523
|
+
]);
|
|
331524
|
+
if (!screenshot && !source) {
|
|
331525
|
+
ch.nullTicks++;
|
|
331526
|
+
if (ch.nullTicks >= MIRROR_DEAD_SESSION_TICKS) {
|
|
331527
|
+
const alive = await isSessionAlive(ch.sessionId);
|
|
331528
|
+
if (!alive) {
|
|
331529
|
+
closeRunnerChannel(ch);
|
|
331530
|
+
return;
|
|
331531
|
+
}
|
|
331532
|
+
ch.nullTicks = 0;
|
|
331533
|
+
}
|
|
331534
|
+
} else {
|
|
331535
|
+
ch.nullTicks = 0;
|
|
331536
|
+
}
|
|
331537
|
+
const frame = { timestamp: Date.now(), screenshot: screenshot ?? null, source: source ?? null };
|
|
331538
|
+
ch.lastFrame = frame;
|
|
331539
|
+
for (const client of ch.clients) writeFrameToChannelClient(ch, client, frame);
|
|
331540
|
+
} catch {
|
|
331541
|
+
} finally {
|
|
331542
|
+
ch.polling = false;
|
|
331543
|
+
}
|
|
331544
|
+
}
|
|
331545
|
+
function ensureChannelPolling(ch) {
|
|
331546
|
+
if (ch.pollTimer) return;
|
|
331547
|
+
ch.pollTimer = setInterval(() => {
|
|
331548
|
+
void pollRunnerChannel(ch);
|
|
331549
|
+
}, MOBILE_SCREENSHOT_INTERVAL_MS);
|
|
331550
|
+
ch.pollTimer.unref?.();
|
|
331551
|
+
}
|
|
331552
|
+
function stopChannelPollingIfIdle(ch) {
|
|
331553
|
+
if (ch.clients.size === 0 && ch.pollTimer) {
|
|
331554
|
+
clearInterval(ch.pollTimer);
|
|
331555
|
+
ch.pollTimer = null;
|
|
331556
|
+
}
|
|
331557
|
+
}
|
|
331558
|
+
function closeRunnerChannel(ch) {
|
|
331559
|
+
if (ch.pollTimer) {
|
|
331560
|
+
clearInterval(ch.pollTimer);
|
|
331561
|
+
ch.pollTimer = null;
|
|
331562
|
+
}
|
|
331563
|
+
for (const client of ch.clients) {
|
|
331564
|
+
try {
|
|
331565
|
+
client.end();
|
|
331566
|
+
} catch {
|
|
331567
|
+
}
|
|
331568
|
+
}
|
|
331569
|
+
ch.clients.clear();
|
|
331570
|
+
ch.lastFrame = null;
|
|
331571
|
+
runnerChannels.delete(ch.deviceId);
|
|
331572
|
+
}
|
|
331573
|
+
function closeAllRunnerChannels() {
|
|
331574
|
+
for (const ch of [...runnerChannels.values()]) closeRunnerChannel(ch);
|
|
331575
|
+
}
|
|
331576
|
+
async function serveRunnerMirror(req, res, ch) {
|
|
331577
|
+
res.writeHead(200, {
|
|
331578
|
+
"Content-Type": "text/event-stream",
|
|
331579
|
+
"Cache-Control": "no-cache",
|
|
331580
|
+
"Connection": "keep-alive",
|
|
331581
|
+
"Access-Control-Allow-Origin": getAllowedOrigin(req),
|
|
331582
|
+
"X-Content-Type-Options": "nosniff"
|
|
331583
|
+
});
|
|
331584
|
+
ch.clients.add(res);
|
|
331585
|
+
ensureChannelPolling(ch);
|
|
331586
|
+
if (ch.lastFrame) {
|
|
331587
|
+
writeFrameToChannelClient(ch, res, ch.lastFrame);
|
|
331588
|
+
} else {
|
|
331589
|
+
await pollRunnerChannel(ch);
|
|
331590
|
+
}
|
|
331591
|
+
req.on("close", () => {
|
|
331592
|
+
ch.clients.delete(res);
|
|
331593
|
+
stopChannelPollingIfIdle(ch);
|
|
331594
|
+
});
|
|
331595
|
+
}
|
|
331392
331596
|
async function handleMirror(req, res) {
|
|
331597
|
+
const deviceId = readDeviceIdParam(req);
|
|
331598
|
+
if (deviceId) {
|
|
331599
|
+
const ch = runnerChannels.get(deviceId);
|
|
331600
|
+
if (ch) {
|
|
331601
|
+
await serveRunnerMirror(req, res, ch);
|
|
331602
|
+
return;
|
|
331603
|
+
}
|
|
331604
|
+
sendJSON(res, 404, { error: `No live mirror for device ${deviceId}.` });
|
|
331605
|
+
return;
|
|
331606
|
+
}
|
|
331607
|
+
if (!state.appiumSessionId && runnerChannels.size === 1) {
|
|
331608
|
+
const soleChannel = runnerChannels.values().next().value;
|
|
331609
|
+
if (soleChannel) {
|
|
331610
|
+
await serveRunnerMirror(req, res, soleChannel);
|
|
331611
|
+
return;
|
|
331612
|
+
}
|
|
331613
|
+
}
|
|
331393
331614
|
if (!state.appiumSessionId) {
|
|
331394
331615
|
sendJSON(res, 400, { error: "No active Appium session. Call /bridge/session/start first." });
|
|
331395
331616
|
return;
|
|
@@ -331836,6 +332057,7 @@ async function startMobileBridge(options) {
|
|
|
331836
332057
|
}
|
|
331837
332058
|
async function stopMobileBridge() {
|
|
331838
332059
|
stopAllMirrorClients();
|
|
332060
|
+
closeAllRunnerChannels();
|
|
331839
332061
|
stopSessionReaper();
|
|
331840
332062
|
clearBridgePidFile();
|
|
331841
332063
|
if (state.appiumSessionId) {
|
|
@@ -331884,8 +332106,31 @@ function getBridgeState() {
|
|
|
331884
332106
|
function setExecutorBusy(busy) {
|
|
331885
332107
|
executorBusyCount = busy ? executorBusyCount + 1 : Math.max(0, executorBusyCount - 1);
|
|
331886
332108
|
}
|
|
331887
|
-
function attachMirrorSession(sessionId, platform3) {
|
|
332109
|
+
function attachMirrorSession(sessionId, platform3, deviceId) {
|
|
331888
332110
|
validateSessionId(sessionId);
|
|
332111
|
+
if (deviceId) {
|
|
332112
|
+
const existing = runnerChannels.get(deviceId);
|
|
332113
|
+
if (existing && existing.sessionId !== sessionId) closeRunnerChannel(existing);
|
|
332114
|
+
const ch = runnerChannels.get(deviceId) ?? {
|
|
332115
|
+
deviceId,
|
|
332116
|
+
sessionId,
|
|
332117
|
+
platform: platform3,
|
|
332118
|
+
clients: /* @__PURE__ */ new Set(),
|
|
332119
|
+
lastFrame: null,
|
|
332120
|
+
pollTimer: null,
|
|
332121
|
+
nullTicks: 0,
|
|
332122
|
+
polling: false
|
|
332123
|
+
};
|
|
332124
|
+
ch.sessionId = sessionId;
|
|
332125
|
+
ch.platform = platform3;
|
|
332126
|
+
ch.nullTicks = 0;
|
|
332127
|
+
runnerChannels.set(deviceId, ch);
|
|
332128
|
+
if (ch.clients.size > 0) {
|
|
332129
|
+
ensureChannelPolling(ch);
|
|
332130
|
+
void pollRunnerChannel(ch);
|
|
332131
|
+
}
|
|
332132
|
+
return;
|
|
332133
|
+
}
|
|
331889
332134
|
if (state.appiumSessionId && state.appiumSessionId !== sessionId) {
|
|
331890
332135
|
stopAllMirrorClients();
|
|
331891
332136
|
}
|
|
@@ -331900,8 +332145,12 @@ function attachMirrorSession(sessionId, platform3) {
|
|
|
331900
332145
|
void pollAndBroadcastFrame();
|
|
331901
332146
|
}
|
|
331902
332147
|
}
|
|
331903
|
-
function detachMirrorSession(
|
|
331904
|
-
if (
|
|
332148
|
+
function detachMirrorSession(deviceId) {
|
|
332149
|
+
if (deviceId) {
|
|
332150
|
+
const ch = runnerChannels.get(deviceId);
|
|
332151
|
+
if (ch) closeRunnerChannel(ch);
|
|
332152
|
+
return;
|
|
332153
|
+
}
|
|
331905
332154
|
state.appiumSessionId = null;
|
|
331906
332155
|
state.platform = null;
|
|
331907
332156
|
mirrorSessionOwner = null;
|
|
@@ -334087,7 +334336,8 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334087
334336
|
testName: ctx.featureName ?? "Mobile discovery",
|
|
334088
334337
|
note: `${platform3} mobile discovery`,
|
|
334089
334338
|
surface: "mobile",
|
|
334090
|
-
mobilePlatform: platform3
|
|
334339
|
+
mobilePlatform: platform3,
|
|
334340
|
+
...assignedDeviceId ? { deviceId: assignedDeviceId } : {}
|
|
334091
334341
|
});
|
|
334092
334342
|
requestLiveBrowserHeartbeat(true);
|
|
334093
334343
|
mobileOnLog(`Mobile discovery (${ctx.mode}) \u2014 ${platform3} / ${mobileTarget.appId}`);
|
|
@@ -334105,6 +334355,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334105
334355
|
deeplink: mobileTarget.deeplink
|
|
334106
334356
|
}, {
|
|
334107
334357
|
onDeviceSelected: async (device) => {
|
|
334358
|
+
liveBrowserHandle.patch({ deviceId: device.deviceId, deviceName: device.name });
|
|
334108
334359
|
try {
|
|
334109
334360
|
capture = await startMobileNetworkCapture({
|
|
334110
334361
|
platform: platform3,
|
|
@@ -334123,7 +334374,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334123
334374
|
}
|
|
334124
334375
|
});
|
|
334125
334376
|
mobileOnLog(`Appium session created: ${session.sessionId} on device ${session.deviceId}`);
|
|
334126
|
-
attachMirrorSession(session.sessionId, platform3);
|
|
334377
|
+
attachMirrorSession(session.sessionId, platform3, session.deviceId);
|
|
334127
334378
|
const driver = new MobileBridgeDriver({
|
|
334128
334379
|
sessionId: session.sessionId,
|
|
334129
334380
|
platform: platform3,
|
|
@@ -334167,7 +334418,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334167
334418
|
}
|
|
334168
334419
|
}
|
|
334169
334420
|
if (session) {
|
|
334170
|
-
detachMirrorSession(session.
|
|
334421
|
+
detachMirrorSession(session.deviceId);
|
|
334171
334422
|
try {
|
|
334172
334423
|
await session.stop();
|
|
334173
334424
|
} catch {
|
|
@@ -334444,6 +334695,14 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334444
334695
|
return;
|
|
334445
334696
|
}
|
|
334446
334697
|
log.info(`\u{1F4F1} Mobile test - ${target.platform} / ${target.appId}`);
|
|
334698
|
+
const leasedDeviceName = assignedDeviceId ? getCachedDeviceSnapshot().find((d) => d.id === assignedDeviceId)?.name : void 0;
|
|
334699
|
+
liveBrowserHandle.patch({
|
|
334700
|
+
surface: "mobile",
|
|
334701
|
+
mobilePlatform: target.platform,
|
|
334702
|
+
...assignedDeviceId ? { deviceId: assignedDeviceId } : {},
|
|
334703
|
+
...leasedDeviceName ? { deviceName: leasedDeviceName } : {}
|
|
334704
|
+
});
|
|
334705
|
+
requestLiveBrowserHeartbeat(true);
|
|
334447
334706
|
const mobileSteps = run2.steps ?? [];
|
|
334448
334707
|
if (mobileSteps.length === 0) {
|
|
334449
334708
|
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
@@ -334472,6 +334731,15 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334472
334731
|
assignedDeviceId,
|
|
334473
334732
|
launchMode: target.launchMode,
|
|
334474
334733
|
deeplink: target.deeplink
|
|
334734
|
+
},
|
|
334735
|
+
// Stream this run to its OWN per-device dashboard tile (keyed by the
|
|
334736
|
+
// leased device id) so N parallel Appium runs each show a live mirror.
|
|
334737
|
+
onSessionReady: (sessionId, deviceId, sessionPlatform) => {
|
|
334738
|
+
attachMirrorSession(sessionId, sessionPlatform, deviceId);
|
|
334739
|
+
requestLiveBrowserHeartbeat(true);
|
|
334740
|
+
},
|
|
334741
|
+
onSessionEnding: (deviceId) => {
|
|
334742
|
+
detachMirrorSession(deviceId);
|
|
334475
334743
|
}
|
|
334476
334744
|
});
|
|
334477
334745
|
attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
|
|
@@ -334487,6 +334755,12 @@ ${result2.logs}`);
|
|
|
334487
334755
|
if (attemptLogs.length > 1) {
|
|
334488
334756
|
result2 = { ...result2, logs: attemptLogs.join("\n\n") };
|
|
334489
334757
|
}
|
|
334758
|
+
if (result2.deviceId || result2.deviceName) {
|
|
334759
|
+
liveBrowserHandle.patch({
|
|
334760
|
+
...result2.deviceId ? { deviceId: result2.deviceId } : {},
|
|
334761
|
+
...result2.deviceName ? { deviceName: result2.deviceName } : {}
|
|
334762
|
+
});
|
|
334763
|
+
}
|
|
334490
334764
|
const duration2 = Date.now() - startTime;
|
|
334491
334765
|
const status = result2.environmental ? "ERROR" : result2.passed ? "PASSED" : "FAILED";
|
|
334492
334766
|
const reportedError = result2.environmental ? `CONFIG_ISSUE: ${result2.environmental}` : result2.error ?? void 0;
|
package/package.json
CHANGED