Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -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 `<root>/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=<hex> # only needed by the community admin (ban/unban) desktop tests

PRINT_TEST_LOGS='true'
PRINT_ONGOING_TEST_LOGS = 1
PRINT_FAILED_TEST_LOGS=1
Expand All @@ -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.
Expand Down
11 changes: 4 additions & 7 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 0 additions & 4 deletions run/test/specs/cross_platform/display_name_sync.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions run/test/specs/cross_platform/display_name_sync_ios.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
},
});
3 changes: 0 additions & 3 deletions run/test/specs/cross_platform/message_sync_friends.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions run/test/utils/binaries.ts
Original file line number Diff line number Diff line change
@@ -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`);
Expand Down Expand Up @@ -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<WorkersPlatform, string | undefined> = {
export const getWorkersCount = (platform: ClientPlatform | undefined) => {
const perPlatform: Record<ClientPlatform, string | undefined> = {
android: process.env.PLAYWRIGHT_WORKERS_COUNT_ANDROID,
ios: process.env.PLAYWRIGHT_WORKERS_COUNT_IOS,
desktop: process.env.PLAYWRIGHT_WORKERS_COUNT_DESKTOP,
Expand Down
27 changes: 20 additions & 7 deletions run/test/utils/capabilities_android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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];
Expand All @@ -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];
Expand Down
84 changes: 52 additions & 32 deletions run/test/utils/capabilities_ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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:
Expand All @@ -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;
Expand Down Expand Up @@ -168,17 +168,25 @@ function getAppEnvOverrides(): Record<string, string> {
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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -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<string, unknown>;

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<AppiumXCUITestCapabilities> | undefined;
function getCapabilities(): Array<AppiumXCUITestCapabilities> {
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<string, unknown>;

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>;
Expand All @@ -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;
Expand All @@ -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}.`
Expand Down Expand Up @@ -327,6 +346,7 @@ export function getIosCapabilities(
}

export function getCapabilitiesForWorker(workerId: number) {
const capabilities = getCapabilities();
const emulator = capabilities[workerId % capabilities.length];
return {
...sharediOSCapabilities,
Expand Down
Loading