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:
@@ -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
144
docs/ios-acceptance.md
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
131
scripts/__tests__/ios-acceptance.test.mjs
Normal file
131
scripts/__tests__/ios-acceptance.test.mjs
Normal 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
264
scripts/ios-acceptance.mjs
Executable 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;
|
||||
});
|
||||
243
scripts/lib/ios-acceptance.mjs
Normal file
243
scripts/lib/ios-acceptance.mjs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user