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
This commit is contained in:
gsxdsm
2026-06-18 16:47:09 -07:00
parent 504305e1cb
commit deb1717812
7 changed files with 787 additions and 0 deletions

View File

@@ -66,6 +66,7 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow
| [Sandbox Backends](./sandbox.md) | Pluggable sandbox backends for executor command isolation (bubblewrap, spawn-based) |
| [Secrets](./secrets.md) | Encrypted secrets storage, per-secret access policies, scopes, and agent tool wiring |
| [Testing](./testing.md) | Full testing lanes, worker fanout guidance, test taxonomy, and file organization |
| [Real iOS Safari Acceptance Surface](./ios-acceptance.md) | Provisioning runbook and harness usage for terminal verification gates on physical or cloud real-iOS Safari |
| [Solutions Catalog](./solutions/) | Documented solutions to past problems (bugs, architecture patterns, best practices) organized by category |
| [Localization Contributing Guide](./i18n-contributing.md) | Conventions for contributing translations, locale file structure, and i18n tooling |
| [Mobile](../MOBILE.md) | Capacitor/PWA mobile development setup and workflow |

144
docs/ios-acceptance.md Normal file
View File

@@ -0,0 +1,144 @@
# Real iOS Safari acceptance surface
[← Docs index](./README.md)
<!--
FNXC:iOSAcceptance 2026-06-18-17:18:
Terminal wide-glyph fixes must prove behavior on real iOS Safari because desktop WebKit, Playwright, jsdom, and simulators repeatedly missed the ASCII cell-width defect. This runbook keeps credential provisioning separate from test execution while giving terminal gates a deterministic run-vs-NO-OP probe.
-->
## Purpose
The mobile xterm wide-glyph defect recurred across FN-6390 → FN-6424 → FN-6603 → FN-6638 → FN-6659. Each fix shipped without a real-iOS reproduction because the execution environment had no BrowserStack, Sauce Labs, LambdaTest, or physical-device surface. FN-6641 and FN-6662 therefore had to treat the real-device gate as unavailable instead of verified.
`scripts/ios-acceptance.mjs` is the reachable-surface plumbing for future terminal acceptance gates:
- `--check` reports whether real-iOS cloud credentials are present and exits `0` only when a provider is usable.
- `--dry-run` prints the redacted provider/capability plan without opening a network session.
- Session mode opens a real iOS Safari W3C WebDriver session, navigates to a served Fusion dashboard URL, captures a PNG screenshot, and always deletes the cloud session in `finally`.
The harness is intentionally dependency-light: it uses built-in `fetch` and does **not** install Selenium, WebdriverIO, Appium, or provider CLIs.
## Real-device options
### Option A — physical iPhone or iPad
Use a current iPhone or iPad running Safari with macOS Safari remote Web Inspector:
1. Serve the built dashboard on a reachable free port. Use `--port 0` or another free port; **never use port 4040**, which is reserved for the production dashboard.
2. Open the URL on the physical device.
3. In macOS Safari, enable Develop menu and choose Develop → device → page.
4. Capture screenshots and measure terminal cell widths through Web Inspector.
This path does not use `scripts/ios-acceptance.mjs` session mode, but the `--check` probe should still return non-zero unless cloud credentials are also present. A human verifier records the physical-device evidence in the task document.
### Option B — real-iOS cloud WebDriver
Supply exactly one complete credential pair for a supported provider. If multiple pairs are present, the harness chooses BrowserStack → Sauce Labs → LambdaTest.
| Provider | Credential keys | Default hub URL |
|---|---|---|
| BrowserStack | `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY` | `https://hub-cloud.browserstack.com/wd/hub` (`upstream-pending-verification`) |
| Sauce Labs | `SAUCE_USERNAME`, `SAUCE_ACCESS_KEY` | `https://ondemand.us-west-1.saucelabs.com/wd/hub` (`upstream-pending-verification`) |
| LambdaTest | `LT_USERNAME`, `LT_ACCESS_KEY` | `https://mobile-hub.lambdatest.com/wd/hub` (`upstream-pending-verification`) |
Hub base URLs are region-configurable:
- `BROWSERSTACK_HUB_URL`
- `SAUCE_HUB_URL`
- `LT_HUB_URL`
Device defaults are intentionally conservative and may be overridden without code changes:
- BrowserStack: `BROWSERSTACK_IOS_DEVICE`, `BROWSERSTACK_IOS_VERSION`
- Sauce Labs: `SAUCE_IOS_DEVICE`, `SAUCE_IOS_VERSION`
- LambdaTest: `LT_IOS_DEVICE`, `LT_IOS_VERSION`
The default capability target is real iOS Safari on `iPhone 15` / iOS `17`; provider-specific options set real-device flags (`realMobile`, `realDevice`, or `isRealMobile`). Do not replace this with Playwright, desktop WebKit, jsdom, or an iOS simulator for terminal acceptance.
## Storing credentials safely
Secret values must never be committed, logged, attached, or written into task documents.
Recommended Fusion setup:
1. Store each provider credential as a project secret with access policy appropriate for the operator (`auto` for unattended gates, `prompt` for manual approval, `deny` when not exportable).
2. Mark gate credentials `env_exportable=true` and set `env_export_key` to the exact env var name, for example `BROWSERSTACK_USERNAME`.
3. Enable project `secretsEnv.enabled=true` so task worktrees receive a gitignored `.env` file with the materialized keys.
4. Keep `secretsEnv.requireGitignored=true` so plaintext is never written to a tracked path.
If environment materialization is unavailable, an operator or agent can fall back to `fn_secret_get` for these exact keys (project scope first, then global) and export them only for the acceptance command. The harness prints key names and missing-key lists, never plaintext values.
## Harness usage
Probe availability for FN-6662-style gates:
```bash
pnpm ios:acceptance -- --check
# or
node scripts/ios-acceptance.mjs --check
```
- Exit `0`: at least one complete cloud credential pair is present; run the real-iOS gate.
- Non-zero: no cloud provider is complete. Record the missing keys and close the observational gate with:
```text
NO-OP: real-iOS surface unavailable — credentials missing, cannot run acceptance gate
```
Inspect a redacted plan without network access:
```bash
BROWSERSTACK_USERNAME=... BROWSERSTACK_ACCESS_KEY=... \
pnpm ios:acceptance -- --dry-run --provider browserstack
```
Run a real session and capture evidence:
```bash
# Serve the dashboard on a free, reachable, non-4040 port first.
DASHBOARD_URL="https://reachable.example.test" \
pnpm ios:acceptance -- --url "$DASHBOARD_URL" --out screenshots/ios-acceptance.png
```
The JSON result includes `provider`, `device`, `platformVersion`, `sessionId`, and `screenshotPath`. The screenshot is a PNG decoded from the WebDriver `/screenshot` response. Authenticated hub URLs and `Authorization` headers are never printed.
## Serving the dashboard for cloud access
Build and serve the dashboard from the verification worktree, then make it reachable to the selected real-iOS surface:
```bash
pnpm build
# Use the project serve/dev command appropriate for the gate and choose --port 0 or a known free non-4040 port.
```
For cloud devices, use the provider's documented tunnel, a public preview URL, or another approved remote-access path. The harness does not start tunnels or download provider binaries; it only talks to the hosted WebDriver hub over HTTPS.
## External Integration Evidence
This harness integrates hosted SaaS WebDriver hubs over W3C WebDriver using built-in `fetch`; no provider binary is downloaded or executed locally, so checksums are not applicable.
- **BrowserStack Automate / Live**
- Canonical upstream repo URL: https://github.com/browserstack/browserstack-local-nodejs
- Docs / homepage URL: https://www.browserstack.com/docs/automate (Live: https://www.browserstack.com/live)
- Release / download URL: https://github.com/browserstack/browserstack-local-nodejs/releases/latest — `upstream-pending-verification`
- WebDriver hub (default, env-overridable via `BROWSERSTACK_HUB_URL`): `https://hub-cloud.browserstack.com/wd/hub` — `upstream-pending-verification`
- Binary / CLI name: N/A for this harness (`fetch`-based W3C hub over HTTPS); reference client binary `browserstack-local`
- Credential keys: `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY`
- Checksum: N/A (hosted service, no downloadable artifact bundled)
- **Sauce Labs Real Device Cloud**
- Canonical upstream repo URL: https://github.com/saucelabs/saucectl
- Docs / homepage URL: https://docs.saucelabs.com (Real Device Cloud: https://saucelabs.com/platform/real-device-cloud)
- Release / download URL: https://github.com/saucelabs/saucectl/releases/latest — `upstream-pending-verification`
- WebDriver hub (default, env-overridable via `SAUCE_HUB_URL`): `https://ondemand.us-west-1.saucelabs.com/wd/hub` — `upstream-pending-verification`
- Binary / CLI name: N/A for this harness (`fetch`-based W3C hub over HTTPS); reference CLI `saucectl`
- Credential keys: `SAUCE_USERNAME`, `SAUCE_ACCESS_KEY`
- Checksum: N/A (hosted service, no downloadable artifact bundled)
- **LambdaTest Real Time / Real Device**
- Canonical upstream repo URL: https://github.com/LambdaTest/LT
- Docs / homepage URL: https://www.lambdatest.com/support/docs/ (Real Time: https://www.lambdatest.com/real-time-browser-testing)
- Release / download URL: https://github.com/LambdaTest/LT/releases/latest — `upstream-pending-verification`
- WebDriver hub (default, env-overridable via `LT_HUB_URL`): `https://mobile-hub.lambdatest.com/wd/hub` — `upstream-pending-verification`
- Binary / CLI name: N/A for this harness (`fetch`-based W3C hub over HTTPS); reference tunnel binary `LT`
- Credential keys: `LT_USERNAME`, `LT_ACCESS_KEY`
- Checksum: N/A (hosted service, no downloadable artifact bundled)

View File

@@ -40,6 +40,9 @@ pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build (N
`pnpm test:full` runs each package's default test script with capped worker fanout (`FUSION_TEST_TOTAL_WORKERS=4 FUSION_TEST_CONCURRENCY=2 pnpm -r --workspace-concurrency=2 test`). Do not casually raise worker counts; dashboard/jsdom and integration-heavy packages destabilize when oversubscribed. Use `VITEST_MAX_WORKERS=<n>` only for targeted package-level investigation.
<!-- FNXC:iOSAcceptance 2026-06-18-17:25: Terminal acceptance gates that depend on real mobile Safari must use the credential-driven real-iOS surface runbook instead of treating desktop WebKit or jsdom as evidence. -->
Terminal acceptance tasks that require real mobile Safari should use [`docs/ios-acceptance.md`](./ios-acceptance.md) for the `--check` run-vs-NO-OP probe, credential wiring, and physical/cloud real-iOS evidence workflow.
Agents running verification through `fn_run_verification` are bounded by default: project `verificationCommandTimeoutMs` when set, otherwise 300s for package scope and 900s for workspace scope, with an 1800s hard cap. Marathon invocations such as root `pnpm test`, `pnpm test:full`, `pnpm verify:workspace`, whole-package tests without file filters, and shell repeat loops are soft-capped unless the agent explicitly passes `allowFullSuite: true`; the escape hatch still emits progress heartbeats and respects the hard cap. Prefer targeted commands such as `pnpm --filter @fusion/<pkg> exec vitest run src/path/to/test.ts --silent=passed-only --reporter=dot` before opting into a full run.
## Fresh-worktree dist bootstrap

View File

@@ -67,6 +67,7 @@
"mobile:dev:android": "pnpm --filter @fusion/mobile dev:android",
"mobile:sync": "pnpm --filter @fusion/mobile cap sync",
"mobile:run:android": "bash scripts/mobile-run-android.sh",
"ios:acceptance": "node scripts/ios-acceptance.mjs",
"build:desktop": "pnpm --filter @fusion/desktop build",
"dist:desktop:win": "pnpm --filter @fusion/desktop build && pnpm --filter @fusion/desktop dist:win"
},

View File

@@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildIosCapabilities,
describeAvailability,
iosHubUrl,
redactUrl,
resolveIosProvider,
} from "../lib/ios-acceptance.mjs";
const allCredentials = {
BROWSERSTACK_USERNAME: "browserstack-user",
BROWSERSTACK_ACCESS_KEY: "browserstack-key",
SAUCE_USERNAME: "sauce-user",
SAUCE_ACCESS_KEY: "sauce-key",
LT_USERNAME: "lt-user",
LT_ACCESS_KEY: "lt-key",
};
test("resolveIosProvider follows BrowserStack, Sauce, LambdaTest precedence", () => {
assert.equal(resolveIosProvider(allCredentials), "browserstack");
assert.equal(
resolveIosProvider({
SAUCE_USERNAME: "sauce-user",
SAUCE_ACCESS_KEY: "sauce-key",
}),
"sauce",
);
assert.equal(
resolveIosProvider({
LT_USERNAME: "lt-user",
LT_ACCESS_KEY: "lt-key",
}),
"lambdatest",
);
assert.equal(resolveIosProvider({}), null);
});
test("resolveIosProvider treats whitespace-only credential values as absent", () => {
assert.equal(
resolveIosProvider({
BROWSERSTACK_USERNAME: "browserstack-user",
BROWSERSTACK_ACCESS_KEY: " ",
SAUCE_USERNAME: "\t",
SAUCE_ACCESS_KEY: "sauce-key",
LT_USERNAME: "lt-user",
LT_ACCESS_KEY: "\n",
}),
null,
);
});
test("describeAvailability enumerates checked and missing keys without secret values", () => {
const availability = describeAvailability({});
assert.equal(availability.available, false);
assert.equal(availability.provider, null);
assert.deepEqual(availability.checkedKeys, [
"BROWSERSTACK_USERNAME",
"BROWSERSTACK_ACCESS_KEY",
"SAUCE_USERNAME",
"SAUCE_ACCESS_KEY",
"LT_USERNAME",
"LT_ACCESS_KEY",
]);
assert.deepEqual(availability.missing, availability.checkedKeys);
const withSecrets = describeAvailability(allCredentials);
const serialized = JSON.stringify(withSecrets);
for (const value of Object.values(allCredentials)) {
assert.equal(serialized.includes(value), false, `availability leaked credential value ${value}`);
}
});
test("buildIosCapabilities creates real iOS Safari capabilities for each provider", () => {
const browserstack = buildIosCapabilities("browserstack", {
deviceName: "iPhone 14",
platformVersion: "16",
});
assert.equal(browserstack.browserName, "safari");
assert.equal(browserstack.platformName, "iOS");
assert.equal(browserstack["bstack:options"].deviceName, "iPhone 14");
assert.equal(browserstack["bstack:options"].osVersion, "16");
assert.equal(browserstack["bstack:options"].realMobile, true);
const sauce = buildIosCapabilities("sauce", {
deviceName: "iPhone 15 Pro",
platformVersion: "17",
});
assert.equal(sauce.browserName, "safari");
assert.equal(sauce.platformName, "iOS");
assert.equal(sauce["appium:deviceName"], "iPhone 15 Pro");
assert.equal(sauce["appium:platformVersion"], "17");
assert.equal(sauce["sauce:options"].realDevice, true);
const lambdatest = buildIosCapabilities("lambdatest", {
deviceName: "iPhone 13",
platformVersion: "15",
});
assert.equal(lambdatest.browserName, "safari");
assert.equal(lambdatest.platformName, "iOS");
assert.equal(lambdatest["LT:Options"].deviceName, "iPhone 13");
assert.equal(lambdatest["LT:Options"].platformVersion, "15");
assert.equal(lambdatest["LT:Options"].isRealMobile, true);
});
test("iosHubUrl uses provider defaults, env overrides, and redacts embedded credentials", () => {
const browserstackUrl = iosHubUrl(
"browserstack",
{ username: "user@example.com", accessKey: "browserstack-secret" },
{},
);
assert.equal(browserstackUrl, "https://user%40example.com:browserstack-secret@hub-cloud.browserstack.com/wd/hub");
const sauceUrl = iosHubUrl("sauce", { username: "sauce-user", accessKey: "sauce-secret" }, {});
assert.equal(sauceUrl, "https://sauce-user:sauce-secret@ondemand.us-west-1.saucelabs.com/wd/hub");
const ltUrl = iosHubUrl("lambdatest", { username: "lt-user", accessKey: "lt-secret" }, {});
assert.equal(ltUrl, "https://lt-user:lt-secret@mobile-hub.lambdatest.com/wd/hub");
const overrideUrl = iosHubUrl(
"browserstack",
{ username: "override-user", accessKey: "override-secret" },
{ BROWSERSTACK_HUB_URL: "https://example.test/custom/wd/hub" },
);
assert.equal(overrideUrl, "https://override-user:override-secret@example.test/custom/wd/hub");
const redacted = redactUrl(overrideUrl);
assert.equal(redacted, "https://<redacted>:<redacted>@example.test/custom/wd/hub");
assert.equal(redacted.includes("override-user"), false);
assert.equal(redacted.includes("override-secret"), false);
});

264
scripts/ios-acceptance.mjs Executable file
View File

@@ -0,0 +1,264 @@
#!/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;
});

View File

@@ -0,0 +1,243 @@
/**
* 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;
}