Add a real iOS Safari acceptance harness and documentation for terminal verification gates. - Add an `ios:acceptance` script with check, dry-run, and real WebDriver session modes. - Support BrowserStack, Sauce Labs, and LambdaTest credential discovery with redacted capability planning. - Document the real-iOS provisioning workflow and link it from testing docs. - Cover credential selection, URL validation, capability shaping, and CLI behavior with node tests. Files changed: docs/README.md | 1 + docs/ios-acceptance.md | 144 ++++++++++++++++ docs/testing.md | 3 + package.json | 1 + scripts/__tests__/ios-acceptance.test.mjs | 131 +++++++++++++++ scripts/ios-acceptance.mjs | 264 ++++++++++++++++++++++++++++++ scripts/lib/ios-acceptance.mjs | 243 +++++++++++++++++++++++++++ 7 files changed, 787 insertions(+) Fusion-Task-Id: FN-6667 Fusion-Task-Lineage: 9eee6dbe-646e-4fff-a1da-fd59cdd2cb59
244 lines
7.4 KiB
JavaScript
244 lines
7.4 KiB
JavaScript
/**
|
|
* FNXC:iOSAcceptance 2026-06-18-16:45:
|
|
* Real iOS Safari is the only acceptable terminal wide-glyph gate because Playwright, desktop WebKit, jsdom, and iOS simulators did not reproduce the ASCII cell-width bug that let repeated blind fixes ship. Keep this module pure so availability probes and tests can enumerate credentials and capabilities without network access or secret logging.
|
|
*/
|
|
|
|
import { URL } from "node:url";
|
|
|
|
export const IOS_PROVIDER_ORDER = ["browserstack", "sauce", "lambdatest"];
|
|
|
|
export const IOS_PROVIDER_CONFIG = {
|
|
browserstack: {
|
|
label: "BrowserStack",
|
|
usernameKey: "BROWSERSTACK_USERNAME",
|
|
accessKey: "BROWSERSTACK_ACCESS_KEY",
|
|
hubEnvKey: "BROWSERSTACK_HUB_URL",
|
|
defaultHubUrl: "https://hub-cloud.browserstack.com/wd/hub",
|
|
defaultDeviceName: "iPhone 15",
|
|
defaultPlatformVersion: "17",
|
|
},
|
|
sauce: {
|
|
label: "Sauce Labs",
|
|
usernameKey: "SAUCE_USERNAME",
|
|
accessKey: "SAUCE_ACCESS_KEY",
|
|
hubEnvKey: "SAUCE_HUB_URL",
|
|
defaultHubUrl: "https://ondemand.us-west-1.saucelabs.com/wd/hub",
|
|
defaultDeviceName: "iPhone 15",
|
|
defaultPlatformVersion: "17",
|
|
},
|
|
lambdatest: {
|
|
label: "LambdaTest",
|
|
usernameKey: "LT_USERNAME",
|
|
accessKey: "LT_ACCESS_KEY",
|
|
hubEnvKey: "LT_HUB_URL",
|
|
defaultHubUrl: "https://mobile-hub.lambdatest.com/wd/hub",
|
|
defaultDeviceName: "iPhone 15",
|
|
defaultPlatformVersion: "17",
|
|
},
|
|
};
|
|
|
|
export function normalizeProvider(provider) {
|
|
const normalized = String(provider ?? "").trim().toLowerCase();
|
|
if (normalized === "lt" || normalized === "lambda-test" || normalized === "lambda_test") {
|
|
return "lambdatest";
|
|
}
|
|
if (normalized === "browser-stack" || normalized === "browser_stack") {
|
|
return "browserstack";
|
|
}
|
|
if (IOS_PROVIDER_CONFIG[normalized]) {
|
|
return normalized;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function nonEmpty(value) {
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
}
|
|
|
|
export function checkedCredentialKeys() {
|
|
return IOS_PROVIDER_ORDER.flatMap((provider) => {
|
|
const config = IOS_PROVIDER_CONFIG[provider];
|
|
return [config.usernameKey, config.accessKey];
|
|
});
|
|
}
|
|
|
|
export function credentialsForProvider(provider, env = {}) {
|
|
const normalized = normalizeProvider(provider);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
const config = IOS_PROVIDER_CONFIG[normalized];
|
|
const username = env[config.usernameKey];
|
|
const accessKey = env[config.accessKey];
|
|
if (!nonEmpty(username) || !nonEmpty(accessKey)) {
|
|
return null;
|
|
}
|
|
return {
|
|
username: username.trim(),
|
|
accessKey: accessKey.trim(),
|
|
usernameKey: config.usernameKey,
|
|
accessKeyName: config.accessKey,
|
|
};
|
|
}
|
|
|
|
export function resolveIosProvider(env = {}) {
|
|
for (const provider of IOS_PROVIDER_ORDER) {
|
|
if (credentialsForProvider(provider, env)) {
|
|
return provider;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function describeAvailability(env = {}) {
|
|
const checkedKeys = checkedCredentialKeys();
|
|
const provider = resolveIosProvider(env);
|
|
return {
|
|
available: provider !== null,
|
|
provider,
|
|
checkedKeys,
|
|
missing: checkedKeys.filter((key) => !nonEmpty(env[key])),
|
|
};
|
|
}
|
|
|
|
export function iosHubUrl(provider, creds, env = {}) {
|
|
const normalized = normalizeProvider(provider);
|
|
if (!normalized) {
|
|
throw new Error(`Unsupported iOS provider: ${provider}`);
|
|
}
|
|
const config = IOS_PROVIDER_CONFIG[normalized];
|
|
const resolvedCreds = creds ?? credentialsForProvider(normalized, env);
|
|
if (!resolvedCreds || !nonEmpty(resolvedCreds.username) || !nonEmpty(resolvedCreds.accessKey)) {
|
|
throw new Error(`Missing credentials for ${normalized}`);
|
|
}
|
|
const base = nonEmpty(env[config.hubEnvKey]) ? env[config.hubEnvKey].trim() : config.defaultHubUrl;
|
|
const url = new URL(base);
|
|
url.username = resolvedCreds.username.trim();
|
|
url.password = resolvedCreds.accessKey.trim();
|
|
return url.toString();
|
|
}
|
|
|
|
export function redactSecretValue(value) {
|
|
return nonEmpty(value) ? "<redacted>" : value;
|
|
}
|
|
|
|
export function redactUrl(value) {
|
|
if (!nonEmpty(value)) {
|
|
return value;
|
|
}
|
|
try {
|
|
const url = new URL(value);
|
|
if (!url.username && !url.password) {
|
|
return url.toString();
|
|
}
|
|
return `${url.protocol}//<redacted>:<redacted>@${url.host}${url.pathname}${url.search}${url.hash}`;
|
|
} catch {
|
|
return String(value).replace(/\/\/([^:@/\s]+):([^@/\s]+)@/g, "//<redacted>:<redacted>@");
|
|
}
|
|
}
|
|
|
|
function resolveCapabilityOption(opts, env, provider, optionName, envSuffix, fallbackName) {
|
|
const config = IOS_PROVIDER_CONFIG[provider];
|
|
const providerPrefix = provider === "browserstack" ? "BROWSERSTACK" : provider === "sauce" ? "SAUCE" : "LT";
|
|
const envKey = `${providerPrefix}_${envSuffix}`;
|
|
return opts[optionName] ?? env?.[envKey] ?? config[fallbackName];
|
|
}
|
|
|
|
export function buildIosCapabilities(provider, opts = {}) {
|
|
const normalized = normalizeProvider(provider);
|
|
if (!normalized) {
|
|
throw new Error(`Unsupported iOS provider: ${provider}`);
|
|
}
|
|
const env = opts.env ?? {};
|
|
const deviceName = String(
|
|
resolveCapabilityOption(opts, env, normalized, "deviceName", "IOS_DEVICE", "defaultDeviceName"),
|
|
).trim();
|
|
const platformVersion = String(
|
|
resolveCapabilityOption(opts, env, normalized, "platformVersion", "IOS_VERSION", "defaultPlatformVersion"),
|
|
).trim();
|
|
const sessionName = String(opts.name ?? "Fusion real-iOS Safari acceptance").trim();
|
|
const buildName = String(opts.build ?? "FN-6667 ios-acceptance").trim();
|
|
|
|
if (normalized === "browserstack") {
|
|
return {
|
|
browserName: "safari",
|
|
platformName: "iOS",
|
|
"bstack:options": {
|
|
deviceName,
|
|
osVersion: platformVersion,
|
|
realMobile: true,
|
|
projectName: "Fusion",
|
|
buildName,
|
|
sessionName,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (normalized === "sauce") {
|
|
return {
|
|
browserName: "safari",
|
|
platformName: "iOS",
|
|
"appium:deviceName": deviceName,
|
|
"appium:platformVersion": platformVersion,
|
|
"appium:automationName": "XCUITest",
|
|
"sauce:options": {
|
|
name: sessionName,
|
|
build: buildName,
|
|
realDevice: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
browserName: "safari",
|
|
platformName: "iOS",
|
|
"LT:Options": {
|
|
deviceName,
|
|
platformVersion,
|
|
platformName: "iOS",
|
|
isRealMobile: true,
|
|
name: sessionName,
|
|
build: buildName,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function publicCapabilityPlan(provider, opts = {}) {
|
|
const normalized = normalizeProvider(provider);
|
|
const capabilities = buildIosCapabilities(normalized, opts);
|
|
return {
|
|
provider: normalized,
|
|
device: capabilityDeviceName(normalized, capabilities),
|
|
platformVersion: capabilityPlatformVersion(normalized, capabilities),
|
|
capabilities,
|
|
};
|
|
}
|
|
|
|
export function capabilityDeviceName(provider, capabilities) {
|
|
const normalized = normalizeProvider(provider);
|
|
if (normalized === "browserstack") {
|
|
return capabilities["bstack:options"]?.deviceName ?? null;
|
|
}
|
|
if (normalized === "sauce") {
|
|
return capabilities["appium:deviceName"] ?? null;
|
|
}
|
|
if (normalized === "lambdatest") {
|
|
return capabilities["LT:Options"]?.deviceName ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function capabilityPlatformVersion(provider, capabilities) {
|
|
const normalized = normalizeProvider(provider);
|
|
if (normalized === "browserstack") {
|
|
return capabilities["bstack:options"]?.osVersion ?? null;
|
|
}
|
|
if (normalized === "sauce") {
|
|
return capabilities["appium:platformVersion"] ?? null;
|
|
}
|
|
if (normalized === "lambdatest") {
|
|
return capabilities["LT:Options"]?.platformVersion ?? null;
|
|
}
|
|
return null;
|
|
}
|