diff --git a/.env.sample b/.env.sample index 1a69b8732..3f276aeae 100644 --- a/.env.sample +++ b/.env.sample @@ -1,9 +1,20 @@ # this is a sample .env file. Copy it to .env, and edit the .env file to match your needs. -ANDROID_APK=/home/yougotthis/Downloads/session-android-universal.apk -IOS_APP_PATH_PREFIX=/home/yougotthis/Downloads/Session.app -EMULATOR_FULL_PATH=/home/yougotthis/Android/Sdk/emulator/emulator -APPIUM_ADB_FULL_PATH=/home/yougotthis/Android/sdk/platform-tools/adb +ANDROID_APK=/path/to/Downloads/session-android-universal.apk +IOS_APP_PATH_PREFIX=/path/to/Downloads/Session.app +EMULATOR_FULL_PATH=/path/to/Android/Sdk/emulator/emulator +APPIUM_ADB_FULL_PATH=/path/to/Android/sdk/platform-tools/adb + +# --- Desktop (the `desktop` and `cross-platform` Playwright projects) --- +# Required for any test that opens a Desktop client. Points at a BUILT Session Desktop source +# checkout (the harness launches `/app/ts/mains/main_node.js` with Electron), not a packaged +# binary — so the checkout must have been compiled first. +SESSION_DESKTOP_ROOT=/path/to/session-desktop +# USE_XVFB=1 # Linux headless: adds --ozone-platform=x11 and blanks WAYLAND_DISPLAY +# LOG_NODE_CONSOLE=1 # pipe Electron main-process console output into the test log +# LOG_BROWSER_CONSOLE=1 # pipe Electron renderer console output into the test log +# SOGS_ADMIN_SEED= # only needed by the community admin (ban/unban) desktop tests + PRINT_TEST_LOGS='true' PRINT_ONGOING_TEST_LOGS = 1 PRINT_FAILED_TEST_LOGS=1 @@ -27,6 +38,14 @@ UPDATE_BASELINES='false' # true auto-saves missing baseline screenshots # DEVNET_HTTP_PORT=1300 # "Storage HTTPS" column — used by the app (devnetHttpPort) # DEVNET_OMQ_PORT=1305 # "Storage OMQ" column — used by the app (devnetOmqPort/QUIC) # +# Desktop's devnet switch. NETWORK_TARGET above is iOS-only and Android switches network by build +# variant, so Desktop needs its own signal: set this when SESSION_DESKTOP_ROOT is a devnet build. +# Leaving it unset means TESTNET, not mainnet — this harness always launches Desktop with a +# `test-integration-*` NODE_APP_INSTANCE, so the app's own seed-node list falls through to testnet. +# Setting this var is the only way to move Desktop off testnet, including to pin it to mainnet +# (e.g. https://seed2.getsession.org:4443). Cross-platform tests refuse to start if platforms disagree. +# LOCAL_DEVNET_SEED_URL=http://10.0.0.1:1280 +# # Optional local file server (speeds media tests). FILE_SERVER_PUBKEY is the server's X25519 # pubkey (libsession uses it directly as the x25519 encryption key — NOT the ed25519). Omit to use # the app's default file-server pubkey. diff --git a/playwright.config.ts b/playwright.config.ts index a94ec7ca8..72f94529a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,19 +3,16 @@ dotenv.config({ quiet: true }); import { defineConfig, ReporterDescription } from '@playwright/test'; +import type { ClientPlatform } from './run/types/target'; + import { allureResultsDir } from './run/constants/allure'; -import { - getRepeatEachCount, - getRetriesCount, - getWorkersCount, - type WorkersPlatform, -} from './run/test/utils/binaries'; +import { getRepeatEachCount, getRetriesCount, getWorkersCount } from './run/test/utils/binaries'; // A run always targets a single platform, but that platform is expressed differently depending // on the entrypoint: CI sets the PLATFORM env variable, while the local `test-*` scripts only // pass it through the `--grep`/`--project` CLI args. Resolve both so the right per-platform // worker count is picked. Returns undefined for the cross-platform project (no dedicated count). -function currentTestPlatform(): WorkersPlatform | undefined { +function currentTestPlatform(): ClientPlatform | undefined { const fromEnv = process.env.PLATFORM; if (fromEnv === 'android' || fromEnv === 'ios' || fromEnv === 'desktop') { return fromEnv; diff --git a/run/test/specs/cross_platform/display_name_sync.spec.ts b/run/test/specs/cross_platform/display_name_sync.spec.ts index 80f5cef0b..574b6452f 100644 --- a/run/test/specs/cross_platform/display_name_sync.spec.ts +++ b/run/test/specs/cross_platform/display_name_sync.spec.ts @@ -14,8 +14,6 @@ crossPlatformTest({ title: 'Display name change syncs (Android changes, Desktop sees)', risk: 'medium', setup: linkedDevices({ android: 1, desktop: 1 }), - allureSuites: { parent: 'User Actions', suite: 'Change Username' }, - allureDescription: 'Android changes its display name; a linked Desktop client reflects it.', testCb: async ({ accounts: { alice } }) => { await alice.android[0].changeDisplayName(NEW_NAME); await alice.desktop[0].assertDisplayName(NEW_NAME); @@ -26,8 +24,6 @@ crossPlatformTest({ title: 'Display name change syncs (Desktop changes, Android sees)', risk: 'medium', setup: linkedDevices({ android: 1, desktop: 1 }), - allureSuites: { parent: 'User Actions', suite: 'Change Username' }, - allureDescription: 'Desktop changes its display name; a linked Android client reflects it.', testCb: async ({ accounts: { alice } }) => { await alice.desktop[0].changeDisplayName(NEW_NAME); await alice.android[0].assertDisplayName(NEW_NAME); diff --git a/run/test/specs/cross_platform/display_name_sync_ios.spec.ts b/run/test/specs/cross_platform/display_name_sync_ios.spec.ts new file mode 100644 index 000000000..6aa9f5859 --- /dev/null +++ b/run/test/specs/cross_platform/display_name_sync_ios.spec.ts @@ -0,0 +1,32 @@ +import { crossPlatformTest } from '../../utils/cross_platform'; +import { linkedDevices } from '../../utils/cross_platform_state_builder'; + +/** + * Cross-platform display-name sync (iOS + Desktop) — the iOS counterpart of + * `display_name_sync.spec.ts`, deliberately identical in shape so a failure here points at the + * platform rather than the scenario. An iOS simulator and a Desktop client are linked to one + * seeded account, one changes the display name, and the other must reflect it. Both clients must + * be on the SAME Session network (enforced by `assertConsistentNetworkTarget`). + */ + +const NEW_NAME = 'Alice in chains'; + +crossPlatformTest({ + title: 'Display name change syncs (iOS changes, Desktop sees)', + risk: 'medium', + setup: linkedDevices({ ios: 1, desktop: 1 }), + testCb: async ({ accounts: { alice } }) => { + await alice.ios[0].changeDisplayName(NEW_NAME); + await alice.desktop[0].assertDisplayName(NEW_NAME); + }, +}); + +crossPlatformTest({ + title: 'Display name change syncs (Desktop changes, iOS sees)', + risk: 'medium', + setup: linkedDevices({ ios: 1, desktop: 1 }), + testCb: async ({ accounts: { alice } }) => { + await alice.desktop[0].changeDisplayName(NEW_NAME); + await alice.ios[0].assertDisplayName(NEW_NAME); + }, +}); diff --git a/run/test/specs/cross_platform/message_sync_friends.spec.ts b/run/test/specs/cross_platform/message_sync_friends.spec.ts index 6788a76c4..0e4fd1baa 100644 --- a/run/test/specs/cross_platform/message_sync_friends.spec.ts +++ b/run/test/specs/cross_platform/message_sync_friends.spec.ts @@ -18,9 +18,6 @@ crossPlatformTest({ alice: { android: 1, desktop: 1 }, bob: { android: 1, desktop: 1 }, }), - allureSuites: { parent: 'Sending Messages', suite: 'Message types' }, - allureDescription: - 'Alice (android + desktop) and Bob (android + desktop) are friends; each sends a message and both messages appear on every device of both users.', testCb: async ({ accounts: { alice, bob } }) => { const aliceName = alice.account.userName; const bobName = bob.account.userName; diff --git a/run/test/utils/binaries.ts b/run/test/utils/binaries.ts index 74feba224..faee43be6 100644 --- a/run/test/utils/binaries.ts +++ b/run/test/utils/binaries.ts @@ -1,6 +1,8 @@ import { existsSync, lstatSync } from 'fs'; import { toNumber } from 'lodash'; +import type { ClientPlatform } from '../../types/target'; + function existsAndFileOrThrow(path: string, id: string) { if (!existsSync(path) || !lstatSync(path).isFile()) { throw new Error(`"${id}" does not exist at: ${path} or not a path`); @@ -48,12 +50,10 @@ export const getRepeatEachCount = () => { return isFinite(asNumber) ? asNumber : 0; }; -export type WorkersPlatform = 'android' | 'desktop' | 'ios'; - // Workers are configured per-platform so each platform can be tuned independently // (e.g. iOS is capped by the self-hosted runner, Android by the emulator count). -export const getWorkersCount = (platform: WorkersPlatform | undefined) => { - const perPlatform: Record = { +export const getWorkersCount = (platform: ClientPlatform | undefined) => { + const perPlatform: Record = { android: process.env.PLAYWRIGHT_WORKERS_COUNT_ANDROID, ios: process.env.PLAYWRIGHT_WORKERS_COUNT_IOS, desktop: process.env.PLAYWRIGHT_WORKERS_COUNT_DESKTOP, diff --git a/run/test/utils/capabilities_android.ts b/run/test/utils/capabilities_android.ts index 8fbd07688..ea6662bff 100644 --- a/run/test/utils/capabilities_android.ts +++ b/run/test/utils/capabilities_android.ts @@ -3,7 +3,6 @@ import dotenv from 'dotenv'; import { isString } from 'lodash'; import { getAndroidApk } from './binaries'; -import { CapabilitiesIndexType } from './capabilities_ios'; dotenv.config({ quiet: true }); // Access the environment variable @@ -43,11 +42,25 @@ function getAllCaps() { return emulatorCapabilities; } -export function getAndroidCapabilities( - capabilitiesIndex: CapabilitiesIndexType -): W3CUiautomator2DriverCaps { +/** Number of emulators this harness knows about (the Android capability pool size). */ +export const getAndroidPoolSize = () => emulatorCapabilities.length; + +/** + * Android counterpart of `capabilityIsValid`. Android used to be validated against the *iOS* pool + * length, which only ever worked because both pools happen to be 4 entries long. + * + * Returns a plain boolean over a plain `number` rather than narrowing to the iOS + * `CapabilitiesIndexType`: the bound checked here is `emulatorCapabilities.length`, so narrowing to + * an iOS-derived type asserted something this function never verified. The Android pool is a + * runtime list (`udids`), so the bound stays a runtime check — the two consumers below share it. + */ +export function androidCapabilityIsValid(capabilitiesIndex: number): boolean { + return capabilitiesIndex >= 0 && capabilitiesIndex < emulatorCapabilities.length; +} + +export function getAndroidCapabilities(capabilitiesIndex: number): W3CUiautomator2DriverCaps { const allCaps = getAllCaps(); - if (capabilitiesIndex >= allCaps.length) { + if (!androidCapabilityIsValid(capabilitiesIndex)) { throw new Error(`Asked invalid android capability index: ${capabilitiesIndex}`); } const cap = allCaps[capabilitiesIndex]; @@ -56,10 +69,10 @@ export function getAndroidCapabilities( alwaysMatch: { ...cap }, } as W3CUiautomator2DriverCaps; } -export function getAndroidUdid(udidIndex: CapabilitiesIndexType): string { +export function getAndroidUdid(udidIndex: number): string { const allCaps = getAllCaps(); - if (udidIndex >= allCaps.length) { + if (!androidCapabilityIsValid(udidIndex)) { throw new Error(`Asked invalid android udid index: ${udidIndex}`); } const cap = allCaps[udidIndex]; diff --git a/run/test/utils/capabilities_ios.ts b/run/test/utils/capabilities_ios.ts index c92db7d57..673d3187c 100644 --- a/run/test/utils/capabilities_ios.ts +++ b/run/test/utils/capabilities_ios.ts @@ -4,6 +4,8 @@ import { W3CXCUITestDriverCaps } from 'appium-xcuitest-driver/build/lib/driver'; import dotenv from 'dotenv'; import { existsSync } from 'fs'; +import type { ServiceNetwork } from '../../types/target'; + import { WDA_DERIVED_DATA_PATH, WDA_PREBUILT_APP_PATH } from '../../../scripts/build_wda'; import { resolveRunSimulators, type Simulator } from '../../../scripts/ios_shared'; import { IntRange } from '../../types/RangeType'; @@ -30,15 +32,13 @@ export const IOS_PRO_CONTEXT: IOSTestContext = { sessionProEnabled: 'true' }; // The devnet the *app* connects to (below) MUST be the same one the *seeder* points at // (see getNetworkTarget in devnet.ts, which uses getIosDevnetSeedUrl()). -export type IosServiceNetwork = 'devnet' | 'mainnet' | 'testnet'; - // Accepted `--network` values (forwarded to the child as NETWORK_TARGET). Kept in sync with -// `IosServiceNetwork` via `satisfies` so this list can't drift from what capabilities_ios accepts. +// `ServiceNetwork` via `satisfies` so this list can't drift from what capabilities_ios accepts. export const ALLOWED_IOS_NETWORKS = [ 'mainnet', 'testnet', 'devnet', -] as const satisfies readonly IosServiceNetwork[]; +] as const satisfies readonly ServiceNetwork[]; // The devnet seed node is a single node that plays two roles with THREE different ports, because // the app and the qa-seeder talk to different services on it: @@ -55,7 +55,7 @@ export type IosDevnetConfig = { omqPort: string; }; -export function getIosServiceNetwork(): IosServiceNetwork { +export function getIosServiceNetwork(): ServiceNetwork { const raw = (process.env.NETWORK_TARGET ?? 'mainnet').trim().toLowerCase(); if (raw === 'mainnet' || raw === 'testnet' || raw === 'devnet') { return raw; @@ -168,17 +168,25 @@ function getAppEnvOverrides(): Record { return appEnvOverridesCache; } -const iosPathPrefix = process.env.IOS_APP_PATH_PREFIX; - export const iOSBundleId = 'com.loki-project.loki-messenger'; -if (!iosPathPrefix) { - throw new Error('IOS_APP_PATH_PREFIX environment variable is not set'); +// Resolved lazily (NOT at module load) for the same reason as `getAppEnvOverrides` above: this module +// is imported on Android- and Desktop-only runs too — `cross_platform_state` pulls in IOS_PRO_CONTEXT +// — and throwing at import time would make those runs impossible without a full iOS setup. Every throw +// below now fires only when an iOS capability is actually built. +let iosAppFullPathCache: string | undefined; +function getIosAppFullPath(): string { + if (iosAppFullPathCache === undefined) { + const iosPathPrefix = process.env.IOS_APP_PATH_PREFIX; + if (!iosPathPrefix) { + throw new Error('IOS_APP_PATH_PREFIX environment variable is not set'); + } + iosAppFullPathCache = iosPathPrefix; + console.log(`iOS app full path: ${iosAppFullPathCache}`); + } + return iosAppFullPathCache; } -const iosAppFullPath = `${iosPathPrefix}`; -console.log(`iOS app full path: ${iosAppFullPath}`); - // Reuse a prebuilt WebDriverAgent runner instead of building/launching WDA via `xcodebuild` on // every session. Freshly-created simulators have no WDA installed, so without this the driver // rebuilds+launches WDA per session — the slowest, flakiest part of startup on a cold clone (it @@ -207,7 +215,6 @@ if (Object.keys(wdaCapabilities).length === 0) { const sharediOSCapabilities: AppiumXCUITestCapabilities = { ...wdaCapabilities, - 'appium:app': iosAppFullPath, 'appium:platformName': 'iOS', 'appium:platformVersion': '26.2', 'appium:deviceName': 'iPhone 17', @@ -236,8 +243,6 @@ const sharediOSCapabilities: AppiumXCUITestCapabilities = { // scripts/ios_shared so global-setup can use it without importing this iOS-only module. export type { Simulator }; -const simulators = resolveRunSimulators(); - // Ports where global-setup started a long-lived WebDriverAgent (local runs only). Devices covered // here attach to that WDA via `webDriverAgentUrl`, which reduces the driver's WDA launch to a single // `/status` call — it skips both the per-session install AND the cross-device lock that otherwise @@ -250,29 +255,42 @@ const wdaReusePorts = new Set( .filter(port => Number.isFinite(port)) ); -const capabilities = simulators.map(sim => { - const base = { - ...sharediOSCapabilities, - 'appium:udid': sim.udid, - 'appium:wdaLocalPort': sim.wdaPort, - } as Record; - - if (wdaReusePorts.has(sim.wdaPort)) { - // `usePreinstalledWDA` must go: the driver still runs its install step when that cap is set, - // even though `webDriverAgentUrl` takes precedence for the launch itself. - delete base['appium:usePreinstalledWDA']; - delete base['appium:prebuiltWDAPath']; - base['appium:webDriverAgentUrl'] = `http://127.0.0.1:${sim.wdaPort}`; +// Lazily resolved and memoised, for the same reason as getIosAppFullPath: `resolveRunSimulators` +// throws when no simulator is configured, which must not happen merely because this module was +// imported by a Desktop- or Android-only run. +let capabilitiesCache: Array | undefined; +function getCapabilities(): Array { + if (capabilitiesCache !== undefined) { + return capabilitiesCache; } - return base as AppiumXCUITestCapabilities; -}); + capabilitiesCache = resolveRunSimulators().map(sim => { + const base = { + ...sharediOSCapabilities, + 'appium:app': getIosAppFullPath(), + 'appium:udid': sim.udid, + 'appium:wdaLocalPort': sim.wdaPort, + } as Record; + + if (wdaReusePorts.has(sim.wdaPort)) { + // `usePreinstalledWDA` must go: the driver still runs its install step when that cap is set, + // even though `webDriverAgentUrl` takes precedence for the launch itself. + delete base['appium:usePreinstalledWDA']; + delete base['appium:prebuiltWDAPath']; + base['appium:webDriverAgentUrl'] = `http://127.0.0.1:${sim.wdaPort}`; + } + + return base as AppiumXCUITestCapabilities; + }); + + return capabilitiesCache; +} // Use a constant max that matches the envVars array length for type safety const _MAX_CAPABILITIES_INDEX = 12 as const; // For runtime validation, check against actual loaded simulators -export const getMaxCapabilitiesIndex = () => capabilities.length; +export const getMaxCapabilitiesIndex = () => getCapabilities().length; // Type is still based on the constant for compile-time safety export type CapabilitiesIndexType = IntRange<0, typeof _MAX_CAPABILITIES_INDEX>; @@ -281,7 +299,7 @@ export function capabilityIsValid( capabilitiesIndex: number ): capabilitiesIndex is CapabilitiesIndexType { // Runtime validation against actual loaded capabilities - if (capabilitiesIndex < 0 || capabilitiesIndex >= capabilities.length) { + if (capabilitiesIndex < 0 || capabilitiesIndex >= getCapabilities().length) { return false; } return true; @@ -291,6 +309,7 @@ export function getIosCapabilities( capabilitiesIndex: CapabilitiesIndexType, customCaps?: IOSTestContext ): W3CXCUITestDriverCaps { + const capabilities = getCapabilities(); if (capabilitiesIndex >= capabilities.length) { throw new Error( `Asked invalid ios cap index: ${capabilitiesIndex}. Number of iOS capabilities: ${capabilities.length}.` @@ -327,6 +346,7 @@ export function getIosCapabilities( } export function getCapabilitiesForWorker(workerId: number) { + const capabilities = getCapabilities(); const emulator = capabilities[workerId % capabilities.length]; return { ...sharediOSCapabilities, diff --git a/run/test/utils/cross_platform.ts b/run/test/utils/cross_platform.ts index 15c126768..8a092205a 100644 --- a/run/test/utils/cross_platform.ts +++ b/run/test/utils/cross_platform.ts @@ -2,7 +2,6 @@ import type { PrebuiltStateKey, StateGroup, UserNameType } from '@session-founda import { type Page, test, type TestInfo } from '@playwright/test'; -import type { AllureSuiteConfig } from '../../types/allure'; import type { DeviceWrapper } from '../../types/DeviceWrapper'; import type { IBaseDeviceWrapper } from '../../types/IBaseDeviceWrapper'; @@ -10,19 +9,17 @@ import { forceCloseAllWindows } from '../../desktop/closeWindows'; import { DesktopWrapper } from '../../desktop/DesktopWrapper'; import { resetTrackedElectronPids } from '../../desktop/open'; import { type TestRisk, type User } from '../../types/testing'; -import { openAppsWithStateCrossPlatform } from './cross_platform_state'; +import { openAppsWithStateCrossPlatform, type PerUserPlatforms } from './cross_platform_state'; import { focusConvoCrossPlatform } from './cross_platform_state_builder'; import { unregisterDevicesForTest } from './device_registry'; -import { getNetworkTarget } from './devnet'; import { captureLogsOnFailure, captureScreenshotsOnFailure } from './failure_artifacts'; import { closeApp } from './open_app'; -/** How many clients of each platform a single account should have. */ -export type CrossPlatformSetup = { - android?: number; - ios?: number; - desktop?: number; -}; +/** + * How many clients of each platform a single account should have — the spec-facing name for the + * opener's `PerUserPlatforms` (same shape; aliased rather than duplicated). + */ +export type CrossPlatformSetup = PerUserPlatforms; /** One account together with the clients (across platforms) linked to it. */ export type AccountClients = { @@ -88,8 +85,6 @@ type CrossPlatformTestArgs = { testCb: (clients: CrossPlatformClients, testInfo: TestInfo) => Promise; shouldSkip?: boolean; isPro?: boolean; - allureSuites?: AllureSuiteConfig; - allureDescription?: string; }; /** @@ -122,9 +117,6 @@ export function crossPlatformTest({ shouldSkip = false, isPro = false, }: CrossPlatformTestArgs) { - const totalAndroid = setup.accounts.reduce((sum, a) => sum + (a.platforms.android ?? 0), 0); - const totalIos = setup.accounts.reduce((sum, a) => sum + (a.platforms.ios ?? 0), 0); - const proTag = isPro ? ' @pro' : ''; const testName = `${title} @cross-platform @${risk ?? 'default'}-risk${proTag}`; @@ -137,21 +129,8 @@ export function crossPlatformTest({ // eslint-disable-next-line no-empty-pattern test(testName, async ({}, testInfo) => { - if (totalAndroid > 0) { - await getNetworkTarget('android'); - } - if (totalIos > 0) { - await getNetworkTarget('ios'); - } console.info(`\n\n==========> Running "${testName}"\n\n`); - // Note: no allure test suite as an allure suite is per platforms - // await setupAllureTestInfo({ - // suites: allureSuites, - // description: allureDescription, - // platform: totalAndroid > 0 ? 'android' : 'ios', - // }); - // Enable Session Pro (dev backend) before launching desktop windows. if (isPro) { process.env.SESSION_PRO = '1'; diff --git a/run/test/utils/cross_platform_state.ts b/run/test/utils/cross_platform_state.ts index 09716358c..da786f8d1 100644 --- a/run/test/utils/cross_platform_state.ts +++ b/run/test/utils/cross_platform_state.ts @@ -10,15 +10,22 @@ import { import type { DeviceWrapper } from '../../types/DeviceWrapper'; import type { IBaseDeviceWrapper } from '../../types/IBaseDeviceWrapper'; +import type { ClientPlatform } from '../../types/target'; import type { User } from '../../types/testing'; +import { forceCloseAllWindows } from '../../desktop/closeWindows'; import { DesktopWrapper } from '../../desktop/DesktopWrapper'; import { openApps, waitFirstWindow } from '../../desktop/open'; +import { getDevicesPerTestCount } from './binaries'; +import { getAndroidPoolSize } from './capabilities_android'; import { IOS_PRO_CONTEXT } from './capabilities_ios'; -import { getNetworkTarget } from './devnet'; -import { openAppMultipleDevices } from './open_app'; +import { assertConsistentNetworkTarget } from './devnet'; +import { closeApp, openAppMultipleDevices } from './open_app'; -/** How many clients of each platform a single account should have. */ +/** + * How many clients of each platform a single account should have. Defined here (the leaf) and + * re-exported by `cross_platform.ts` as `CrossPlatformSetup`, which is the name specs use. + */ export type PerUserPlatforms = { android?: number; ios?: number; @@ -35,6 +42,56 @@ export type UserClients = { all: IBaseDeviceWrapper[]; }; +/** + * Fail before the (slow) seeding step if this test asks for more mobile clients than the machine + * has. Without it the run pays for a full `buildStateForTest` and then dies one device at a time + * inside the opener, which is a much worse signal. + * + * Desktop is uncapped: each window gets its own `NODE_APP_INSTANCE`, so there is no pool. + */ +function assertPoolsCanFit(totalAndroid: number, totalIos: number): void { + const devicesPerWorker = getDevicesPerTestCount(); + if (totalIos > devicesPerWorker) { + throw new Error( + `This test needs ${totalIos} iOS simulator(s), but each worker is allocated only ` + + `${devicesPerWorker} (DEVICES_PER_TEST_COUNT=${devicesPerWorker}). Re-run with a larger ` + + `pool, e.g. \`pnpm test-ios-parallel --devices ${totalIos}\`.` + ); + } + const androidPoolSize = getAndroidPoolSize(); + if (totalAndroid > androidPoolSize) { + throw new Error( + `This test needs ${totalAndroid} Android emulator(s), but the harness only knows about ` + + `${androidPoolSize} (see the udid list in capabilities_android.ts).` + ); + } +} + +/** + * One opener rejected: close whatever its siblings managed to open, so a half-open test doesn't + * leave live Appium sessions (which hold their simulator/emulator) or Electron processes behind. + * Cleanup failures are logged, never thrown — the original opener error is the one worth surfacing. + */ +async function closePartiallyOpenedClients( + mobile: DeviceWrapper[], + desktopWindows: Page[] +): Promise { + if (mobile.length > 0) { + try { + await closeApp(...mobile); + } catch (e) { + console.error('Failed to close mobile sessions after a failed cross-platform open:', e); + } + } + // Called even with no page: Electron pids are tracked globally (the caller resets them before + // opening), so a window that launched but never yielded a page is only reachable this way. + try { + await forceCloseAllWindows(desktopWindows); + } catch (e) { + console.error('forceCloseAllWindows failed after a failed cross-platform open:', e); + } +} + function toUser(stateUser: StateUser): User { return { userName: stateUser.userName, @@ -81,10 +138,23 @@ export async function openAppsWithStateCrossPlatform const totalIos = perUser.reduce((sum, u) => sum + (u.ios ?? 0), 0); const totalDesktop = perUser.reduce((sum, u) => sum + (u.desktop ?? 0), 0); - // The seeder needs a network target; derive it from whichever mobile platform is present - // (getNetworkTarget caches into DETECTED_NETWORK_TARGET, so this is consistent per run). - const primaryPlatform = totalAndroid > 0 ? 'android' : 'ios'; - const net = await getNetworkTarget(primaryPlatform); + // Every platform in this test must be on the SAME Session network, or the seeder writes the + // account onto one network while a client polls another and the test hangs until it times out. + // Each platform resolves its network from a different source, so cross-check them all up front + // (before the slow seeding step) and use the agreed network for the seeder. + const present: ClientPlatform[] = []; + if (totalAndroid > 0) { + present.push('android'); + } + if (totalIos > 0) { + present.push('ios'); + } + if (totalDesktop > 0) { + present.push('desktop'); + } + assertPoolsCanFit(totalAndroid, totalIos); + + const net = await assertConsistentNetworkTarget(present); const prebuilt = await buildStateForTest(stateToBuildKey, groupName, net); const seedUsers = (prebuilt as { users: StateUser[] }).users; @@ -95,14 +165,62 @@ export async function openAppsWithStateCrossPlatform } // Open each platform once, then slice per user (preserves Appium capability-index order). - const androidPool = - totalAndroid > 0 ? await openAppMultipleDevices('android', totalAndroid, testInfo) : []; - const iosPool = + // The three platforms open CONCURRENTLY — a mixed test would otherwise pay the sum of three slow + // openers. Windows within the desktop group still open sequentially: `openApps` does that + // deliberately, because launching Electron windows in parallel triggers a sqlite error. + // + // `allSettled`, not `all`: on a rejection `all` returns immediately while its siblings keep + // opening, and this function then throws without ever handing the caller the clients that DID + // open — so those simulator/emulator sessions stay alive and pin their device for the next test. + // Collect every outcome, close what opened, then rethrow the original failure. + const [androidSettled, iosSettled, desktopSettled] = await Promise.allSettled([ + totalAndroid > 0 ? openAppMultipleDevices('android', totalAndroid, testInfo) : [], totalIos > 0 - ? await openAppMultipleDevices('ios', totalIos, testInfo, isPro ? IOS_PRO_CONTEXT : undefined) - : []; - const desktopApps = totalDesktop > 0 ? await openApps(totalDesktop) : []; - const desktopWindows = await Promise.all(desktopApps.map(app => waitFirstWindow(app))); + ? openAppMultipleDevices('ios', totalIos, testInfo, isPro ? IOS_PRO_CONTEXT : undefined) + : [], + totalDesktop > 0 + ? openApps(totalDesktop).then(apps => Promise.all(apps.map(app => waitFirstWindow(app)))) + : [], + ]); + const androidPool = androidSettled.status === 'fulfilled' ? androidSettled.value : []; + const iosPool = iosSettled.status === 'fulfilled' ? iosSettled.value : []; + const desktopWindows = desktopSettled.status === 'fulfilled' ? desktopSettled.value : []; + + // Report EVERY opener that failed, not just the first. The three run concurrently, so a bad + // simulator pool or a stale Desktop build routinely takes down more than one at a time — and + // `.find()` would silently drop all but android's reason, which is the case allSettled exists to + // handle well. Each reason is logged with its platform (that keeps its own stack intact), then a + // single failure is rethrown verbatim and multiple are aggregated. + const failures = ( + [ + ['android', androidSettled], + ['ios', iosSettled], + ['desktop', desktopSettled], + ] as const satisfies ReadonlyArray]> + ).filter( + (entry): entry is readonly [ClientPlatform, PromiseRejectedResult] => + entry[1].status === 'rejected' + ); + if (failures.length > 0) { + await closePartiallyOpenedClients([...androidPool, ...iosPool], desktopWindows); + failures.forEach(([platform, r]) => + console.error(`Cross-platform open failed for ${platform}:`, r.reason) + ); + if (failures.length === 1) { + throw failures[0][1].reason; + } + throw new AggregateError( + failures.map(([, r]) => r.reason as unknown), + `${failures.length} platform openers failed: ` + + failures + .map( + ([platform, r]) => + `${platform}: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}` + ) + .join(' | ') + ); + } + const desktopPool = desktopWindows.map(page => new DesktopWrapper(page)); let ai = 0; diff --git a/run/test/utils/devnet.ts b/run/test/utils/devnet.ts index 7a4110c20..295d069bf 100644 --- a/run/test/utils/devnet.ts +++ b/run/test/utils/devnet.ts @@ -1,5 +1,6 @@ import { buildStateForTest } from '@session-foundation/qa-seeder'; +import type { ClientPlatform, ServiceNetwork } from '../../types/target'; import type { SupportedPlatformsType } from './open_app'; import { DEVNET_URL } from '../../constants'; @@ -11,35 +12,109 @@ import { getIosDevnetSeedUrl, getIosServiceNetwork } from './capabilities_ios'; // NOTE this currently only applies to Android as iOS doesn't supply AQA builds yet type NetworkType = Parameters[2]; -// Using native fetch to check devnet accessibility +/** + * Number of active service nodes in an oxend `get_n_service_nodes` reply, or `undefined` if the + * payload isn't one. Narrows off `unknown` so a wrong service answering the port can't be mistaken + * for a seed node. + */ +function activeSnodeCount(payload: unknown): number | undefined { + if (typeof payload !== 'object' || payload === null) { + return undefined; + } + const { result } = payload as { result?: unknown }; + if (typeof result !== 'object' || result === null) { + return undefined; + } + const { service_node_states: states } = result as { service_node_states?: unknown }; + return Array.isArray(states) ? states.length : undefined; +} + +/** + * Minimum number of active service nodes the probe accepts as "the registry is populated" — a local + * devnet brings up ~12. + */ +const SEED_PROBE_MIN_NODES = 5; + +/** + * How many the probe asks for. Capped because an unlimited `get_n_service_nodes` returns every node + * on the network (>1000 states on mainnet), and kept strictly ABOVE the minimum: `limit` truncates + * the reply, so with `limit === SEED_PROBE_MIN_NODES` the count check could only ever pass at + * exactly the cap, and raising the minimum alone would make it unsatisfiable. + */ +const SEED_PROBE_LIMIT = SEED_PROBE_MIN_NODES * 2; + +/** + * A seed node is only usable if its oxend RPC answers the request its consumers actually make: + * `get_n_service_nodes` on `/json_rpc`, the same call Desktop's `getSnodesFromSeedUrl` and the + * qa-seeder (`@session-foundation/network-requests`) send, returning at least + * `SEED_PROBE_MIN_NODES` active nodes. + * `active_only` / `limit` / `fields` are exactly the params those consumers use — the qa-seeder's + * `GetSnodesFromSeed` sends all three (with `limit: 20`), while Desktop omits `limit` on purpose + * because it prunes cached swarms against the reply, which a liveness probe doesn't do. + * + * A bare GET liveness probe is not enough: any listener on the port passes it — a 404, an unrelated + * service, or an oxend that has come up with zero registered nodes (routine on a freshly started + * Sesh-Net-Docker stack). All three then hang the test to the timeout with no clue why. + */ async function isDevnetReachable(url: string = DEVNET_URL): Promise { const isCI = process.env.CI === '1'; const maxAttempts = isCI ? 3 : 1; const timeout = isCI ? 10_000 : 2_000; + const endpoint = `${url.replace(/\/$/, '')}/json_rpc`; + const body = JSON.stringify({ + jsonrpc: '2.0', + id: '0', + method: 'get_n_service_nodes', + params: { + active_only: true, + limit: SEED_PROBE_LIMIT, + fields: { public_ip: true, storage_port: true }, + }, + }); + // Check if devnet is available for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); try { if (maxAttempts > 1) { console.log(`Checking devnet accessibility (attempt ${attempt}/${maxAttempts})...`); } - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } - const response = await fetch(url, { signal: controller.signal }); - clearTimeout(timeoutId); + const count = activeSnodeCount((await response.json()) as unknown); + if (count === undefined) { + throw new Error('not an oxend RPC endpoint (no result.service_node_states in the reply)'); + } + if (count < SEED_PROBE_MIN_NODES) { + throw new Error( + `oxend RPC answered but only ${count} active service node(s) are registered ` + + `(need at least ${SEED_PROBE_MIN_NODES})` + ); + } - console.log(`Devnet ${url} is accessible (HTTP ${response.status})`); + console.log(`Devnet ${url} is usable (at least ${count} active service nodes)`); return true; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error'; if (attempt === maxAttempts) { - console.log(`Internal devnet is not accessible: ${errorMsg}`); + console.log(`Devnet ${url} is not usable: ${errorMsg}`); } else { console.log(`Attempt ${attempt} failed: ${errorMsg}, retrying...`); await sleepFor(attempt * 1000); } + } finally { + clearTimeout(timeoutId); } } return false; @@ -68,8 +143,9 @@ export async function getNetworkTarget(platform: SupportedPlatformsType): Promis const canAccessDevnet = await isDevnetReachable(seedUrl); if (!canAccessDevnet) { throw new Error( - `NETWORK_TARGET=devnet, but the devnet seed node at ${seedUrl} is not reachable. ` + - `Ensure the devnet is running/reachable, or set NETWORK_TARGET=mainnet.` + `NETWORK_TARGET=devnet, but the devnet seed node at ${seedUrl} is not usable ` + + `(reason logged above). Ensure the devnet is running and has registered service ` + + `nodes, or set NETWORK_TARGET=mainnet.` ); } process.env.DETECTED_NETWORK_TARGET = seedUrl; @@ -122,3 +198,208 @@ export function getAppDisplayName(): AppName { const apkPath = getAndroidApk(); return isAutomaticQABuildAndroid(apkPath) ? 'Session AQA' : 'Session QA'; } + +// --- Cross-platform network consistency --- +// +// Every platform picks its network from a DIFFERENT source, and none of them know about each +// other: +// - iOS : NETWORK_TARGET, injected as app launch args (capabilities_ios.ts) +// - Android : the APK build variant (IS_AUTOMATIC_QA / an `automaticQa` filename) — NETWORK_TARGET +// is ignored entirely +// - Desktop : LOCAL_DEVNET_SEED_URL, which despite the name is a general seed-node override and +// DEFAULTS TO TESTNET here (not mainnet) — see resolveDesktopTarget below +// +// So the out-of-the-box combination (iOS mainnet + Desktop testnet) does NOT agree; Desktop has to +// be pinned with LOCAL_DEVNET_SEED_URL, or iOS moved with NETWORK_TARGET. +// +// If they disagree, the seeder writes the account onto one network while a client polls another, +// and the test simply hangs until the 480s timeout with no clue why. `getNetworkTarget` can't +// catch this: it memoises into DETECTED_NETWORK_TARGET and returns early, so calling it once per +// platform silently resolves only the first one. + +export type ResolvedNetworkTarget = { + platform: ClientPlatform; + /** Which network this platform is pointed at. Mismatches here are fatal. */ + networkClass: ServiceNetwork; + /** How this platform refers to that network — a seed URL for devnet, else the network name. */ + ref: NetworkType; + /** What to change to move this platform to another network. Used in the error message. */ + knob: string; +}; + +function resolveIosTarget(): ResolvedNetworkTarget { + const networkClass = getIosServiceNetwork(); + return { + platform: 'ios', + networkClass, + ref: networkClass === 'devnet' ? getIosDevnetSeedUrl() : networkClass, + knob: 'NETWORK_TARGET (plus the DEVNET_* vars for devnet)', + }; +} + +function resolveAndroidTarget(): ResolvedNetworkTarget { + const isAQA = isAutomaticQABuildAndroid(getAndroidApk()); + return { + platform: 'android', + networkClass: isAQA ? 'devnet' : 'mainnet', + ref: isAQA ? DEVNET_URL : 'mainnet', + knob: 'the ANDROID_APK build variant (IS_AUTOMATIC_QA=true or an `automaticQa` APK)', + }; +} + +/** + * Session Desktop's seed nodes, from `window.getSeedNodeList()` in `app/preload.js`. Used to work + * out which network a given LOCAL_DEVNET_SEED_URL actually points at — the var name says "devnet", + * but it is really a plain seed-node override and is routinely pointed at mainnet or testnet. + */ +const DESKTOP_MAINNET_SEED_HOSTS = [ + 'seed1.getsession.org', + 'seed2.getsession.org', + 'seed3.getsession.org', +]; +const DESKTOP_TESTNET_PORT = '38157'; + +function resolveDesktopTarget(): ResolvedNetworkTarget { + const seedUrl = (process.env.LOCAL_DEVNET_SEED_URL ?? '').trim(); + const knob = 'LOCAL_DEVNET_SEED_URL (and the SESSION_DESKTOP_ROOT build it matches)'; + + // NOT mainnet when unset. Desktop's `useTestNet` flag is `isTestNet() || isTestIntegration()`, + // and this harness always launches with NODE_APP_INSTANCE=`test-integration-...` (see + // MULTI_PREFIX in run/desktop/open.ts), so `isTestIntegration()` is always true and + // `getSeedNodeList()` falls through to the testnet seed. Setting LOCAL_DEVNET_SEED_URL is the + // only way to move Desktop off testnet — including to pin it to mainnet. + if (!seedUrl) { + return { + platform: 'desktop', + networkClass: 'testnet', + ref: 'testnet', + knob, + }; + } + const invalidSeedUrl = () => + new Error( + `LOCAL_DEVNET_SEED_URL must be an http(s) URL (got "${seedUrl}"). ` + + `Unset it to let Desktop use its default (testnet under this harness).` + ); + + // `getSeedNodeList()` returns [seedUrl] verbatim, so the URL alone decides the network. Parse + // rather than string-match on "http": a value like `http-devnet` passes a `startsWith` check and + // then dies with a bare `TypeError: Invalid URL`, and `httpx://host` would parse and be silently + // accepted as a devnet seed. + let parsed: URL; + try { + parsed = new URL(seedUrl); + } catch { + throw invalidSeedUrl(); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw invalidSeedUrl(); + } + if (DESKTOP_MAINNET_SEED_HOSTS.includes(parsed.hostname)) { + const networkClass: ServiceNetwork = + parsed.port === DESKTOP_TESTNET_PORT ? 'testnet' : 'mainnet'; + return { platform: 'desktop', networkClass, ref: networkClass, knob }; + } + return { platform: 'desktop', networkClass: 'devnet', ref: seedUrl as `http${string}`, knob }; +} + +/** + * Resolve the network each given platform is actually pointed at. Unlike `getNetworkTarget` this + * never consults or writes the DETECTED_NETWORK_TARGET cache, so every platform is really resolved. + * + * `present` is deduped first: a platform's network is a property of the environment, not of how + * many clients of it a test opens, so resolving it twice would only re-read the APK path, log + * again, and name the platform twice in the mismatch error. One entry out per DISTINCT platform in. + */ +export function resolveNetworkTargets(present: Array): ResolvedNetworkTarget[] { + const resolvers: Record ResolvedNetworkTarget> = { + ios: resolveIosTarget, + android: resolveAndroidTarget, + desktop: resolveDesktopTarget, + }; + + return [...new Set(present)].map(platform => resolvers[platform]()); +} + +/** + * Assert every platform taking part in a cross-platform test is on the SAME network, and return + * that network for the seeder. + * + * Mismatched network *classes* (mainnet vs devnet) are fatal — that combination can never work. + * Devnet URLs are only warned about: the platforms legitimately refer to the same devnet in + * different shapes (iOS builds `http://DEVNET_IP:DEVNET_RPC_PORT`, Android uses the DEVNET_URL + * constant, Desktop uses LOCAL_DEVNET_SEED_URL), so a textual difference is not proof of a problem. + */ +export async function assertConsistentNetworkTarget( + present: Array +): Promise { + if (present.length === 0) { + throw new Error('assertConsistentNetworkTarget: no platforms given'); + } + const resolved = resolveNetworkTargets(present); + const describe = (t: ResolvedNetworkTarget) => + ` - ${t.platform}: ${t.networkClass} (${t.ref})\n change with: ${t.knob}`; + + const classes = new Set(resolved.map(t => t.networkClass)); + if (classes.size > 1) { + throw new Error( + `Cross-platform network mismatch: the clients in this test are pointed at different ` + + `Session networks, so the account would never sync.\n` + + `${resolved.map(describe).join('\n')}\n` + + `Point every platform at the same network before re-running.` + ); + } + + const [networkClass] = [...classes]; + console.log(`Network target (cross-platform): ${networkClass}`); + resolved.forEach(t => console.log(` ${t.platform} -> ${t.ref}`)); + + if (networkClass === 'mainnet' || networkClass === 'testnet') { + process.env.DETECTED_NETWORK_TARGET = networkClass; + return networkClass; + } + + // Devnet: warn on differing references, then verify the seed node the seeder will use is up. + const refs = new Set(resolved.map(t => t.ref)); + if (refs.size > 1) { + console.warn( + `Warning: platforms reference the devnet by different URLs (${[...refs].join(', ')}). ` + + `This is expected when they address the same seed node on different ports, but if the ` + + `test hangs, check they really are the same devnet.` + ); + } + + // Any platform's devnet ref works as the seeder URL: all three are the seed node's oxend RPC + // endpoint (the `IP:RPC` / DEVNET_RPC_PORT port, 1280 on Sesh-Net-Docker) — Desktop passes it + // straight to `getSnodesFromSeedUrl`, the same get_n_service_nodes call the seeder makes. + // Android is preferred purely to preserve the pre-existing behaviour for android+desktop tests. + // + // A Record, not a list, so the compiler enforces coverage: a fourth ClientPlatform missing from + // an `Array` still typechecks and would leave a devnet run made up only of that + // platform picking no ref at all. Here it is a build error instead. + const seederPreference: Record = { android: 0, ios: 1, desktop: 2 }; + // `resolved` is non-empty: `present` was checked above and dedupe keeps at least one entry. + const seederRef = [...resolved].sort( + (a, b) => seederPreference[a.platform] - seederPreference[b.platform] + )[0].ref; + + // Every distinct ref has to be up, not just the seeder's: a client pointed at a dead seed node + // hangs the test to the timeout with no clue why, even when the seeder's own endpoint is fine. + // Sequential: `refs` holds a single entry in the common case, so there is nothing to overlap. + const unreachable: Array = []; + for (const ref of refs) { + if (!(await isDevnetReachable(ref))) { + unreachable.push(ref); + } + } + if (unreachable.length > 0) { + const plural = unreachable.length > 1; + throw new Error( + `The devnet seed node${plural ? 's' : ''} at ${unreachable.join(', ')} ` + + `${plural ? 'are' : 'is'} not usable (reason logged above), but every client in this ` + + `test is configured for devnet:\n${resolved.map(describe).join('\n')}` + ); + } + process.env.DETECTED_NETWORK_TARGET = seederRef; + return seederRef; +} diff --git a/run/test/utils/open_app.ts b/run/test/utils/open_app.ts index 5130b0482..d06538f64 100644 --- a/run/test/utils/open_app.ts +++ b/run/test/utils/open_app.ts @@ -15,10 +15,15 @@ import { AndroidDeviceWrapper } from '../../types/AndroidDeviceWrapper'; import { DeviceWrapper } from '../../types/DeviceWrapper'; import { IosDeviceWrapper } from '../../types/IosDeviceWrapper'; import { getAdbFullPath, getDevicesPerTestCount } from './binaries'; -import { androidAppPackage, getAndroidCapabilities, getAndroidUdid } from './capabilities_android'; +import { + androidAppPackage, + androidCapabilityIsValid, + getAndroidCapabilities, + getAndroidPoolSize, + getAndroidUdid, +} from './capabilities_android'; import { CapabilitiesIndexType, - capabilityIsValid, getIosCapabilities, iOSBundleId, IOSTestContext, @@ -199,14 +204,19 @@ const openAndroidApp = async ( ): Promise<{ device: DeviceWrapper; }> => { - const parallelIndex = process.env.TEST_PARALLEL_INDEX || '1'; + // Defaults to worker 0, matching openiOSApp. Playwright always sets TEST_PARALLEL_INDEX, so this + // only applies when the opener is driven outside the runner. + const parallelIndex = process.env.TEST_PARALLEL_INDEX || '0'; console.info('process.env.TEST_PARALLEL_INDEX:', process.env.TEST_PARALLEL_INDEX, parallelIndex); const parallelIndexNumber = parseInt(parallelIndex); const actualCapabilitiesIndex = capabilitiesIndex + getDevicesPerTestCount() * parallelIndexNumber; - if (!capabilityIsValid(actualCapabilitiesIndex)) { - throw new Error(`Invalid actual capability given: ${actualCapabilitiesIndex}`); + if (!androidCapabilityIsValid(actualCapabilitiesIndex)) { + throw new Error( + `Invalid actual capability given: ${actualCapabilitiesIndex}. Only ${getAndroidPoolSize()} ` + + `emulator(s) are known to the harness.` + ); } if (isNaN(actualCapabilitiesIndex)) { diff --git a/run/types/target.ts b/run/types/target.ts new file mode 100644 index 000000000..87356e4be --- /dev/null +++ b/run/types/target.ts @@ -0,0 +1,18 @@ +/** + * The two axes a test run targets. Both unions had drifted into per-module copies (one in + * `capabilities_ios.ts` and another in `devnet.ts`; one in `binaries.ts` and another in + * `devnet.ts`), so they are defined once here and imported everywhere else. + * + * Deliberately a dependency-free leaf module: it is imported by `playwright.config.ts`, + * `binaries.ts`, `capabilities_ios.ts` and `devnet.ts`, none of which should have to pull in a + * runtime module chain just to name a union. + */ + +/** A platform this suite can drive a client on. */ +export type ClientPlatform = 'android' | 'desktop' | 'ios'; + +/** + * A Session network a client (or the seeder) can be pointed at. Note each platform selects one + * differently — see `resolveNetworkTargets` in `run/test/utils/devnet.ts`. + */ +export type ServiceNetwork = 'devnet' | 'mainnet' | 'testnet'; diff --git a/scripts/run_ios_parallel.ts b/scripts/run_ios_parallel.ts index f4816495c..ee61efa27 100644 --- a/scripts/run_ios_parallel.ts +++ b/scripts/run_ios_parallel.ts @@ -1,6 +1,8 @@ import { spawn } from 'child_process'; import dotenv from 'dotenv'; +import type { ServiceNetwork } from '../run/types/target'; + import { PARALLEL_TIER_NAMES, PARALLEL_TIERS, @@ -9,11 +11,7 @@ import { passGrep, simulatorsRequired, } from '../run/constants/parallelism'; -import { - ALLOWED_IOS_NETWORKS, - type IosServiceNetwork, - type Simulator, -} from '../run/test/utils/capabilities_ios'; +import { ALLOWED_IOS_NETWORKS, type Simulator } from '../run/test/utils/capabilities_ios'; import { ensureWdaBuilt } from './build_wda'; import { createIOSSimulators, resolveDeviceConfig } from './create_ios_simulators'; import { deleteSimulators } from './ios_shared'; @@ -174,7 +172,7 @@ function validate(args: ParsedArgs): number { } // Validate --network before provisioning: an unknown value (e.g. a "devent" typo) would // otherwise create the whole simulator pool and spawn Playwright before failing downstream. - if (args.network && !ALLOWED_IOS_NETWORKS.includes(args.network as IosServiceNetwork)) { + if (args.network && !ALLOWED_IOS_NETWORKS.includes(args.network as ServiceNetwork)) { console.error(`Invalid --network "${args.network}". Use ${ALLOWED_IOS_NETWORKS.join(' | ')}.`); process.exit(1); }