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
15 changes: 13 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,10 +273,21 @@ pnpm test-high-risk-ios # --grep '@ios @high-risk'

pnpm test-ios-parallel --tier standard # tiered, 6 sims — fastest full-suite local run
pnpm test-ios-parallel --list-tiers # tiers, their cost and their passes

pnpm test-android-parallel --tier full # tiered, 8 emulators (the pool must already be up)
pnpm test-android-parallel --list-tiers
```

`--tier` provisions throwaway simulators and runs one pass per device class; a `--grep` alongside it
narrows every pass rather than replacing the device-class filter. See **Parallelism** above.
`--tier` runs one pass per device class; a `--grep` alongside it narrows every pass rather than
replacing the device-class filter. See **Parallelism** above.

The two runners differ in one way that matters: the iOS one **provisions** throwaway simulators and
deletes them afterwards, while the Android one provisions nothing, because Appium will not boot an
emulator. Bring the pool up with `pnpm create-emulators <n>` first — the runner checks the udids it
needs are attached and refuses up front, since nothing else validates `workers × devices` against the
Android pool (`global-setup` does that arithmetic for iOS only). Its worker counts are the arithmetic
that fills the pool, **not measurements**: nothing above one worker has been timed on Android, and an
emulator is a QEMU VM costing 5-7 GB, so the iOS numbers do not transfer.

### How tags work

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"test-mobile": "npx playwright test --project mobile --grep ",
"test-ios": "_TESTING=1 npx playwright test --project mobile --grep '@ios'",
"test-ios-parallel": "npx ts-node scripts/run_ios_parallel.ts",
"test-android-parallel": "npx ts-node scripts/run_android_parallel.ts",
"test-android": "_TESTING=1 npx playwright test --project mobile --grep '@android'",
"test-high-risk-android": "_TESTING=1 npx playwright test --project mobile --grep '@android @high-risk'",
"test-high-risk-ios": "_TESTING=1 npx playwright test --project mobile --grep '@ios @high-risk'",
Expand Down
71 changes: 69 additions & 2 deletions run/constants/parallelism.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,79 @@ export const PARALLEL_TIERS = {
},
} as const satisfies Record<string, ParallelTier>;

/**
* Emulator-parallelism tiers, the Android counterpart of the table above.
*
* The mechanics are identical - `openAndroidApp` offsets each worker's pool by `TEST_PARALLEL_INDEX`
* exactly as `openiOSApp` does, and `DEVICES_PER_TEST_COUNT` is the same single global per
* invocation - so parallelism is expressed the same way, as one pass per device class.
*
* **The worker counts here are UNMEASURED.** They are the arithmetic that fills the pool
* (`devices * workers <= emulators`), not findings. The iOS numbers above do not transfer: a booted
* emulator is a QEMU virtual machine costing gigabytes of RAM, where a simulator is a process tree,
* so the two saturate a host for entirely different reasons. Treat a first run on any of these as a
* measurement, and lower the workers before concluding a flake is the app's fault.
*
* RAM is the binding constraint, and it is not subtle. Measured on the 64 GB Linux runner with four
* emulators up: 5.4-7.4 GB PSS each, 24 GB total, essentially all private, so there is no shared-page
* inflation to discount. Eight lands near 50 GB. Check `free -g` against the tier before running one.
*
* Unlike the iOS runner, nothing here provisions anything: Appium will not boot an emulator, so the
* pool has to be up before the run starts (`pnpm create-emulators <n>`).
*/
export const ANDROID_PARALLEL_TIERS = {
/** 4 emulators — the pool the suite shipped with. 3- and 4-device specs stay serial. */
conservative: {
summary: '4 emulators — safest; 3- and 4-device specs stay serial',
passes: [
{ devices: 1, workers: 4 },
{ devices: 2, workers: 2 },
{ devices: 3, workers: 1 },
{ devices: 4, workers: 1 },
],
},

/** 6 emulators — every pass but `@4-devices` gets at least two workers. ~37 GB of emulator RAM. */
standard: {
summary: '6 emulators — every pass but @4-devices parallelises',
passes: [
{ devices: 1, workers: 6 },
{ devices: 2, workers: 3 },
{ devices: 3, workers: 2 },
{ devices: 4, workers: 1 },
],
},

/**
* 8 emulators, the `MAX_EMULATORS` cap. The only tier in which `@4-devices` parallelises at all.
*
* Near 50 GB of emulator RAM before the tests do anything, so this wants a 64 GB host and nothing
* else running on it - on the Linux runner that means both GitHub Actions runner services stopped,
* not just one.
*/
full: {
summary: '8 emulators — fills the pool; needs a 64 GB host to itself',
passes: [
{ devices: 1, workers: 8 },
{ devices: 2, workers: 4 },
{ devices: 3, workers: 2 },
{ devices: 4, workers: 2 },
],
},
} as const satisfies Record<string, ParallelTier>;

export type AndroidParallelTierName = keyof typeof ANDROID_PARALLEL_TIERS;

export const ANDROID_PARALLEL_TIER_NAMES = Object.keys(
ANDROID_PARALLEL_TIERS
) as AndroidParallelTierName[];

export type ParallelTierName = keyof typeof PARALLEL_TIERS;

export const PARALLEL_TIER_NAMES = Object.keys(PARALLEL_TIERS) as ParallelTierName[];

/** Simulators a tier needs: the largest single pass, since passes run one after another. */
export function simulatorsRequired(tier: ParallelTier): number {
/** Devices a tier needs: the largest single pass, since passes run one after another. */
export function devicesRequired(tier: ParallelTier): number {
return Math.max(...tier.passes.map(p => p.devices * p.workers));
}

Expand Down
5 changes: 2 additions & 3 deletions run/desktop/DesktopWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1114,10 +1114,9 @@ export class DesktopWrapper implements IBaseDeviceWrapper {
/** Returns immediately if the loader never shows — see the helper for why that is a pass. */
public async waitForLoadingAnimationToFinish(
loader: DataTestId,
appearWithinMs?: number,
finishWithinMs?: number
options?: { appearWithinMs?: number; finishWithinMs?: number; windowMayClose?: boolean }
): Promise<void> {
await waitForLoadingAnimationToFinish(this.page, loader, appearWithinMs, finishWithinMs);
await waitForLoadingAnimationToFinish(this.page, loader, options);
}

public async clickOnTextMessage(
Expand Down
25 changes: 22 additions & 3 deletions run/desktop/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,12 +212,18 @@ export async function waitForMatchingPlaceholder(
* `network_page.spec.ts` grew its own `networkDataLoaded` rather than use this: a loader that never
* cleared used to hang until the test timed out with nothing to go on. Now it fails against this
* selector, which says what was still spinning.
*
* `windowMayClose` is for the work that restarts the app (clearing all data): the loader goes away
* with the window, which Playwright reports as a target-closed error rather than a hidden element.
*/
export async function waitForLoadingAnimationToFinish(
window: Page,
loader: DataTestId,
appearWithinMs = 2_000,
finishWithinMs = 60_000
{
appearWithinMs = 2_000,
finishWithinMs = 60_000,
windowMayClose = false,
}: { appearWithinMs?: number; finishWithinMs?: number; windowMayClose?: boolean } = {}
) {
const selector = buildSelectorEscapeText({ strategy: 'data-testid', selector: loader });

Expand All @@ -234,10 +240,23 @@ export async function waitForLoadingAnimationToFinish(
}

console.info(`${loader} was found, waiting for it to be gone`);
await window.waitForSelector(selector, { timeout: finishWithinMs, state: 'hidden' });
try {
await window.waitForSelector(selector, { timeout: finishWithinMs, state: 'hidden' });
} catch (e) {
if (!windowMayClose || !windowWentAway(window, e as Error)) {
throw e;
}
console.info(`${loader} went away with its window — the app restarted`);
return;
}
console.info('Loading animation has finished');
}

/** `isClosed()` does not always flip before the pending wait rejects, hence the message check too. */
function windowWentAway(window: Page, e: Error): boolean {
return window.isClosed() || /closed/i.test(e.message);
}

/**
* The absence counterpart to `waitForElement`. `hidden` covers never-attached, so it also suits an
* element that should never have rendered.
Expand Down
11 changes: 6 additions & 5 deletions run/test/specs/desktop/delete_account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,12 @@ sessionTestTwoWindows('Delete account from swarm', async ([windowA, windowB]) =>
// Confirm deletion by clicking Clear, twice
await windowA.clickOnMatchingText(tStripped('clear'));
await windowA.clickOnMatchingText(tStripped('clear'));
await windowA.waitForLoadingAnimationToFinish(Global.loadingSpinner.selector);
// await sleepFor(7500);
// Wait for window to close and reopen

// await windowA.close();
// The delete restarts the app, so the spinner is optional at both ends: on a fast network it
// can be gone before we look, and when we do catch it, it leaves with the window.
await windowA.waitForLoadingAnimationToFinish(Global.loadingSpinner.selector, {
appearWithinMs: 1_000,
windowMayClose: true,
});
restoringWindows = await openAppsAndWaitWindows(1); // not using sessionTest here as we need to close and reopen one of the window
const [restoringWindowPage] = restoringWindows;
const restoringWindow = new DesktopWrapper(restoringWindowPage, 'alice-restoring');
Expand Down
9 changes: 8 additions & 1 deletion run/test/utils/capabilities_android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { isString } from 'lodash';

import type { ProContext } from './pro_context';

import { BASE_PORT, MAX_EMULATORS } from '../../../scripts/android_config';
import { getAndroidApk } from './binaries';
import { buildAndroidLaunchExtras } from './devnet_android';
dotenv.config({ quiet: true });
Expand Down Expand Up @@ -49,7 +50,13 @@ const sharedCapabilities: W3CUiautomator2DriverCaps['alwaysMatch'] = {
'appium:enforceAppInstall': true,
};

const udids = ['emulator-5554', 'emulator-5556', 'emulator-5558', 'emulator-5560'];
// Derived rather than listed: the pool size and the port step already live in `android_config`, and
// spelling the udids out here meant growing the pool was a two-file change with nothing to catch a
// half-done one.
const udids = Array.from(
{ length: MAX_EMULATORS },
(_, i) => `emulator-${BASE_PORT + i * 2}` as const
);

const emulatorCapabilities: W3CUiautomator2DriverCaps['alwaysMatch'][] = udids.map(udid => ({
...sharedCapabilities,
Expand Down
4 changes: 2 additions & 2 deletions run/test/utils/copy_file_to_simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';

import { mediaFolder } from '../../constants/testfiles';
import { DeviceWrapper } from '../../types/DeviceWrapper';

const TARGET_GROUP_ID = 'group.com.apple.FileProvider.LocalStorage';
const MEDIA_ROOT = path.join('run', 'test', 'media');

/**
* Utility for copying a file from the local 'media' directory to the current iOS simulator's
Expand Down Expand Up @@ -44,7 +44,7 @@ function getSimulatorDownloadsPath(
* Copies a file from the 'media' directory to the simulator's "Downloads" folder on disk if not already present.
*/
export function copyFileToSimulator(device: DeviceWrapper, fileName: string): void {
const sourcePath = path.join(MEDIA_ROOT, fileName);
const sourcePath = path.join(mediaFolder, fileName);

const groupContainerPath = getFilesAppGroupContainerPath(device.udid);
const { downloadsPath, destinationPath } = getSimulatorDownloadsPath(
Expand Down
11 changes: 9 additions & 2 deletions scripts/android_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,15 @@ export const AVD_RAM_MB = 4192;
/** The suite's first udid; each subsequent emulator is +2 (adb's console/adb port pairing). */
export const BASE_PORT = 5554;

/** `capabilities_android.ts` declares exactly this many udids. */
export const MAX_EMULATORS = 4;
/**
* Size of the emulator pool, and the count `capabilities_android.ts` builds its udid list from.
*
* Bounded by host RAM, not by anything in the suite. Measured on the 64 GB Linux runner: four
* emulators sit at 5.4-7.4 GB PSS each (24 GB total, essentially all private), leaving eight at
* roughly 50 GB and ~11 GB of headroom. Raise this only after measuring on the host in question - an
* over-subscribed host fails with timeouts indistinguishable from product bugs.
*/
export const MAX_EMULATORS = 8;

/** The command-line tools bundle, used only when provisioning a CI machine from scratch. */
export const CMDLINE_TOOLS_ZIP = 'commandlinetools-linux-11076708_latest.zip';
Expand Down
Loading