Files
fusion/scripts/ios-acceptance.mjs
gsxdsm deb1717812 FN-6667: add real iOS Safari acceptance harness
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
2026-06-18 16:47:09 -07:00

265 lines
8.5 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* FNXC:iOSAcceptance 2026-06-18-17:02:
* Terminal acceptance gates need a cheap run-vs-NO-OP probe and a dependency-light real-device WebDriver path. This CLI emits only structured, redacted metadata so cloud credentials can be supplied through env or Fusion secrets materialization without leaking plaintext into logs.
*/
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { URL } from "node:url";
import {
buildIosCapabilities,
capabilityDeviceName,
capabilityPlatformVersion,
credentialsForProvider,
describeAvailability,
iosHubUrl,
normalizeProvider,
publicCapabilityPlan,
redactUrl,
} from "./lib/ios-acceptance.mjs";
function printJson(value) {
console.log(JSON.stringify(value, null, 2));
}
function usage() {
return `Usage:
node scripts/ios-acceptance.mjs --check
node scripts/ios-acceptance.mjs --dry-run [--provider browserstack|sauce|lambdatest]
node scripts/ios-acceptance.mjs --url <dashboardUrl> --out <screenshotPath> [--provider browserstack|sauce|lambdatest]
Options:
--check Probe credential availability only; no WebDriver session.
--url <url> Dashboard URL to open on real iOS Safari. Port 4040 is rejected.
--out <path> Screenshot PNG path for session mode.
--provider <name> Override provider auto-resolution.
--dry-run Resolve credentials and print the redacted capability plan; no network.
--help Show this help.
`;
}
function parseArgs(argv) {
const args = { check: false, dryRun: false, provider: null, url: null, out: null, help: false };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--check") {
args.check = true;
} else if (arg === "--dry-run") {
args.dryRun = true;
} else if (arg === "--help" || arg === "-h") {
args.help = true;
} else if (arg === "--provider") {
args.provider = argv[++index];
} else if (arg === "--url") {
args.url = argv[++index];
} else if (arg === "--out") {
args.out = argv[++index];
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return args;
}
function selectProvider(args, env) {
const availability = describeAvailability(env);
if (!args.provider) {
return { provider: availability.provider, availability };
}
const provider = normalizeProvider(args.provider);
if (!provider) {
throw new Error(`Unsupported provider: ${args.provider}`);
}
const creds = credentialsForProvider(provider, env);
return {
provider: creds ? provider : null,
requestedProvider: provider,
availability: {
...availability,
available: Boolean(creds),
provider: creds ? provider : null,
},
};
}
function printMissingCredentialHint(availability) {
console.error(`[ios:acceptance] Missing real-iOS credentials: ${availability.missing.join(", ") || "none"}`);
console.error(
"[ios:acceptance] If credentials are stored in Fusion, use fn_secret_get or enable env-exportable project secrets so BROWSERSTACK_*, SAUCE_*, or LT_* keys materialize into this worktree.",
);
console.error(
"[ios:acceptance] NO-OP sentinel for verification gates: NO-OP: real-iOS surface unavailable — credentials missing, cannot run acceptance gate",
);
}
function assertDashboardUrl(value) {
if (!value) {
throw new Error("Session mode requires --url <dashboardUrl>.");
}
const url = new URL(value);
if (url.port === "4040") {
throw new Error("Port 4040 is reserved for the production dashboard; serve acceptance builds on --port 0 or another free non-4040 port.");
}
return url.toString();
}
function basicAuthHeader(creds) {
return `Basic ${Buffer.from(`${creds.username}:${creds.accessKey}`).toString("base64")}`;
}
function webdriverEndpoint(authenticatedHubUrl, path) {
const url = new URL(authenticatedHubUrl);
url.username = "";
url.password = "";
const basePath = url.pathname.replace(/\/+$/, "");
const nextPath = path.replace(/^\/+/, "");
url.pathname = `${basePath}/${nextPath}`;
return url.toString();
}
async function webdriverFetch(authenticatedHubUrl, path, init = {}) {
const endpoint = webdriverEndpoint(authenticatedHubUrl, path);
const response = await fetch(endpoint, {
...init,
headers: {
"content-type": "application/json",
...init.headers,
},
});
const text = await response.text();
let body = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = { raw: text.slice(0, 500) };
}
}
if (!response.ok) {
const message = body?.value?.message ?? body?.message ?? response.statusText;
throw new Error(`WebDriver ${init.method ?? "GET"} ${path} failed (${response.status}): ${message}`);
}
return body;
}
function sessionIdFromCreateResponse(body) {
return body?.value?.sessionId ?? body?.sessionId ?? null;
}
async function runSession({ provider, dashboardUrl, screenshotPath, env }) {
const creds = credentialsForProvider(provider, env);
if (!creds) {
throw new Error(`Missing credentials for ${provider}`);
}
const capabilities = buildIosCapabilities(provider, { env });
const hubUrl = iosHubUrl(provider, creds, env);
const authHeader = basicAuthHeader(creds);
let sessionId = null;
try {
const createBody = await webdriverFetch(hubUrl, "/session", {
method: "POST",
headers: { authorization: authHeader },
body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }),
});
sessionId = sessionIdFromCreateResponse(createBody);
if (!sessionId) {
throw new Error("WebDriver session response did not include a sessionId.");
}
await webdriverFetch(hubUrl, `/session/${encodeURIComponent(sessionId)}/url`, {
method: "POST",
headers: { authorization: authHeader },
body: JSON.stringify({ url: dashboardUrl }),
});
const screenshotBody = await webdriverFetch(hubUrl, `/session/${encodeURIComponent(sessionId)}/screenshot`, {
method: "GET",
headers: { authorization: authHeader },
});
const screenshot = screenshotBody?.value;
if (typeof screenshot !== "string" || screenshot.length === 0) {
throw new Error("WebDriver screenshot response did not include base64 PNG data.");
}
const absoluteScreenshotPath = resolve(screenshotPath);
await mkdir(dirname(absoluteScreenshotPath), { recursive: true });
await writeFile(absoluteScreenshotPath, Buffer.from(screenshot, "base64"));
return {
provider,
device: capabilityDeviceName(provider, capabilities),
platformVersion: capabilityPlatformVersion(provider, capabilities),
sessionId,
screenshotPath: absoluteScreenshotPath,
};
} finally {
if (sessionId) {
try {
await webdriverFetch(hubUrl, `/session/${encodeURIComponent(sessionId)}`, {
method: "DELETE",
headers: { authorization: authHeader },
});
} catch (error) {
console.error(`[ios:acceptance] Failed to delete WebDriver session ${sessionId}: ${error.message}`);
}
}
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
return 0;
}
const { provider, requestedProvider, availability } = selectProvider(args, process.env);
if (args.check) {
const result = requestedProvider
? { ...availability, requestedProvider }
: availability;
printJson(result);
if (!result.available) {
printMissingCredentialHint(result);
return 1;
}
return 0;
}
if (!provider) {
printJson(requestedProvider ? { ...availability, requestedProvider } : availability);
printMissingCredentialHint(availability);
return 1;
}
const creds = credentialsForProvider(provider, process.env);
const hubUrl = iosHubUrl(provider, creds, process.env);
const plan = {
...publicCapabilityPlan(provider, { env: process.env }),
hubUrl: redactUrl(hubUrl),
};
if (args.dryRun) {
printJson({ dryRun: true, ...plan });
return 0;
}
const dashboardUrl = assertDashboardUrl(args.url);
if (!args.out) {
throw new Error("Session mode requires --out <screenshotPath>.");
}
console.error(`[ios:acceptance] Opening real iOS Safari via ${provider} at ${plan.hubUrl}`);
const result = await runSession({ provider, dashboardUrl, screenshotPath: args.out, env: process.env });
printJson(result);
return 0;
}
main()
.then((code) => {
process.exitCode = code;
})
.catch((error) => {
console.error(`[ios:acceptance] ${error.message}`);
process.exitCode = 1;
});