diff --git a/docs/README.md b/docs/README.md index ac6b2bece0..c68dc6a74b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 | diff --git a/docs/ios-acceptance.md b/docs/ios-acceptance.md new file mode 100644 index 0000000000..9ae78c1a35 --- /dev/null +++ b/docs/ios-acceptance.md @@ -0,0 +1,144 @@ +# Real iOS Safari acceptance surface + +[← Docs index](./README.md) + + + +## 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) diff --git a/docs/testing.md b/docs/testing.md index aece16f658..ea3978cf84 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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=` only for targeted package-level investigation. + +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/ exec vitest run src/path/to/test.ts --silent=passed-only --reporter=dot` before opting into a full run. ## Fresh-worktree dist bootstrap diff --git a/package.json b/package.json index a6df96a0c0..933621814e 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/__tests__/ios-acceptance.test.mjs b/scripts/__tests__/ios-acceptance.test.mjs new file mode 100644 index 0000000000..d7c65ac841 --- /dev/null +++ b/scripts/__tests__/ios-acceptance.test.mjs @@ -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://:@example.test/custom/wd/hub"); + assert.equal(redacted.includes("override-user"), false); + assert.equal(redacted.includes("override-secret"), false); +}); diff --git a/scripts/ios-acceptance.mjs b/scripts/ios-acceptance.mjs new file mode 100755 index 0000000000..8ebb6e65dd --- /dev/null +++ b/scripts/ios-acceptance.mjs @@ -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 --out [--provider browserstack|sauce|lambdatest] + +Options: + --check Probe credential availability only; no WebDriver session. + --url Dashboard URL to open on real iOS Safari. Port 4040 is rejected. + --out Screenshot PNG path for session mode. + --provider 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 ."); + } + 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 ."); + } + + 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; + }); diff --git a/scripts/lib/ios-acceptance.mjs b/scripts/lib/ios-acceptance.mjs new file mode 100644 index 0000000000..d3717008c8 --- /dev/null +++ b/scripts/lib/ios-acceptance.mjs @@ -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) ? "" : 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}//:@${url.host}${url.pathname}${url.search}${url.hash}`; + } catch { + return String(value).replace(/\/\/([^:@/\s]+):([^@/\s]+)@/g, "//:@"); + } +} + +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; +}