From 9ab38d9c11560a1f0b537c95ac327b35ddd83e10 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Thu, 12 Feb 2026 13:20:32 +1100 Subject: [PATCH 01/38] feat: make user pro via dev backend --- english_wordlist.txt | 1626 ++++++++++++++++++++++++++++++++++++ package.json | 2 + pnpm-lock.yaml | 22 +- run/constants/index.ts | 2 + run/test/utils/mock_pro.ts | 394 +++++++++ 5 files changed, 2045 insertions(+), 1 deletion(-) create mode 100644 english_wordlist.txt create mode 100644 run/test/utils/mock_pro.ts diff --git a/english_wordlist.txt b/english_wordlist.txt new file mode 100644 index 0000000000..9b92e9344e --- /dev/null +++ b/english_wordlist.txt @@ -0,0 +1,1626 @@ +abbey +abducts +ability +ablaze +abnormal +abort +abrasive +absorb +abyss +academy +aces +aching +acidic +acoustic +acquire +across +actress +acumen +adapt +addicted +adept +adhesive +adjust +adopt +adrenalin +adult +adventure +aerial +afar +affair +afield +afloat +afoot +afraid +after +against +agenda +aggravate +agile +aglow +agnostic +agony +agreed +ahead +aided +ailments +aimless +airport +aisle +ajar +akin +alarms +album +alchemy +alerts +algebra +alkaline +alley +almost +aloof +alpine +already +also +altitude +alumni +always +amaze +ambush +amended +amidst +ammo +amnesty +among +amply +amused +anchor +android +anecdote +angled +ankle +annoyed +answers +antics +anvil +anxiety +anybody +apart +apex +aphid +aplomb +apology +apply +apricot +aptitude +aquarium +arbitrary +archer +ardent +arena +argue +arises +army +around +arrow +arsenic +artistic +ascend +ashtray +aside +asked +asleep +aspire +assorted +asylum +athlete +atlas +atom +atrium +attire +auburn +auctions +audio +august +aunt +austere +autumn +avatar +avidly +avoid +awakened +awesome +awful +awkward +awning +awoken +axes +axis +axle +aztec +azure +baby +bacon +badge +baffles +bagpipe +bailed +bakery +balding +bamboo +banjo +baptism +basin +batch +bawled +bays +because +beer +befit +begun +behind +being +below +bemused +benches +berries +bested +betting +bevel +beware +beyond +bias +bicycle +bids +bifocals +biggest +bikini +bimonthly +binocular +biology +biplane +birth +biscuit +bite +biweekly +blender +blip +bluntly +boat +bobsled +bodies +bogeys +boil +boldly +bomb +border +boss +both +bounced +bovine +bowling +boxes +boyfriend +broken +brunt +bubble +buckets +budget +buffet +bugs +building +bulb +bumper +bunch +business +butter +buying +buzzer +bygones +byline +bypass +cabin +cactus +cadets +cafe +cage +cajun +cake +calamity +camp +candy +casket +catch +cause +cavernous +cease +cedar +ceiling +cell +cement +cent +certain +chlorine +chrome +cider +cigar +cinema +circle +cistern +citadel +civilian +claim +click +clue +coal +cobra +cocoa +code +coexist +coffee +cogs +cohesive +coils +colony +comb +cool +copy +corrode +costume +cottage +cousin +cowl +criminal +cube +cucumber +cuddled +cuffs +cuisine +cunning +cupcake +custom +cycling +cylinder +cynical +dabbing +dads +daft +dagger +daily +damp +dangerous +dapper +darted +dash +dating +dauntless +dawn +daytime +dazed +debut +decay +dedicated +deepest +deftly +degrees +dehydrate +deity +dejected +delayed +demonstrate +dented +deodorant +depth +desk +devoid +dewdrop +dexterity +dialect +dice +diet +different +digit +dilute +dime +dinner +diode +diplomat +directed +distance +ditch +divers +dizzy +doctor +dodge +does +dogs +doing +dolphin +domestic +donuts +doorway +dormant +dosage +dotted +double +dove +down +dozen +dreams +drinks +drowning +drunk +drying +dual +dubbed +duckling +dude +duets +duke +dullness +dummy +dunes +duplex +duration +dusted +duties +dwarf +dwelt +dwindling +dying +dynamite +dyslexic +each +eagle +earth +easy +eating +eavesdrop +eccentric +echo +eclipse +economics +ecstatic +eden +edgy +edited +educated +eels +efficient +eggs +egotistic +eight +either +eject +elapse +elbow +eldest +eleven +elite +elope +else +eluded +emails +ember +emerge +emit +emotion +empty +emulate +energy +enforce +enhanced +enigma +enjoy +enlist +enmity +enough +enraged +ensign +entrance +envy +epoxy +equip +erase +erected +erosion +error +eskimos +espionage +essential +estate +etched +eternal +ethics +etiquette +evaluate +evenings +evicted +evolved +examine +excess +exhale +exit +exotic +exquisite +extra +exult +fabrics +factual +fading +fainted +faked +fall +family +fancy +farming +fatal +faulty +fawns +faxed +fazed +feast +february +federal +feel +feline +females +fences +ferry +festival +fetches +fever +fewest +fiat +fibula +fictional +fidget +fierce +fifteen +fight +films +firm +fishing +fitting +five +fixate +fizzle +fleet +flippant +flying +foamy +focus +foes +foggy +foiled +folding +fonts +foolish +fossil +fountain +fowls +foxes +foyer +framed +friendly +frown +fruit +frying +fudge +fuel +fugitive +fully +fuming +fungal +furnished +fuselage +future +fuzzy +gables +gadget +gags +gained +galaxy +gambit +gang +gasp +gather +gauze +gave +gawk +gaze +gearbox +gecko +geek +gels +gemstone +general +geometry +germs +gesture +getting +geyser +ghetto +ghost +giant +giddy +gifts +gigantic +gills +gimmick +ginger +girth +giving +glass +gleeful +glide +gnaw +gnome +goat +goblet +godfather +goes +goggles +going +goldfish +gone +goodbye +gopher +gorilla +gossip +gotten +gourmet +governing +gown +greater +grunt +guarded +guest +guide +gulp +gumball +guru +gusts +gutter +guys +gymnast +gypsy +gyrate +habitat +hacksaw +haggled +hairy +hamburger +happens +hashing +hatchet +haunted +having +hawk +haystack +hazard +hectare +hedgehog +heels +hefty +height +hemlock +hence +heron +hesitate +hexagon +hickory +hiding +highway +hijack +hiker +hills +himself +hinder +hippo +hire +history +hitched +hive +hoax +hobby +hockey +hoisting +hold +honked +hookup +hope +hornet +hospital +hotel +hounded +hover +howls +hubcaps +huddle +huge +hull +humid +hunter +hurried +husband +huts +hybrid +hydrogen +hyper +iceberg +icing +icon +identity +idiom +idled +idols +igloo +ignore +iguana +illness +imagine +imbalance +imitate +impel +inactive +inbound +incur +industrial +inexact +inflamed +ingested +initiate +injury +inkling +inline +inmate +innocent +inorganic +input +inquest +inroads +insult +intended +inundate +invoke +inwardly +ionic +irate +iris +irony +irritate +island +isolated +issued +italics +itches +items +itinerary +itself +ivory +jabbed +jackets +jaded +jagged +jailed +jamming +january +jargon +jaunt +javelin +jaws +jazz +jeans +jeers +jellyfish +jeopardy +jerseys +jester +jetting +jewels +jigsaw +jingle +jittery +jive +jobs +jockey +jogger +joining +joking +jolted +jostle +journal +joyous +jubilee +judge +juggled +juicy +jukebox +july +jump +junk +jury +justice +juvenile +kangaroo +karate +keep +kennel +kept +kernels +kettle +keyboard +kickoff +kidneys +king +kiosk +kisses +kitchens +kiwi +knapsack +knee +knife +knowledge +knuckle +koala +laboratory +ladder +lagoon +lair +lakes +lamb +language +laptop +large +last +later +launching +lava +lawsuit +layout +lazy +lectures +ledge +leech +left +legion +leisure +lemon +lending +leopard +lesson +lettuce +lexicon +liar +library +licks +lids +lied +lifestyle +light +likewise +lilac +limits +linen +lion +lipstick +liquid +listen +lively +loaded +lobster +locker +lodge +lofty +logic +loincloth +long +looking +lopped +lordship +losing +lottery +loudly +love +lower +loyal +lucky +luggage +lukewarm +lullaby +lumber +lunar +lurk +lush +luxury +lymph +lynx +lyrics +macro +madness +magically +mailed +major +makeup +malady +mammal +maps +masterful +match +maul +maverick +maximum +mayor +maze +meant +mechanic +medicate +meeting +megabyte +melting +memoir +menu +merger +mesh +metro +mews +mice +midst +mighty +mime +mirror +misery +mittens +mixture +moat +mobile +mocked +mohawk +moisture +molten +moment +money +moon +mops +morsel +mostly +motherly +mouth +movement +mowing +much +muddy +muffin +mugged +mullet +mumble +mundane +muppet +mural +musical +muzzle +myriad +mystery +myth +nabbing +nagged +nail +names +nanny +napkin +narrate +nasty +natural +nautical +navy +nearby +necklace +needed +negative +neither +neon +nephew +nerves +nestle +network +neutral +never +newt +nexus +nibs +niche +niece +nifty +nightly +nimbly +nineteen +nirvana +nitrogen +nobody +nocturnal +nodes +noises +nomad +noodles +northern +nostril +noted +nouns +novelty +nowhere +nozzle +nuance +nucleus +nudged +nugget +nuisance +null +number +nuns +nurse +nutshell +nylon +oaks +oars +oasis +oatmeal +obedient +object +obliged +obnoxious +observant +obtains +obvious +occur +ocean +october +odds +odometer +offend +often +oilfield +ointment +okay +older +olive +olympics +omega +omission +omnibus +onboard +oncoming +oneself +ongoing +onion +online +onslaught +onto +onward +oozed +opacity +opened +opposite +optical +opus +orange +orbit +orchid +orders +organs +origin +ornament +orphans +oscar +ostrich +otherwise +otter +ouch +ought +ounce +ourselves +oust +outbreak +oval +oven +owed +owls +owner +oxidant +oxygen +oyster +ozone +pact +paddles +pager +pairing +palace +pamphlet +pancakes +paper +paradise +pastry +patio +pause +pavements +pawnshop +payment +peaches +pebbles +peculiar +pedantic +peeled +pegs +pelican +pencil +people +pepper +perfect +pests +petals +phase +pheasants +phone +phrases +physics +piano +picked +pierce +pigment +piloted +pimple +pinched +pioneer +pipeline +pirate +pistons +pitched +pivot +pixels +pizza +playful +pledge +pliers +plotting +plus +plywood +poaching +pockets +podcast +poetry +point +poker +polar +ponies +pool +popular +portents +possible +potato +pouch +poverty +powder +pram +present +pride +problems +pruned +prying +psychic +public +puck +puddle +puffin +pulp +pumpkins +punch +puppy +purged +push +putty +puzzled +pylons +pyramid +python +queen +quick +quote +rabbits +racetrack +radar +rafts +rage +railway +raking +rally +ramped +randomly +rapid +rarest +rash +rated +ravine +rays +razor +react +rebel +recipe +reduce +reef +refer +regular +reheat +reinvest +rejoices +rekindle +relic +remedy +renting +reorder +repent +request +reruns +rest +return +reunion +revamp +rewind +rhino +rhythm +ribbon +richly +ridges +rift +rigid +rims +ringing +riots +ripped +rising +ritual +river +roared +robot +rockets +rodent +rogue +roles +romance +roomy +roped +roster +rotate +rounded +rover +rowboat +royal +ruby +rudely +ruffled +rugged +ruined +ruling +rumble +runway +rural +rustled +ruthless +sabotage +sack +sadness +safety +saga +sailor +sake +salads +sample +sanity +sapling +sarcasm +sash +satin +saucepan +saved +sawmill +saxophone +sayings +scamper +scenic +school +science +scoop +scrub +scuba +seasons +second +sedan +seeded +segments +seismic +selfish +semifinal +sensible +september +sequence +serving +session +setup +seventh +sewage +shackles +shelter +shipped +shocking +shrugged +shuffled +shyness +siblings +sickness +sidekick +sieve +sifting +sighting +silk +simplest +sincerely +sipped +siren +situated +sixteen +sizes +skater +skew +skirting +skulls +skydive +slackens +sleepless +slid +slower +slug +smash +smelting +smidgen +smog +smuggled +snake +sneeze +sniff +snout +snug +soapy +sober +soccer +soda +software +soggy +soil +solved +somewhere +sonic +soothe +soprano +sorry +southern +sovereign +sowed +soya +space +speedy +sphere +spiders +splendid +spout +sprig +spud +spying +square +stacking +stellar +stick +stockpile +strained +stunning +stylishly +subtly +succeed +suddenly +suede +suffice +sugar +suitcase +sulking +summon +sunken +superior +surfer +sushi +suture +swagger +swept +swiftly +sword +swung +syllabus +symptoms +syndrome +syringe +system +taboo +tacit +tadpoles +tagged +tail +taken +talent +tamper +tanks +tapestry +tarnished +tasked +tattoo +taunts +tavern +tawny +taxi +teardrop +technical +tedious +teeming +tell +template +tender +tepid +tequila +terminal +testing +tether +textbook +thaw +theatrics +thirsty +thorn +threaten +thumbs +thwart +ticket +tidy +tiers +tiger +tilt +timber +tinted +tipsy +tirade +tissue +titans +toaster +tobacco +today +toenail +toffee +together +toilet +token +tolerant +tomorrow +tonic +toolbox +topic +torch +tossed +total +touchy +towel +toxic +toyed +trash +trendy +tribal +trolling +truth +trying +tsunami +tubes +tucks +tudor +tuesday +tufts +tugs +tuition +tulips +tumbling +tunnel +turnip +tusks +tutor +tuxedo +twang +tweezers +twice +twofold +tycoon +typist +tyrant +ugly +ulcers +ultimate +umbrella +umpire +unafraid +unbending +uncle +under +uneven +unfit +ungainly +unhappy +union +unjustly +unknown +unlikely +unmask +unnoticed +unopened +unplugs +unquoted +unrest +unsafe +until +unusual +unveil +unwind +unzip +upbeat +upcoming +update +upgrade +uphill +upkeep +upload +upon +upper +upright +upstairs +uptight +upwards +urban +urchins +urgent +usage +useful +usher +using +usual +utensils +utility +utmost +utopia +uttered +vacation +vague +vain +value +vampire +vane +vapidly +vary +vastness +vats +vaults +vector +veered +vegan +vehicle +vein +velvet +venomous +verification +vessel +veteran +vexed +vials +vibrate +victim +video +viewpoint +vigilant +viking +village +vinegar +violin +vipers +virtual +visited +vitals +vivid +vixen +vocal +vogue +voice +volcano +vortex +voted +voucher +vowels +voyage +vulture +wade +waffle +wagtail +waist +waking +wallets +wanted +warped +washing +water +waveform +waxing +wayside +weavers +website +wedge +weekday +weird +welders +went +wept +were +western +wetsuit +whale +when +whipped +whole +wickets +width +wield +wife +wiggle +wildly +winter +wipeout +wiring +wise +withdrawn +wives +wizard +wobbly +woes +woken +wolf +womanly +wonders +woozy +worry +wounded +woven +wrap +wrist +wrong +yacht +yahoo +yanks +yard +yawning +yearbook +yellow +yesterday +yeti +yields +yodel +yoga +younger +yoyo +zapped +zeal +zebra +zero +zesty +zigzags +zinger +zippers +zodiac +zombie +zones +zoom \ No newline at end of file diff --git a/package.json b/package.json index 1b0ff29028..90d4a89148 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,8 @@ }, "dependencies": { "@appium/support": "^7.0.5", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", "@playwright/test": "^1.58.2", "@session-foundation/playwright-reporter": "^0.0.8", "@session-foundation/qa-seeder": "^0.1.26", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 165f2e3521..0b1d65c0b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,12 @@ importers: '@appium/support': specifier: ^7.0.5 version: 7.0.5 + '@noble/curves': + specifier: ^2.0.1 + version: 2.0.1 + '@noble/hashes': + specifier: ^2.0.1 + version: 2.0.1 '@playwright/test': specifier: ^1.58.2 version: 1.58.2 @@ -447,6 +453,14 @@ packages: '@jsquash/png@3.1.1': resolution: {integrity: sha512-C10pc+0H6j0h8fENOfnGOvkXCmvpSQTDGlfGd0sHphZhPSGTyLjIrHba0FaZZdsKqA/wlmhYicUHb92vfZphaw==} + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -3400,6 +3414,12 @@ snapshots: '@jsquash/png@3.1.1': {} + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5627,7 +5647,7 @@ snapshots: proxy-agent@6.3.1: dependencies: agent-base: 7.1.4 - debug: 4.3.4 + debug: 4.4.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 diff --git a/run/constants/index.ts b/run/constants/index.ts index 1e9ba83a08..504d6b9104 100644 --- a/run/constants/index.ts +++ b/run/constants/index.ts @@ -22,6 +22,8 @@ export const testLink = `https://getsession.org/`; export const DEVNET_URL = 'http://sesh-net.local:1280'; +export const PRO_BACKEND_URL = 'https://pro-backend-dev.getsession.org'; + export const ONS_MAPPINGS = { TESTQA: { ons: 'testqa', diff --git a/run/test/utils/mock_pro.ts b/run/test/utils/mock_pro.ts new file mode 100644 index 0000000000..e05a86fd06 --- /dev/null +++ b/run/test/utils/mock_pro.ts @@ -0,0 +1,394 @@ +/** + * Session Pro Test Account Setup + * + * Registers test accounts as Pro subscribers against the Session Pro dev backend, + * bypassing Google Play / Apple App Store verification entirely. + * + * Based on: + * https://github.com/session-foundation/session-pro-backend/blob/main/examples/endpoint_example.py + * + * Usage: + * import { makeAccountPro } from './mock_pro'; + * + * await makeAccountPro({ + * mnemonic: 'word1 word2 ... word13', + * provider: 'google' // or 'apple' + * }); + * + * In order for the changes to take effect in the clients it's best to force close and restart the app + */ + +import { ed25519 } from '@noble/curves/ed25519.js'; +import { blake2b } from '@noble/hashes/blake2.js'; +import { randomBytes } from 'crypto'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import { PRO_BACKEND_URL } from '../../../constants'; + +export type PaymentProvider = 'apple' | 'google'; + +export interface MakeAccountProParams { + mnemonic: string; + provider: PaymentProvider; + dryRun?: boolean; // If true, build and print the request but don't send it +} + +export interface AddProPaymentRequest { + version: number; + master_pkey: string; + rotating_pkey: string; + master_sig: string; + rotating_sig: string; + payment_tx: { + provider: number; + google_payment_token?: string; + google_order_id?: string; + apple_tx_id?: string; + }; +} + +export interface ProProof { + version: number; + expiry_unix_ts_ms: number; + gen_index_hash: string; + rotating_pkey: string; + sig: string; +} + +export interface AddProPaymentResponse { + status: number; + result?: ProProof; + errors?: string[]; +} + +let WORDLIST_CACHE: string[] | null = null; + +function getWordlist(): string[] { + if (WORDLIST_CACHE) { + return WORDLIST_CACHE; + } + + const wordlistPath = join(__dirname, '../../../../english_wordlist.txt'); + const content = readFileSync(wordlistPath, 'utf-8'); + const words = content.split('\n').map(w => w.trim()).filter(Boolean); + + if (words.length !== 1626) { + throw new Error(`Expected 1626 words in wordlist, got ${words.length}`); + } + + WORDLIST_CACHE = words; + return words; +} + +// Decodes a 13-word recovery phrase a 16-byte seed hex string. */ +export function mnemonicToSeedHex(mnemonic: string): string { + const wordlist = getWordlist(); + const n = wordlist.length; // 1626 + + const words = mnemonic.toLowerCase().trim().split(/\s+/); + if (words.length !== 13) { + throw new Error(`Expected 13 words, got ${words.length}`); + } + + // Build word -> index lookup + const wordToIdx = new Map(); + wordlist.forEach((w, i) => wordToIdx.set(w, i)); + + // Resolve word indices (with prefix matching support) + const indices: number[] = []; + for (const word of words) { + if (wordToIdx.has(word)) { + indices.push(wordToIdx.get(word)!); + } else { + // Try prefix match (first 4 chars) + const matches = wordlist + .map((w, i) => ({ w, i })) + .filter(({ w }) => w.startsWith(word.slice(0, 4))); + + if (matches.length === 1) { + indices.push(matches[0].i); + } else { + throw new Error(`Unknown or ambiguous mnemonic word: '${word}'`); + } + } + } + + + // Decode: every 3 words -> 4 bytes (little-endian) + const dataIndices = indices.slice(0, 12); + const seedBytes: number[] = []; + for (let i = 0; i < 12; i += 3) { + const w1 = dataIndices[i]; + const w2 = dataIndices[i + 1]; + const w3 = dataIndices[i + 2]; + + const x = w1 + n * (((w2 - w1) % n + n) % n) + n * n * (((w3 - w2) % n + n) % n); + + // Convert to 4 bytes little-endian + seedBytes.push(x & 0xff); + seedBytes.push((x >> 8) & 0xff); + seedBytes.push((x >> 16) & 0xff); + seedBytes.push((x >> 24) & 0xff); + } + + if (seedBytes.length !== 16) { + throw new Error(`Expected 16 bytes, got ${seedBytes.length}`); + } + + return Buffer.from(seedBytes).toString('hex'); +} + +function padSeed(seedHex: string): Uint8Array { + const seed = Buffer.from(seedHex, 'hex'); + if (seed.length !== 16) { + throw new Error(`Seed must be 16 bytes, got ${seed.length}`); + } + + // Pad with 16 zero bytes + const padded = new Uint8Array(32); + padded.set(seed, 0); + return padded; +} + +// Derives the account-level Ed25519 keypair by zero-padding the 16-byte seed to 32 bytes. +export function deriveAccountEd25519Keypair(seedHex: string): { privateKey: Uint8Array; publicKey: Uint8Array } { + const padded = padSeed(seedHex); + const privateKey = padded; + const publicKey = ed25519.getPublicKey(privateKey); + return { privateKey, publicKey }; +} + +// Derives the Pro master keypair from the seed using Blake2b with "SessionProRandom" as the key. +export function deriveProMasterKey(seedHex: string): { privateKey: Uint8Array; publicKey: Uint8Array } { + const padded = padSeed(seedHex); + + // Blake2b-256 with "SessionProRandom" as the key + const proSeed = blake2b(padded, { + dkLen: 32, + key: Buffer.from('SessionProRandom', 'utf-8') + }); + + const privateKey = proSeed; + const publicKey = ed25519.getPublicKey(privateKey); + + return { privateKey, publicKey }; +} + +// Generates a random ephemeral rotating keypair for the payment request. +export function generateRotatingKey(): { privateKey: Uint8Array; publicKey: Uint8Array } { + const privateKey = ed25519.utils.randomSecretKey(); + const publicKey = ed25519.getPublicKey(privateKey); + return { privateKey, publicKey }; +} + + +function makeAddProPaymentHash( + version: number, + masterPubkey: Uint8Array, + rotatingPubkey: Uint8Array, + provider: number, + paymentToken?: string, + orderId?: string, + appleTxId?: string +): Uint8Array { + const personalization = Buffer.from('ProAddPayment___', 'utf-8'); // 16 bytes + + const parts: Uint8Array[] = [ + new Uint8Array([version]), + masterPubkey, + rotatingPubkey, + new Uint8Array([provider]) + ]; + + if (provider === 1) { // Google + if (!paymentToken || !orderId) { + throw new Error('Google provider requires payment_token and order_id'); + } + parts.push(Buffer.from(paymentToken, 'utf-8')); + parts.push(Buffer.from(orderId, 'utf-8')); + } else if (provider === 2) { // Apple + if (!appleTxId) { + throw new Error('Apple provider requires tx_id'); + } + parts.push(Buffer.from(appleTxId, 'utf-8')); + } + + // Concatenate all parts + const totalLen = parts.reduce((sum, p) => sum + p.length, 0); + const message = new Uint8Array(totalLen); + let offset = 0; + for (const part of parts) { + message.set(part, offset); + offset += part.length; + } + + return blake2b(message, { dkLen: 32, personalization }); +} + +// Builds a signed add_pro_payment request body with fake payment tokens. +export function buildAddProPaymentRequest( + masterKey: { privateKey: Uint8Array; publicKey: Uint8Array }, + rotatingKey: { privateKey: Uint8Array; publicKey: Uint8Array }, + provider: PaymentProvider +): AddProPaymentRequest { + const version = 0; + const providerNum = provider === 'google' ? 1 : 2; + + let paymentToken: string | undefined; + let orderId: string | undefined; + let appleTxId: string | undefined; + + const timestamp = Date.now(); + const nonce = randomBytes(4).toString('hex'); + + if (provider === 'google') { + paymentToken = `DEV.${timestamp}.${nonce}`; + orderId = `DEV.${timestamp}.${nonce}`; + } else { + appleTxId = `DEV.${timestamp}.${nonce}`; + } + + const hash = makeAddProPaymentHash( + version, + masterKey.publicKey, + rotatingKey.publicKey, + providerNum, + paymentToken, + orderId, + appleTxId + ); + + const masterSig = ed25519.sign(hash, masterKey.privateKey); + const rotatingSig = ed25519.sign(hash, rotatingKey.privateKey); + + const paymentTx: AddProPaymentRequest['payment_tx'] = { + provider: providerNum + }; + + if (provider === 'google') { + paymentTx.google_payment_token = paymentToken; + paymentTx.google_order_id = orderId; + } else { + paymentTx.apple_tx_id = appleTxId; + } + + return { + version, + master_pkey: Buffer.from(masterKey.publicKey).toString('hex'), + rotating_pkey: Buffer.from(rotatingKey.publicKey).toString('hex'), + master_sig: Buffer.from(masterSig).toString('hex'), + rotating_sig: Buffer.from(rotatingSig).toString('hex'), + payment_tx: paymentTx + }; +} + +// POSTs the payment request to the Pro backend with retries and timeout. +export async function addProPayment( + backendUrl: string, + request: AddProPaymentRequest, + { maxAttempts = 3, timeout = 10_000 } = {} +): Promise { + const url = `${backendUrl}/add_pro_payment`; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + const data = (await response.json()) as AddProPaymentResponse; + + if (!response.ok || data.status !== 0) { + throw new Error( + `Failed to add Pro payment: ${data.errors?.join(', ') || `HTTP ${response.status}`}` + ); + } + + return data; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + if (attempt === maxAttempts) { + // eslint-disable-next-line preserve-caught-error + throw new Error(`add_pro_payment failed after ${maxAttempts} attempts: ${msg}`); + } + console.log(`add_pro_payment attempt ${attempt}/${maxAttempts} failed: ${msg}, retrying...`); + } + } + + throw new Error('Unreachable'); +} + +// Registers a test account as a Pro subscriber against the dev backend. +export async function makeAccountPro(params: MakeAccountProParams): Promise { + const { mnemonic, provider, dryRun = false } = params; + + console.log('Deriving keys from mnemonic...'); + const seedHex = mnemonicToSeedHex(mnemonic); + + const masterKey = deriveProMasterKey(seedHex); + + console.log(` Master pubkey: ${Buffer.from(masterKey.publicKey).toString('hex')}`); + + // Generate rotating key + const rotatingKey = generateRotatingKey(); + console.log(` Rotating pubkey: ${Buffer.from(rotatingKey.publicKey).toString('hex')}`); + + // Build request + console.log(`\nBuilding add_pro_payment request (${provider})...`); + const request = buildAddProPaymentRequest(masterKey, rotatingKey, provider); + console.log('\nRequest body:'); + console.log(JSON.stringify(request, null, 2)); + + if (dryRun) { + console.log('\nDRY RUN - Request not sent'); + return null; + } + + // Send request + console.log(`\nSending request to ${PRO_BACKEND_URL}...`); + const response = await addProPayment(PRO_BACKEND_URL, request); + + if (!response.result) { + throw new Error('No proof in response'); + } + + console.log('Account successfully registered as Pro'); + console.log(` Expiry: ${new Date(response.result.expiry_unix_ts_ms).toISOString()}`); + + return response.result; +} + +if (require.main === module) { + const args = process.argv.slice(2); + + if (args.length < 2) { + console.error('Usage: ts-node mock_pro.ts [--dry-run]'); + console.error('Example: ts-node mock_pro.ts "word1 word2 ..." google'); + console.error(' ts-node mock_pro.ts "word1 word2 ..." apple --dry-run'); + process.exit(1); + } + + const dryRun = args.includes('--dry-run'); + const filteredArgs = args.filter(a => a !== '--dry-run'); + const [mnemonic, provider] = filteredArgs; + + makeAccountPro({ + mnemonic, + provider: provider as PaymentProvider, + dryRun + }) + .then(() => process.exit(0)) + .catch(err => { + console.error('Error:', err.message); + process.exit(1); + }); +} \ No newline at end of file From 51116b3b767f2a288cbba04fe1be0f6a7b8d5c39 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Thu, 12 Feb 2026 13:20:55 +1100 Subject: [PATCH 02/38] feat: start ios app in post pro mode --- run/test/utils/capabilities_ios.ts | 20 +++++++++++++++----- run/test/utils/open_app.ts | 6 ++---- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/run/test/utils/capabilities_ios.ts b/run/test/utils/capabilities_ios.ts index 2097e9207f..6228b160cd 100644 --- a/run/test/utils/capabilities_ios.ts +++ b/run/test/utils/capabilities_ios.ts @@ -7,6 +7,12 @@ import { IntRange } from '../../types/RangeType'; dotenv.config({ quiet: true }); +export type IOSTestContext = { + customInstallTime?: string; + sessionProEnabled?: string; +}; + + const iosPathPrefix = process.env.IOS_APP_PATH_PREFIX; export const iOSBundleId = 'com.loki-project.loki-messenger'; @@ -125,7 +131,7 @@ export function capabilityIsValid( export function getIosCapabilities( capabilitiesIndex: CapabilitiesIndexType, - customInstallTime?: string + customCaps?: IOSTestContext ): W3CXCUITestDriverCaps { if (capabilitiesIndex >= capabilities.length) { throw new Error( @@ -141,10 +147,14 @@ export function getIosCapabilities( const baseEnv = (caps['appium:processArguments'] as { env?: Record } | undefined)?.env ?? {}; - // Optional per-test override: - // Some tests set IOS_CUSTOM_FIRST_INSTALL_DATE_TIME before starting Appium. - // If present, inject it into the processArguments.env. Otherwise inject nothing. - const customEnv = customInstallTime ? { customFirstInstallDateTime: customInstallTime } : {}; + // Build custom env entries from per-test overrides + const customEnv: Record = {}; + if (customCaps?.customInstallTime) { + customEnv.customFirstInstallDateTime = customCaps.customInstallTime; + } + if (customCaps?.sessionProEnabled) { + customEnv.sessionPro = customCaps.sessionProEnabled; + } // Rebuild the processArguments block with merged env vars caps['appium:processArguments'] = { diff --git a/run/test/utils/open_app.ts b/run/test/utils/open_app.ts index 38655f111e..bb6099e553 100644 --- a/run/test/utils/open_app.ts +++ b/run/test/utils/open_app.ts @@ -19,6 +19,7 @@ import { capabilityIsValid, getIosCapabilities, iOSBundleId, + IOSTestContext, } from './capabilities_ios'; import { cleanPermissions } from './permissions'; import { registerDevicesForTest } from './screenshot_helper'; @@ -29,9 +30,6 @@ const APPIUM_PORT = 4728; export type SupportedPlatformsType = 'android' | 'ios'; -export type IOSTestContext = { - customInstallTime?: string; -}; export const openAppMultipleDevices = async ( platform: SupportedPlatformsType, @@ -339,7 +337,7 @@ const openiOSApp = async ( const capabilities = getIosCapabilities( actualCapabilitiesIndex as CapabilitiesIndexType, - iOSContext?.customInstallTime + iOSContext ); const udid = capabilities.alwaysMatch['appium:udid'] as string; From 0113d754aaf0b5e10d517e0155e57c1e66f3e2b3 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Thu, 12 Feb 2026 13:21:37 +1100 Subject: [PATCH 03/38] feat: test animated display picture upload --- .gitattributes | 7 +- run/constants/testfiles.ts | 1 + run/test/locators/index.ts | 17 ++++- run/test/specs/cta_donate_review.spec.ts | 6 +- run/test/specs/cta_donate_time.spec.ts | 12 +--- .../specs/media/animated_profile_picture.webp | 3 + run/test/specs/message_length.spec.ts | 11 +-- ...r_actions_animated_profile_picture.spec.ts | 69 +++++++++++++++++++ run/test/utils/check_cta.ts | 38 ++++++++++ run/test/utils/test_setup.ts | 51 -------------- run/types/DeviceWrapper.ts | 65 +++++++++++------ run/types/testing.ts | 1 + 12 files changed, 182 insertions(+), 99 deletions(-) create mode 100644 run/test/specs/media/animated_profile_picture.webp create mode 100644 run/test/specs/user_actions_animated_profile_picture.spec.ts create mode 100644 run/test/utils/check_cta.ts delete mode 100644 run/test/utils/test_setup.ts diff --git a/.gitattributes b/.gitattributes index 99ac5c82c8..64b88312a8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,6 @@ -run/screenshots/**/*.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.pdf filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.mp4 filter=lfs diff=lfs merge=lfs -text +*.webp filter=lfs diff=lfs merge=lfs -text +*.gif filter=lfs diff=lfs merge=lfs -text diff --git a/run/constants/testfiles.ts b/run/constants/testfiles.ts index 8f66e8cd7a..751ca10abd 100644 --- a/run/constants/testfiles.ts +++ b/run/constants/testfiles.ts @@ -3,3 +3,4 @@ export const testFile = 'test_file.pdf'; export const testVideo = 'test_video.mp4'; export const testVideoThumbnail = 'test_video_thumbnail.png'; export const profilePicture = 'profile_picture.jpg'; +export const animatedProfilePicture = 'animated_profile_picture.webp' diff --git a/run/test/locators/index.ts b/run/test/locators/index.ts index bde5766884..33ace6b1b8 100644 --- a/run/test/locators/index.ts +++ b/run/test/locators/index.ts @@ -311,10 +311,25 @@ export class FirstGif extends LocatorsInterface { } } -export class ImageName extends LocatorsInterface { +export class GIFName extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { // Dates can wildly differ between emulators but it will begin with "Photo taken on" on Android + case 'android': + return { + strategy: 'xpath', + selector: `//*[starts-with(@content-desc, "GIF taken on")]`, + }; + case 'ios': + throw new Error(`No such element on iOS`); + } + } +} + +export class ImageName extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + // Dates can wildly differ between emulators but it will begin with "GIF taken on" on Android case 'android': return { strategy: 'xpath', diff --git a/run/test/specs/cta_donate_review.spec.ts b/run/test/specs/cta_donate_review.spec.ts index 6000863d39..f34ba55aef 100644 --- a/run/test/specs/cta_donate_review.spec.ts +++ b/run/test/specs/cta_donate_review.spec.ts @@ -44,11 +44,7 @@ async function donateCTAReview(platform: SupportedPlatformsType, testInfo: TestI await forceStopAndRestartApp(device); }); await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Donate CTA'), async () => { - await device.checkCTAStrings( - tStripped('donateSessionHelp'), - tStripped('donateSessionDescription'), - [tStripped('donate'), tStripped('maybeLater')] - ); + await device.checkCTA('donate'); }); // There *is* supposed to be a blur on Android but there is a bug on API 34 emulators preventing it from showing await test.step(TestSteps.VERIFY.SCREENSHOT('Donate CTA'), async () => { diff --git a/run/test/specs/cta_donate_time.spec.ts b/run/test/specs/cta_donate_time.spec.ts index 06844e0f0e..04fed44682 100644 --- a/run/test/specs/cta_donate_time.spec.ts +++ b/run/test/specs/cta_donate_time.spec.ts @@ -1,15 +1,13 @@ import test, { TestInfo } from '@playwright/test'; -import { tStripped } from '../../localizer/lib'; import { TestSteps } from '../../types/allure'; import { iosIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; -import { CTAButtonPositive } from '../locators/global'; import { PlusButton } from '../locators/home'; import { newUser } from '../utils/create_account'; +import { IOSTestContext } from '../utils/capabilities_ios'; import { closeApp, - IOSTestContext, openAppOnPlatformSingleDevice, SupportedPlatformsType, } from '../utils/open_app'; @@ -42,11 +40,7 @@ async function donateCTAShowsSevenDaysAgo(platform: SupportedPlatformsType, test return { device }; }); await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Donate CTA'), async () => { - await device.checkCTAStrings( - tStripped('donateSessionHelp'), - tStripped('donateSessionDescription'), - [tStripped('donate'), tStripped('maybeLater')] - ); + await device.checkCTA('donate'); }); await test.step(TestSteps.SETUP.CLOSE_APP, async () => { await closeApp(device); @@ -77,7 +71,7 @@ async function donateCTADoesntShowSixDaysAgo(platform: SupportedPlatformsType, t await test.step('Verify Donate CTA does not show', async () => { await Promise.all([ device.waitForTextElementToBePresent(new PlusButton(device)), - device.verifyElementNotPresent(new CTAButtonPositive(device)), + device.verifyNoCTAShows(), ]); }); await test.step(TestSteps.SETUP.CLOSE_APP, async () => { diff --git a/run/test/specs/media/animated_profile_picture.webp b/run/test/specs/media/animated_profile_picture.webp new file mode 100644 index 0000000000..2c9906d2c8 --- /dev/null +++ b/run/test/specs/media/animated_profile_picture.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cdc5aad7aad38f0f279cfc0d5b8143dd8354420f505991ec49c0ee2fa97d099b +size 417482 diff --git a/run/test/specs/message_length.spec.ts b/run/test/specs/message_length.spec.ts index 95e4fa386b..7d720d9e3f 100644 --- a/run/test/specs/message_length.spec.ts +++ b/run/test/specs/message_length.spec.ts @@ -97,16 +97,7 @@ for (const testCase of messageLengthTestCases) { // Android: CTA appears, verify and dismiss // Post-Pro is active on debug/qa builds by default // This will be the default for both platforms once Pro is live - await device.checkCTAStrings( - tStripped('upgradeTo'), - tStripped('proCallToActionLongerMessages'), - [tStripped('theContinue'), tStripped('cancel')], - [ - tStripped('proFeatureListLongerMessages'), - tStripped('proFeatureListPinnedConversations'), - tStripped('proFeatureListLoadsMore'), - ] - ); + await device.checkCTA('longerMessages'); await device.clickOnElementAll(new CTAButtonNegative(device)); await device.verifyElementNotPresent(new MessageBody(device, message)); } diff --git a/run/test/specs/user_actions_animated_profile_picture.spec.ts b/run/test/specs/user_actions_animated_profile_picture.spec.ts new file mode 100644 index 0000000000..17d1f332c8 --- /dev/null +++ b/run/test/specs/user_actions_animated_profile_picture.spec.ts @@ -0,0 +1,69 @@ +import { test, type TestInfo } from '@playwright/test'; + +import { TestSteps } from '../../types/allure'; +import { bothPlatformsIt } from '../../types/sessionIt'; +import { USERNAME } from '../../types/testing'; +import { PathMenuItem } from './locators/settings'; +import { newUser } from './utils/create_account'; +import { makeAccountPro } from './utils/mock_pro'; +import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from './utils/open_app'; +import { forceStopAndRestart } from './utils/utilities'; + +bothPlatformsIt({ + title: 'Upload animated profile picture (non Pro)', + risk: 'medium', + countOfDevicesNeeded: 1, + testCb: nonProAnimatedDP, + allureSuites: { + parent: 'User Actions', + suite: 'Change Profile Picture', + }, +}); + +bothPlatformsIt({ + title: 'Upload animated profile picture (Pro)', + risk: 'medium', + countOfDevicesNeeded: 1, + testCb: proAnimatedDP, + allureSuites: { + parent: 'User Actions', + suite: 'Change Profile Picture', + }, +}); + +async function nonProAnimatedDP(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + await newUser(device, USERNAME.ALICE, { saveUserData: false }); + return { device }; + }); + await test.step(TestSteps.USER_ACTIONS.CHANGE_PROFILE_PICTURE, async () => { + await device.uploadProfilePicture(true); + await device.checkCTA('animatedProfilePicture'); + }); + + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} +async function proAnimatedDP(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device, alice } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + const alice = await newUser(device, USERNAME.ALICE); + return { device, alice }; + }); + await makeAccountPro({ + mnemonic: alice.recoveryPhrase, + provider: 'google' + }, +); + await forceStopAndRestart(device) + await test.step(TestSteps.USER_ACTIONS.CHANGE_PROFILE_PICTURE, async () => { + await device.uploadProfilePicture(true); + }); + await device.waitForTextElementToBePresent(new PathMenuItem(device)) + await device.verifyNoCTAShows() + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} \ No newline at end of file diff --git a/run/test/utils/check_cta.ts b/run/test/utils/check_cta.ts new file mode 100644 index 0000000000..41a7f84533 --- /dev/null +++ b/run/test/utils/check_cta.ts @@ -0,0 +1,38 @@ +import { tStripped } from '../../../localizer/lib'; + +export type CTAType = 'animatedProfilePicture' | 'donate' | 'longerMessages'; + +export type CTAConfig = { + heading: string; + body: string; + buttons: string[]; + features?: string[]; +} + +export const ctaConfigs: Record = { + donate: { + heading: tStripped('donateSessionHelp'), + body: tStripped('donateSessionDescription'), + buttons: [tStripped('donate'), tStripped('maybeLater')], + }, + longerMessages: { + heading: tStripped('upgradeTo'), + body: tStripped('proCallToActionLongerMessages'), + buttons: [tStripped('theContinue'), tStripped('cancel')], + features: [ + tStripped('proFeatureListLongerMessages'), + tStripped('proFeatureListPinnedConversations'), + tStripped('proFeatureListLoadsMore'), + ], + }, + animatedProfilePicture: { + heading: tStripped('upgradeTo'), + body: tStripped('proAnimatedDisplayPictureCallToActionDescription'), + buttons: [tStripped('theContinue'), tStripped('cancel')], + features: [ + tStripped('proFeatureListAnimatedDisplayPicture'), + tStripped('proFeatureListLongerMessages'), + tStripped('proFeatureListLoadsMore'), + ], + }, +}; diff --git a/run/test/utils/test_setup.ts b/run/test/utils/test_setup.ts deleted file mode 100644 index 97af4b9edf..0000000000 --- a/run/test/utils/test_setup.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* -Checkout branch that needs testing: -The Command needs to include: -- platform -- branch -- number of emulators ( new user to old user ratio ) - - -navigate to platform folder - Documents > session-(platform) -git checkout *branch* -then build from branch (ios does automatically, android requires manually click on hammer icon) - -ANDROID -start two emulators (cold boot on android) -cd ~/Library/Android/sdk -./emulator/emulator -avd Pixel_4_API_30 - -open new terminal window -cd ~/Library/Android/sdk -./emulator/emulator -avd Pixel_4_API_30_2 - -IOS -open -a simulator --args -IOS_1_SIMULATOR -no-boot-anim -open -a simulator --args -IOS_2_SIMULATOR -no-boot-anim - -run branch on emulator one -run branch on emulator two - -once session is running on emulator one: -click create session id -save session id -click continue -enter display name -continue -continue -continue -save recovery phrase from reminder -navigate out of reminder page - -once session is running on emulator two: -log into test account -click on restore account - - - - - - - -*/ diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index 7ad9f1a1b8..c1fdbc2d15 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -18,12 +18,14 @@ import { describeLocator, DownloadMediaButton, FirstGif, + GIFName, ImageName, ImagePermissionsModalAllow, LocatorsInterface, ReadReceiptsButton, } from '../../run/test/locators'; import { + animatedProfilePicture, profilePicture, testFile, testImage, @@ -68,7 +70,11 @@ import { UserSettings, VersionNumber, } from '../test/locators/settings'; -import { EnterAccountID, NewMessageOption, NextButton } from '../test/locators/start_conversation'; +import { + EnterAccountID, + NewMessageOption, + NextButton, +} from '../test/locators/start_conversation'; import { clickOnCoordinates, sleepFor } from '../test/utils'; import { getAdbFullPath } from '../test/utils/binaries'; import { parseDataImage } from '../test/utils/check_colour'; @@ -1874,7 +1880,7 @@ export class DeviceWrapper { } public async pushMediaToDevice( - mediaFileName: 'profile_picture.jpg' | 'test_file.pdf' | 'test_image.jpg' | 'test_video.mp4' + mediaFileName: 'animated_profile_picture.webp' | 'profile_picture.jpg' | 'test_file.pdf' | 'test_image.jpg' | 'test_video.mp4' ) { const filePath = path.join('run', 'test', 'specs', 'media', mediaFileName); if (this.isIOS()) { @@ -2155,7 +2161,17 @@ export class DeviceWrapper { return sentTimestamp; } - public async uploadProfilePicture() { + public async uploadProfilePicture(animated: boolean = false) { + let uploadPicture: 'animated_profile_picture.webp' | 'profile_picture.jpg' + let dpLocator + if (animated) { + uploadPicture = animatedProfilePicture + dpLocator = new GIFName(this) + } else { + uploadPicture = profilePicture + dpLocator = new ImageName(this) + } + await this.clickOnElementAll(new UserSettings(this)); // Click on Profile picture await this.clickOnElementAll(new UserAvatar(this)); @@ -2166,12 +2182,12 @@ export class DeviceWrapper { await sleepFor(5000); // sometimes Appium doesn't recognize the XPATH immediately await this.matchAndTapImage( { strategy: 'xpath', selector: `//XCUIElementTypeImage` }, - profilePicture + uploadPicture ); await this.clickOnByAccessibilityID('Done'); } else if (this.isAndroid()) { // Push file first - await this.pushMediaToDevice(profilePicture); + await this.pushMediaToDevice(uploadPicture); await this.clickOnElementAll(new ImagePermissionsModalAllow(this)); await sleepFor(1000); await this.clickOnElementAll({ @@ -2179,8 +2195,8 @@ export class DeviceWrapper { selector: 'Image button', }); await sleepFor(500); - await this.clickOnElementAll(new ImageName(this)); - await this.clickOnElementById('network.loki.messenger:id/crop_image_menu_crop'); + await this.clickOnElementAll(dpLocator); + if (!animated) { await this.clickOnElementById('network.loki.messenger:id/crop_image_menu_crop'); } } await this.clickOnElementAll(new SaveProfilePictureButton(this)); } @@ -2523,21 +2539,12 @@ export class DeviceWrapper { this.assertTextMatches(actualDescription, expectedDescription, 'Modal description'); } - /** - * Checks CTA component text against expected values. - * CTAs contain: heading, body, 0-3 features, 1-2 buttons. - * @param heading - Expected CTA heading text - * @param body - Expected CTA body text - * @param buttons - Expected button text(s). First is positive, second (if present) is negative - * @param features - Optional array of expected feature text (0-3 items) - * @throws Error if any text element doesn't match expected value - */ - public async checkCTAStrings( - heading: string, - body: string, - buttons: string[], - features?: string[] - ): Promise { + private async checkCTAStrings({ + heading, + body, + buttons, + features, + }: CTAConfig): Promise { // Validate input if (features && features.length > 3) { throw new Error('CTAs support maximum 3 features'); @@ -2580,6 +2587,20 @@ export class DeviceWrapper { } } + public async checkCTA(type: CTAType): Promise { + await this.checkCTAStrings(ctaConfigs[type]); + } + + // This is the bare minimum of a CTA so we only check these + // Features may or may not exist anyway, same goes for negative buttons + public async verifyNoCTAShows(): Promise { + await Promise.all([ + this.verifyElementNotPresent(new CTAHeading(this)), + this.verifyElementNotPresent(new CTABody(this)), + this.verifyElementNotPresent(new CTAButtonPositive(this)) + ]) + } + public async getElementPixelColor(args: LocatorsInterface): Promise { // Wait for the element to be present const element = await this.waitForTextElementToBePresent(args); diff --git a/run/types/testing.ts b/run/types/testing.ts index e8032a0180..b02be61ac4 100644 --- a/run/types/testing.ts +++ b/run/types/testing.ts @@ -124,6 +124,7 @@ export type XPath = | `(//XCUIElementTypeImage[@name="gif cell"])[1]` | `//*[./*[@name='${DISAPPEARING_TIMES}']]/*[2]` | `//*[@resource-id='network.loki.messenger:id/callTitle' and contains(@text, ':')]` + | `//*[starts-with(@content-desc, "GIF taken on")]` | `//*[starts-with(@content-desc, "Photo taken on")]` | `//android.view.ViewGroup[@resource-id='network.loki.messenger:id/mainContainer'][.//android.widget.TextView[contains(@text,'${string}')]]//androidx.compose.ui.platform.ComposeView[@resource-id='network.loki.messenger:id/profilePictureView']` | `//android.view.ViewGroup[@resource-id="network.loki.messenger:id/mainContainer"][.//android.widget.TextView[contains(@text,"${string}")]]//android.view.ViewGroup[@resource-id="network.loki.messenger:id/layout_emoji_container"]` From 6e32b55fc5ed2f60cc57c2d0228b14700d9ed490 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Thu, 12 Feb 2026 17:04:36 +1100 Subject: [PATCH 04/38] fix: use rainbow gif, sample pixels for animation --- run/constants/testfiles.ts | 2 +- .../specs/media/animated_profile_picture.gif | 3 + .../specs/media/animated_profile_picture.webp | 3 - ...r_actions_animated_profile_picture.spec.ts | 20 ++-- run/test/utils/capabilities_ios.ts | 1 - run/test/utils/check_cta.ts | 2 +- run/test/utils/mock_pro.ts | 113 ++++++++++-------- run/test/utils/open_app.ts | 1 - run/types/DeviceWrapper.ts | 56 ++++++--- 9 files changed, 113 insertions(+), 88 deletions(-) create mode 100644 run/test/specs/media/animated_profile_picture.gif delete mode 100644 run/test/specs/media/animated_profile_picture.webp diff --git a/run/constants/testfiles.ts b/run/constants/testfiles.ts index 751ca10abd..ba5d230b61 100644 --- a/run/constants/testfiles.ts +++ b/run/constants/testfiles.ts @@ -3,4 +3,4 @@ export const testFile = 'test_file.pdf'; export const testVideo = 'test_video.mp4'; export const testVideoThumbnail = 'test_video_thumbnail.png'; export const profilePicture = 'profile_picture.jpg'; -export const animatedProfilePicture = 'animated_profile_picture.webp' +export const animatedProfilePicture = 'animated_profile_picture.gif'; diff --git a/run/test/specs/media/animated_profile_picture.gif b/run/test/specs/media/animated_profile_picture.gif new file mode 100644 index 0000000000..e609338d16 --- /dev/null +++ b/run/test/specs/media/animated_profile_picture.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0cbfc07234028c655e51d9a3d218061c2363b314b4ce59101ce0bfac6e085d77 +size 33041 diff --git a/run/test/specs/media/animated_profile_picture.webp b/run/test/specs/media/animated_profile_picture.webp deleted file mode 100644 index 2c9906d2c8..0000000000 --- a/run/test/specs/media/animated_profile_picture.webp +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cdc5aad7aad38f0f279cfc0d5b8143dd8354420f505991ec49c0ee2fa97d099b -size 417482 diff --git a/run/test/specs/user_actions_animated_profile_picture.spec.ts b/run/test/specs/user_actions_animated_profile_picture.spec.ts index 17d1f332c8..f3de9f41b6 100644 --- a/run/test/specs/user_actions_animated_profile_picture.spec.ts +++ b/run/test/specs/user_actions_animated_profile_picture.spec.ts @@ -3,7 +3,7 @@ import { test, type TestInfo } from '@playwright/test'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; -import { PathMenuItem } from './locators/settings'; +import { PathMenuItem, UserAvatar } from './locators/settings'; import { newUser } from './utils/create_account'; import { makeAccountPro } from './utils/mock_pro'; import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from './utils/open_app'; @@ -52,18 +52,18 @@ async function proAnimatedDP(platform: SupportedPlatformsType, testInfo: TestInf const alice = await newUser(device, USERNAME.ALICE); return { device, alice }; }); - await makeAccountPro({ - mnemonic: alice.recoveryPhrase, - provider: 'google' - }, -); - await forceStopAndRestart(device) + await makeAccountPro({ + mnemonic: alice.recoveryPhrase, + provider: 'google', + }); + await forceStopAndRestart(device); await test.step(TestSteps.USER_ACTIONS.CHANGE_PROFILE_PICTURE, async () => { await device.uploadProfilePicture(true); }); - await device.waitForTextElementToBePresent(new PathMenuItem(device)) - await device.verifyNoCTAShows() + await device.waitForTextElementToBePresent(new PathMenuItem(device)); + await device.verifyNoCTAShows(); + await device.verifyElementIsAnimated(new UserAvatar(device)); await test.step(TestSteps.SETUP.CLOSE_APP, async () => { await closeApp(device); }); -} \ No newline at end of file +} diff --git a/run/test/utils/capabilities_ios.ts b/run/test/utils/capabilities_ios.ts index 6228b160cd..aca51480b5 100644 --- a/run/test/utils/capabilities_ios.ts +++ b/run/test/utils/capabilities_ios.ts @@ -12,7 +12,6 @@ export type IOSTestContext = { sessionProEnabled?: string; }; - const iosPathPrefix = process.env.IOS_APP_PATH_PREFIX; export const iOSBundleId = 'com.loki-project.loki-messenger'; diff --git a/run/test/utils/check_cta.ts b/run/test/utils/check_cta.ts index 41a7f84533..a7f9cd7d43 100644 --- a/run/test/utils/check_cta.ts +++ b/run/test/utils/check_cta.ts @@ -7,7 +7,7 @@ export type CTAConfig = { body: string; buttons: string[]; features?: string[]; -} +}; export const ctaConfigs: Record = { donate: { diff --git a/run/test/utils/mock_pro.ts b/run/test/utils/mock_pro.ts index e05a86fd06..06e68a8588 100644 --- a/run/test/utils/mock_pro.ts +++ b/run/test/utils/mock_pro.ts @@ -1,20 +1,20 @@ /** * Session Pro Test Account Setup - * + * * Registers test accounts as Pro subscribers against the Session Pro dev backend, * bypassing Google Play / Apple App Store verification entirely. - * - * Based on: + * + * Based on: * https://github.com/session-foundation/session-pro-backend/blob/main/examples/endpoint_example.py - * + * * Usage: * import { makeAccountPro } from './mock_pro'; - * + * * await makeAccountPro({ * mnemonic: 'word1 word2 ... word13', * provider: 'google' // or 'apple' * }); - * + * * In order for the changes to take effect in the clients it's best to force close and restart the app */ @@ -31,7 +31,7 @@ export type PaymentProvider = 'apple' | 'google'; export interface MakeAccountProParams { mnemonic: string; provider: PaymentProvider; - dryRun?: boolean; // If true, build and print the request but don't send it + dryRun?: boolean; // If true, build and print the request but don't send it } export interface AddProPaymentRequest { @@ -71,7 +71,10 @@ function getWordlist(): string[] { const wordlistPath = join(__dirname, '../../../../english_wordlist.txt'); const content = readFileSync(wordlistPath, 'utf-8'); - const words = content.split('\n').map(w => w.trim()).filter(Boolean); + const words = content + .split('\n') + .map(w => w.trim()) + .filter(Boolean); if (words.length !== 1626) { throw new Error(`Expected 1626 words in wordlist, got ${words.length}`); @@ -105,7 +108,7 @@ export function mnemonicToSeedHex(mnemonic: string): string { const matches = wordlist .map((w, i) => ({ w, i })) .filter(({ w }) => w.startsWith(word.slice(0, 4))); - + if (matches.length === 1) { indices.push(matches[0].i); } else { @@ -114,7 +117,6 @@ export function mnemonicToSeedHex(mnemonic: string): string { } } - // Decode: every 3 words -> 4 bytes (little-endian) const dataIndices = indices.slice(0, 12); const seedBytes: number[] = []; @@ -122,9 +124,9 @@ export function mnemonicToSeedHex(mnemonic: string): string { const w1 = dataIndices[i]; const w2 = dataIndices[i + 1]; const w3 = dataIndices[i + 2]; - - const x = w1 + n * (((w2 - w1) % n + n) % n) + n * n * (((w3 - w2) % n + n) % n); - + + const x = w1 + n * ((((w2 - w1) % n) + n) % n) + n * n * ((((w3 - w2) % n) + n) % n); + // Convert to 4 bytes little-endian seedBytes.push(x & 0xff); seedBytes.push((x >> 8) & 0xff); @@ -144,7 +146,7 @@ function padSeed(seedHex: string): Uint8Array { if (seed.length !== 16) { throw new Error(`Seed must be 16 bytes, got ${seed.length}`); } - + // Pad with 16 zero bytes const padded = new Uint8Array(32); padded.set(seed, 0); @@ -152,37 +154,42 @@ function padSeed(seedHex: string): Uint8Array { } // Derives the account-level Ed25519 keypair by zero-padding the 16-byte seed to 32 bytes. -export function deriveAccountEd25519Keypair(seedHex: string): { privateKey: Uint8Array; publicKey: Uint8Array } { +export function deriveAccountEd25519Keypair(seedHex: string): { + privateKey: Uint8Array; + publicKey: Uint8Array; +} { const padded = padSeed(seedHex); const privateKey = padded; const publicKey = ed25519.getPublicKey(privateKey); return { privateKey, publicKey }; } -// Derives the Pro master keypair from the seed using Blake2b with "SessionProRandom" as the key. -export function deriveProMasterKey(seedHex: string): { privateKey: Uint8Array; publicKey: Uint8Array } { +// Derives the Pro master keypair from the seed using Blake2b with "SessionProRandom" as the key. +export function deriveProMasterKey(seedHex: string): { + privateKey: Uint8Array; + publicKey: Uint8Array; +} { const padded = padSeed(seedHex); - + // Blake2b-256 with "SessionProRandom" as the key - const proSeed = blake2b(padded, { + const proSeed = blake2b(padded, { dkLen: 32, - key: Buffer.from('SessionProRandom', 'utf-8') + key: Buffer.from('SessionProRandom', 'utf-8'), }); - + const privateKey = proSeed; const publicKey = ed25519.getPublicKey(privateKey); - + return { privateKey, publicKey }; } -// Generates a random ephemeral rotating keypair for the payment request. +// Generates a random ephemeral rotating keypair for the payment request. export function generateRotatingKey(): { privateKey: Uint8Array; publicKey: Uint8Array } { const privateKey = ed25519.utils.randomSecretKey(); const publicKey = ed25519.getPublicKey(privateKey); return { privateKey, publicKey }; } - function makeAddProPaymentHash( version: number, masterPubkey: Uint8Array, @@ -193,27 +200,29 @@ function makeAddProPaymentHash( appleTxId?: string ): Uint8Array { const personalization = Buffer.from('ProAddPayment___', 'utf-8'); // 16 bytes - + const parts: Uint8Array[] = [ new Uint8Array([version]), masterPubkey, rotatingPubkey, - new Uint8Array([provider]) + new Uint8Array([provider]), ]; - - if (provider === 1) { // Google + + if (provider === 1) { + // Google if (!paymentToken || !orderId) { throw new Error('Google provider requires payment_token and order_id'); } parts.push(Buffer.from(paymentToken, 'utf-8')); parts.push(Buffer.from(orderId, 'utf-8')); - } else if (provider === 2) { // Apple + } else if (provider === 2) { + // Apple if (!appleTxId) { throw new Error('Apple provider requires tx_id'); } parts.push(Buffer.from(appleTxId, 'utf-8')); } - + // Concatenate all parts const totalLen = parts.reduce((sum, p) => sum + p.length, 0); const message = new Uint8Array(totalLen); @@ -222,7 +231,7 @@ function makeAddProPaymentHash( message.set(part, offset); offset += part.length; } - + return blake2b(message, { dkLen: 32, personalization }); } @@ -234,11 +243,11 @@ export function buildAddProPaymentRequest( ): AddProPaymentRequest { const version = 0; const providerNum = provider === 'google' ? 1 : 2; - + let paymentToken: string | undefined; let orderId: string | undefined; let appleTxId: string | undefined; - + const timestamp = Date.now(); const nonce = randomBytes(4).toString('hex'); @@ -248,7 +257,7 @@ export function buildAddProPaymentRequest( } else { appleTxId = `DEV.${timestamp}.${nonce}`; } - + const hash = makeAddProPaymentHash( version, masterKey.publicKey, @@ -258,28 +267,28 @@ export function buildAddProPaymentRequest( orderId, appleTxId ); - + const masterSig = ed25519.sign(hash, masterKey.privateKey); const rotatingSig = ed25519.sign(hash, rotatingKey.privateKey); - + const paymentTx: AddProPaymentRequest['payment_tx'] = { - provider: providerNum + provider: providerNum, }; - + if (provider === 'google') { paymentTx.google_payment_token = paymentToken; paymentTx.google_order_id = orderId; } else { paymentTx.apple_tx_id = appleTxId; } - + return { version, master_pkey: Buffer.from(masterKey.publicKey).toString('hex'), rotating_pkey: Buffer.from(rotatingKey.publicKey).toString('hex'), master_sig: Buffer.from(masterSig).toString('hex'), rotating_sig: Buffer.from(rotatingSig).toString('hex'), - payment_tx: paymentTx + payment_tx: paymentTx, }; } @@ -327,7 +336,7 @@ export async function addProPayment( throw new Error('Unreachable'); } -// Registers a test account as a Pro subscriber against the dev backend. +// Registers a test account as a Pro subscriber against the dev backend. export async function makeAccountPro(params: MakeAccountProParams): Promise { const { mnemonic, provider, dryRun = false } = params; @@ -335,41 +344,41 @@ export async function makeAccountPro(params: MakeAccountProParams): Promise [--dry-run]'); console.error('Example: ts-node mock_pro.ts "word1 word2 ..." google'); @@ -384,11 +393,11 @@ if (require.main === module) { makeAccountPro({ mnemonic, provider: provider as PaymentProvider, - dryRun + dryRun, }) .then(() => process.exit(0)) .catch(err => { console.error('Error:', err.message); process.exit(1); }); -} \ No newline at end of file +} diff --git a/run/test/utils/open_app.ts b/run/test/utils/open_app.ts index bb6099e553..e3640b58c5 100644 --- a/run/test/utils/open_app.ts +++ b/run/test/utils/open_app.ts @@ -30,7 +30,6 @@ const APPIUM_PORT = 4728; export type SupportedPlatformsType = 'android' | 'ios'; - export const openAppMultipleDevices = async ( platform: SupportedPlatformsType, numberOfDevices: number, diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index c1fdbc2d15..84a3251991 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -1,7 +1,7 @@ import type { Constraints, DefaultCreateSessionResult } from '@appium/types'; import { getImageOccurrence } from '@appium/opencv'; -import { TestInfo } from '@playwright/test'; +import { expect, TestInfo } from '@playwright/test'; import { AndroidUiautomator2Driver } from 'appium-uiautomator2-driver'; import { W3CUiautomator2DriverCaps } from 'appium-uiautomator2-driver/build/lib/types'; import { W3CXCUITestDriverCaps, XCUITestDriver } from 'appium-xcuitest-driver/build/lib/driver'; @@ -1880,7 +1880,12 @@ export class DeviceWrapper { } public async pushMediaToDevice( - mediaFileName: 'animated_profile_picture.webp' | 'profile_picture.jpg' | 'test_file.pdf' | 'test_image.jpg' | 'test_video.mp4' + mediaFileName: + | 'animated_profile_picture.gif' + | 'profile_picture.jpg' + | 'test_file.pdf' + | 'test_image.jpg' + | 'test_video.mp4' ) { const filePath = path.join('run', 'test', 'specs', 'media', mediaFileName); if (this.isIOS()) { @@ -2162,14 +2167,14 @@ export class DeviceWrapper { } public async uploadProfilePicture(animated: boolean = false) { - let uploadPicture: 'animated_profile_picture.webp' | 'profile_picture.jpg' - let dpLocator + let uploadPicture: 'animated_profile_picture.gif' | 'profile_picture.jpg'; + let dpLocator; if (animated) { - uploadPicture = animatedProfilePicture - dpLocator = new GIFName(this) + uploadPicture = animatedProfilePicture; + dpLocator = new GIFName(this); } else { - uploadPicture = profilePicture - dpLocator = new ImageName(this) + uploadPicture = profilePicture; + dpLocator = new ImageName(this); } await this.clickOnElementAll(new UserSettings(this)); @@ -2196,7 +2201,9 @@ export class DeviceWrapper { }); await sleepFor(500); await this.clickOnElementAll(dpLocator); - if (!animated) { await this.clickOnElementById('network.loki.messenger:id/crop_image_menu_crop'); } + if (!animated) { + await this.clickOnElementById('network.loki.messenger:id/crop_image_menu_crop'); + } } await this.clickOnElementAll(new SaveProfilePictureButton(this)); } @@ -2539,12 +2546,7 @@ export class DeviceWrapper { this.assertTextMatches(actualDescription, expectedDescription, 'Modal description'); } - private async checkCTAStrings({ - heading, - body, - buttons, - features, - }: CTAConfig): Promise { + private async checkCTAStrings({ heading, body, buttons, features }: CTAConfig): Promise { // Validate input if (features && features.length > 3) { throw new Error('CTAs support maximum 3 features'); @@ -2591,14 +2593,14 @@ export class DeviceWrapper { await this.checkCTAStrings(ctaConfigs[type]); } - // This is the bare minimum of a CTA so we only check these - // Features may or may not exist anyway, same goes for negative buttons + // This is the bare minimum of a CTA so we only check these + // Features may or may not exist anyway, same goes for negative buttons public async verifyNoCTAShows(): Promise { await Promise.all([ this.verifyElementNotPresent(new CTAHeading(this)), this.verifyElementNotPresent(new CTABody(this)), - this.verifyElementNotPresent(new CTAButtonPositive(this)) - ]) + this.verifyElementNotPresent(new CTAButtonPositive(this)), + ]); } public async getElementPixelColor(args: LocatorsInterface): Promise { @@ -2609,6 +2611,22 @@ export class DeviceWrapper { const pixelColor = await parseDataImage(base64image); return pixelColor; } + // Sample an element's centre pixel color SAMPLE_SIZE times to determine whether it is animated or not. + // If the set contains more than 1 color it is likely animated. + public async verifyElementIsAnimated(args: LocatorsInterface): Promise { + const SAMPLE_SIZE = 3; + const colors = new Set(); + for (let i = 0; i < SAMPLE_SIZE; i++) { + colors.add(await this.getElementPixelColor(args)); + if (i < SAMPLE_SIZE - 1) { + await sleepFor(300); + } + } + expect( + colors.size, + `Expected element to be animated but detected 1 unique color: ${[...colors][0]}` + ).toBeGreaterThan(1); + } public async getVersionNumber() { // NOTE if this becomes necessary for more tests, consider adding a property/caching to the DeviceWrapper From c22f4cb5efef93c08c0d47073dc99c0cc191dbc7 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Fri, 13 Feb 2026 09:07:58 +1100 Subject: [PATCH 05/38] fix: recovery banner only shows with >2 convos --- run/test/locators/settings.ts | 4 ++-- run/test/utils/create_account.ts | 12 +++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/run/test/locators/settings.ts b/run/test/locators/settings.ts index 0352e10e5b..f9fa622f83 100644 --- a/run/test/locators/settings.ts +++ b/run/test/locators/settings.ts @@ -208,8 +208,8 @@ export class RecoveryPasswordMenuItem extends LocatorsInterface { switch (this.platform) { case 'android': return { - strategy: 'id', - selector: 'Recovery password menu item', + strategy: '-android uiautomator', + selector: 'new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().resourceId("Recovery password menu item"))', } as const; case 'ios': return { diff --git a/run/test/utils/create_account.ts b/run/test/utils/create_account.ts index 05922ee2e8..22dbfe563a 100644 --- a/run/test/utils/create_account.ts +++ b/run/test/utils/create_account.ts @@ -10,9 +10,8 @@ import { FastModeRadio, SlowModeRadio, } from '../locators/onboarding'; -import { RecoveryPhraseContainer, RevealRecoveryPhraseButton } from '../locators/settings'; +import { RecoveryPasswordMenuItem, RecoveryPhraseContainer } from '../locators/settings'; import { UserSettings } from '../locators/settings'; -import { CopyButton } from '../locators/start_conversation'; import { handleBackgroundPermissions, handleNotificationPermissions } from './permissions'; export type BaseSetupOptions = { @@ -70,18 +69,17 @@ export async function newUser( } // Open recovery phrase modal and save recovery phrase - await device.waitForTextElementToBePresent(new RevealRecoveryPhraseButton(device)); - await device.clickOnElementAll(new RevealRecoveryPhraseButton(device)); + await device.clickOnElementAll(new UserSettings(device)); + await device.onIOS().scrollDown(); + await device.clickOnElementAll(new RecoveryPasswordMenuItem(device)); const recoveryPhraseContainer = await device.clickOnElementAll( new RecoveryPhraseContainer(device) ); - await device.onAndroid().clickOnElementAll(new CopyButton(device)); const recoveryPhrase = await device.getTextFromElement(recoveryPhraseContainer); device.log(`${userName}s recovery phrase is "${recoveryPhrase}"`); await device.navigateBack(false); - + await device.scrollUp(); // Get Account ID from User Settings - await device.clickOnElementAll(new UserSettings(device)); const el = await device.waitForTextElementToBePresent(new AccountIDDisplay(device)); const accountID = await device.getTextFromElement(el); await device.clickOnElementAll(new CloseSettings(device)); From f052e5ba97f6d8f5f008cd0459f2d6fddee27ce3 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Fri, 13 Feb 2026 10:19:24 +1100 Subject: [PATCH 06/38] fix: android 1.32.0 locator and flow changes --- run/test/locators/index.ts | 7 +++---- run/test/specs/review_positive.spec.ts | 9 ++------- run/types/DeviceWrapper.ts | 4 ---- run/types/testing.ts | 4 ++-- 4 files changed, 7 insertions(+), 17 deletions(-) diff --git a/run/test/locators/index.ts b/run/test/locators/index.ts index 33ace6b1b8..f59f39793f 100644 --- a/run/test/locators/index.ts +++ b/run/test/locators/index.ts @@ -51,8 +51,8 @@ export class BlockedContactsSettings extends LocatorsInterface { switch (this.platform) { case 'android': return { - strategy: 'accessibility id', - selector: 'qa-blocked-contacts-settings-item', + strategy: 'id', + selector: 'preferences-option-blocked-contacts', }; case 'ios': return { @@ -471,8 +471,7 @@ export class ReadReceiptsButton extends LocatorsInterface { case 'android': return { strategy: 'id', - selector: 'android:id/summary', - text: 'Show read receipts for all messages you send and receive.', + selector: 'preferences-option-read-receipt', } as const; case 'ios': return { diff --git a/run/test/specs/review_positive.spec.ts b/run/test/specs/review_positive.spec.ts index 86a8ff2687..b3344db272 100644 --- a/run/test/specs/review_positive.spec.ts +++ b/run/test/specs/review_positive.spec.ts @@ -28,11 +28,6 @@ bothPlatformsIt({ async function reviewPromptPositive(platform: SupportedPlatformsType, testInfo: TestInfo) { const storevariant = platform === 'android' ? 'Google Play Store' : 'App Store'; - // Platform specific string for the Rate Session modal - const rateModalDescriptionString = - platform === 'android' - ? tStripped('rateSessionModalDescription', { storevariant }) - : tStripped('rateSessionModalDescriptionUpdated', { storevariant }); const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); await newUser(device, USERNAME.ALICE, { saveUserData: false }); @@ -52,9 +47,9 @@ async function reviewPromptPositive(platform: SupportedPlatformsType, testInfo: await device.clickOnElementAll(new ReviewPromptItsGreatButton(device)); }); await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Rate Session'), async () => { - await device.checkModalStrings(tStripped('rateSession'), rateModalDescriptionString); + await device.checkModalStrings(tStripped('rateSession'), tStripped('rateSessionModalDescriptionUpdated', { storevariant })); await device.waitForTextElementToBePresent(new ReviewPromptRateAppButton(device)); - await device.onAndroid().waitForTextElementToBePresent(new ReviewPromptNotNowButton(device)); // On iOS the modal only has the Rate button + await device.verifyElementNotPresent(new ReviewPromptNotNowButton(device)); // This modal now only has the Rate button }); await test.step(TestSteps.SETUP.CLOSE_APP, async () => { await closeApp(device); diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index 84a3251991..ef1c067d36 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -2386,14 +2386,10 @@ export class DeviceWrapper { public async turnOnReadReceipts() { await this.navigateBack(); - await sleepFor(100); await this.clickOnElementAll(new UserSettings(this)); - await sleepFor(500); await this.clickOnElementAll(new PrivacyMenuItem(this)); - await sleepFor(2000); await this.clickOnElementAll(new ReadReceiptsButton(this)); await this.navigateBack(false); - await sleepFor(100); await this.clickOnElementAll(new CloseSettings(this)); } diff --git a/run/types/testing.ts b/run/types/testing.ts index b02be61ac4..f547a2efa5 100644 --- a/run/types/testing.ts +++ b/run/types/testing.ts @@ -358,7 +358,6 @@ export type AccessibilityId = | 'Pin' | 'Please enter a shorter group name' | 'Privacy Policy' - | 'qa-blocked-contacts-settings-item' | 'rate-app-button' | 'Read Receipts - Switch' | 'Recents' @@ -436,7 +435,6 @@ export type Id = | 'android:id/alertTitle' | 'android:id/button1' | 'android:id/content_preview_text' - | 'android:id/summary' | 'android:id/title' | 'android.widget.TextView' | 'Appearance' @@ -567,6 +565,8 @@ export type Id = | 'open-survey-button' | 'Open' | 'Open URL' + | 'preferences-option-blocked-contacts' + | 'preferences-option-read-receipt' | 'preferred-display-name' | 'Privacy' | 'Privacy policy button' From 954dbe6c9c35c91b616ae25576e55ec686081d25 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Fri, 13 Feb 2026 13:40:37 +1100 Subject: [PATCH 07/38] chore: more restructure/merge errors --- eslint.config.mjs | 1 + run/test/locators/settings.ts | 3 ++- run/test/specs/app_disguise_set.spec.ts | 7 ++++++- run/test/specs/cta_donate_time.spec.ts | 8 ++------ run/test/specs/review_positive.spec.ts | 5 ++++- .../user_actions_animated_profile_picture.spec.ts | 10 +++++----- run/test/utils/check_cta.ts | 2 +- run/test/utils/mock_pro.ts | 3 +-- run/types/DeviceWrapper.ts | 11 ++++------- 9 files changed, 26 insertions(+), 24 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 9c4e4dd0f2..19027f0387 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -42,6 +42,7 @@ export default defineConfig( rules: { 'no-unused-vars': 'off', // we have @typescript-eslint/no-unused-vars enabled below 'no-else-return': 'error', + 'preserve-caught-error': 'off', '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-explicit-any': 'off', diff --git a/run/test/locators/settings.ts b/run/test/locators/settings.ts index f9fa622f83..7761885bd8 100644 --- a/run/test/locators/settings.ts +++ b/run/test/locators/settings.ts @@ -209,7 +209,8 @@ export class RecoveryPasswordMenuItem extends LocatorsInterface { case 'android': return { strategy: '-android uiautomator', - selector: 'new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().resourceId("Recovery password menu item"))', + selector: + 'new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().resourceId("Recovery password menu item"))', } as const; case 'ios': return { diff --git a/run/test/specs/app_disguise_set.spec.ts b/run/test/specs/app_disguise_set.spec.ts index f9a32af507..c0abcbf5a1 100644 --- a/run/test/specs/app_disguise_set.spec.ts +++ b/run/test/specs/app_disguise_set.spec.ts @@ -14,7 +14,12 @@ import { } from '../locators/settings'; import { sleepFor } from '../utils'; import { newUser } from '../utils/create_account'; -import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType, uninstallApp } from '../utils/open_app'; +import { + closeApp, + openAppOnPlatformSingleDevice, + SupportedPlatformsType, + uninstallApp, +} from '../utils/open_app'; bothPlatformsItSeparate({ title: 'App disguise set icon', diff --git a/run/test/specs/cta_donate_time.spec.ts b/run/test/specs/cta_donate_time.spec.ts index 04fed44682..c7e1f46c29 100644 --- a/run/test/specs/cta_donate_time.spec.ts +++ b/run/test/specs/cta_donate_time.spec.ts @@ -4,13 +4,9 @@ import { TestSteps } from '../../types/allure'; import { iosIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; import { PlusButton } from '../locators/home'; -import { newUser } from '../utils/create_account'; import { IOSTestContext } from '../utils/capabilities_ios'; -import { - closeApp, - openAppOnPlatformSingleDevice, - SupportedPlatformsType, -} from '../utils/open_app'; +import { newUser } from '../utils/create_account'; +import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; import { setIOSFirstInstallDate } from '../utils/time_travel'; // iOS uses app-level time override (customFirstInstallDateTime capability). diff --git a/run/test/specs/review_positive.spec.ts b/run/test/specs/review_positive.spec.ts index b3344db272..8de5d64916 100644 --- a/run/test/specs/review_positive.spec.ts +++ b/run/test/specs/review_positive.spec.ts @@ -47,7 +47,10 @@ async function reviewPromptPositive(platform: SupportedPlatformsType, testInfo: await device.clickOnElementAll(new ReviewPromptItsGreatButton(device)); }); await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Rate Session'), async () => { - await device.checkModalStrings(tStripped('rateSession'), tStripped('rateSessionModalDescriptionUpdated', { storevariant })); + await device.checkModalStrings( + tStripped('rateSession'), + tStripped('rateSessionModalDescriptionUpdated', { storevariant }) + ); await device.waitForTextElementToBePresent(new ReviewPromptRateAppButton(device)); await device.verifyElementNotPresent(new ReviewPromptNotNowButton(device)); // This modal now only has the Rate button }); diff --git a/run/test/specs/user_actions_animated_profile_picture.spec.ts b/run/test/specs/user_actions_animated_profile_picture.spec.ts index f3de9f41b6..6f90f770ce 100644 --- a/run/test/specs/user_actions_animated_profile_picture.spec.ts +++ b/run/test/specs/user_actions_animated_profile_picture.spec.ts @@ -3,11 +3,11 @@ import { test, type TestInfo } from '@playwright/test'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; -import { PathMenuItem, UserAvatar } from './locators/settings'; -import { newUser } from './utils/create_account'; -import { makeAccountPro } from './utils/mock_pro'; -import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from './utils/open_app'; -import { forceStopAndRestart } from './utils/utilities'; +import { PathMenuItem, UserAvatar } from '../locators/settings'; +import { newUser } from '../utils/create_account'; +import { makeAccountPro } from '../utils/mock_pro'; +import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; +import { forceStopAndRestart } from '../utils/utilities'; bothPlatformsIt({ title: 'Upload animated profile picture (non Pro)', diff --git a/run/test/utils/check_cta.ts b/run/test/utils/check_cta.ts index a7f9cd7d43..96cc65665e 100644 --- a/run/test/utils/check_cta.ts +++ b/run/test/utils/check_cta.ts @@ -1,4 +1,4 @@ -import { tStripped } from '../../../localizer/lib'; +import { tStripped } from '../../localizer/lib'; export type CTAType = 'animatedProfilePicture' | 'donate' | 'longerMessages'; diff --git a/run/test/utils/mock_pro.ts b/run/test/utils/mock_pro.ts index 06e68a8588..32e8b12618 100644 --- a/run/test/utils/mock_pro.ts +++ b/run/test/utils/mock_pro.ts @@ -24,7 +24,7 @@ import { randomBytes } from 'crypto'; import { readFileSync } from 'fs'; import { join } from 'path'; -import { PRO_BACKEND_URL } from '../../../constants'; +import { PRO_BACKEND_URL } from '../../constants'; export type PaymentProvider = 'apple' | 'google'; @@ -326,7 +326,6 @@ export async function addProPayment( } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; if (attempt === maxAttempts) { - // eslint-disable-next-line preserve-caught-error throw new Error(`add_pro_payment failed after ${maxAttempts} attempts: ${msg}`); } console.log(`add_pro_payment attempt ${attempt}/${maxAttempts} failed: ${msg}, retrying...`); diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index ef1c067d36..5ae88bc04a 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -70,15 +70,12 @@ import { UserSettings, VersionNumber, } from '../test/locators/settings'; -import { - EnterAccountID, - NewMessageOption, - NextButton, -} from '../test/locators/start_conversation'; +import { EnterAccountID, NewMessageOption, NextButton } from '../test/locators/start_conversation'; import { clickOnCoordinates, sleepFor } from '../test/utils'; import { getAdbFullPath } from '../test/utils/binaries'; import { parseDataImage } from '../test/utils/check_colour'; import { isSameColor } from '../test/utils/check_colour'; +import { CTAConfig, ctaConfigs, CTAType } from '../test/utils/check_cta'; import { SupportedPlatformsType } from '../test/utils/open_app'; import { isDeviceAndroid, isDeviceIOS, runScriptAndLog } from '../test/utils/utilities'; import { @@ -2607,8 +2604,8 @@ export class DeviceWrapper { const pixelColor = await parseDataImage(base64image); return pixelColor; } - // Sample an element's centre pixel color SAMPLE_SIZE times to determine whether it is animated or not. - // If the set contains more than 1 color it is likely animated. + // Sample an element's centre pixel color SAMPLE_SIZE times to determine whether it is animated or not. + // If the set contains more than 1 color it is likely animated. public async verifyElementIsAnimated(args: LocatorsInterface): Promise { const SAMPLE_SIZE = 3; const colors = new Set(); From 3eb2c86fa42bb36f4f2aab7a52737f57cde15774 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Fri, 13 Feb 2026 15:51:30 +1100 Subject: [PATCH 08/38] Merge remote-tracking branch 'origin/dev' into feat/pro --- run/test/{specs => }/media/animated_profile_picture.gif | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename run/test/{specs => }/media/animated_profile_picture.gif (100%) diff --git a/run/test/specs/media/animated_profile_picture.gif b/run/test/media/animated_profile_picture.gif similarity index 100% rename from run/test/specs/media/animated_profile_picture.gif rename to run/test/media/animated_profile_picture.gif From e347841ba22d762dd6b29147ceed5d4b1f86c920 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Fri, 13 Feb 2026 16:48:30 +1100 Subject: [PATCH 09/38] feat: expand char count tests to cover pro --- run/test/specs/message_length.spec.ts | 96 +++++++++++++++++++++------ run/test/utils/mock_pro.ts | 2 +- run/types/DeviceWrapper.ts | 21 +++++- 3 files changed, 94 insertions(+), 25 deletions(-) diff --git a/run/test/specs/message_length.spec.ts b/run/test/specs/message_length.spec.ts index 7d720d9e3f..81595d5888 100644 --- a/run/test/specs/message_length.spec.ts +++ b/run/test/specs/message_length.spec.ts @@ -14,46 +14,97 @@ import { import { CTAButtonNegative } from '../locators/global'; import { PlusButton } from '../locators/home'; import { EnterAccountID, NewMessageOption, NextButton } from '../locators/start_conversation'; +import { IOSTestContext } from '../utils/capabilities_ios'; import { newUser } from '../utils/create_account'; +import { makeAccountPro } from '../utils/mock_pro'; import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; +import { forceStopAndRestart } from '../utils/utilities'; -const maxChars = 2000; -const countdownThreshold = 1800; +const STANDARD_MAX_CHARS = 2000; +const PRO_MAX_CHARS = 10000; +const COUNTDOWN_START_THRESHOLD = 200; const messageLengthTestCases = [ { + pro: false, length: 1799, - char: 'a', shouldSend: true, description: 'no countdown shows, message sends', }, - { length: 1800, char: 'b', shouldSend: true, description: 'countdown shows 200, message sends' }, - { length: 2000, char: 'c', shouldSend: true, description: 'countdown shows 0, message sends' }, { + pro: false, + length: 1800, + shouldSend: true, + description: 'countdown shows 200, message sends', + }, + { + pro: false, + length: 2000, + shouldSend: true, + description: 'countdown shows 0, message sends', + }, + { + pro: false, length: 2001, - char: 'd', + shouldSend: false, + description: 'countdown shows -1, cannot send message', + }, + { + pro: true, + length: 9799, + shouldSend: true, + description: 'no countdown shows, message sends', + }, + { + pro: true, + length: 9800, + shouldSend: true, + description: 'countdown shows 200, message sends', + }, + { + pro: true, + length: 10000, + shouldSend: true, + description: 'countdown shows 0, message sends', + }, + { + pro: true, + length: 10001, shouldSend: false, description: 'countdown shows -1, cannot send message', }, ]; for (const testCase of messageLengthTestCases) { + const proSuffix = testCase.pro ? `Pro` : `non Pro`; bothPlatformsIt({ - title: `Message length limit (${testCase.length} chars)`, + title: `Message length limit (${testCase.length} chars ${proSuffix})`, risk: 'high', countOfDevicesNeeded: 1, allureSuites: { parent: 'Sending Messages', suite: 'Rules', }, - allureDescription: `Verifies message length behavior at ${testCase.length} characters - ${testCase.description}`, + allureDescription: `Verifies message length behavior at ${testCase.length} characters - ${testCase.description} (${proSuffix})`, testCb: async (platform: SupportedPlatformsType, testInfo: TestInfo) => { + const iosContext: IOSTestContext = { + sessionProEnabled: 'true', + }; const { device, alice } = await test.step(TestSteps.SETUP.NEW_USER, async () => { - const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo, iosContext); const alice = await newUser(device, USERNAME.ALICE); return { device, alice }; }); + if (testCase.pro) { + const paymentProvider = platform === 'ios' ? 'apple' : 'google'; + await makeAccountPro({ + mnemonic: alice.recoveryPhrase, + provider: paymentProvider, + }); + await forceStopAndRestart(device); + } + // Send message to self to bring up Note to Self conversation await test.step(TestSteps.OPEN.NTS, async () => { await device.clickOnElementAll(new PlusButton(device)); @@ -64,12 +115,15 @@ for (const testCase of messageLengthTestCases) { }); await test.step(`Type ${testCase.length} chars, check countdown`, async () => { + const expectedMax = testCase.pro ? PRO_MAX_CHARS : STANDARD_MAX_CHARS; const expectedCount = - testCase.length < countdownThreshold ? null : (maxChars - testCase.length).toString(); + testCase.length < expectedMax - COUNTDOWN_START_THRESHOLD + ? null + : (expectedMax - testCase.length).toString(); // Construct the string of desired length - const message = testCase.char.repeat(testCase.length); - await device.inputText(message, new MessageInput(device)); + const message = 'x'.repeat(testCase.length); + await device.inputText(message, new MessageInput(device), true); // Does the countdown appear? if (expectedCount) { @@ -85,21 +139,19 @@ for (const testCase of messageLengthTestCases) { // Is the message short enough to send? if (testCase.shouldSend) { await device.waitForTextElementToBePresent(new MessageBody(device, message)); - } else if (platform === 'ios') { - // iOS: Modal appears, verify and dismiss + } else if (!testCase.pro) { + // For Non Pro, a CTA appears + await device.checkCTA('longerMessages'); + await device.clickOnElementAll(new CTAButtonNegative(device)); + await device.verifyElementNotPresent(new MessageBody(device, message)); + } else if (testCase.pro) { + // For Pro, a normal message length dialog appears await device.checkModalStrings( tStripped('modalMessageTooLongTitle'), - tStripped('modalMessageTooLongDescription', { limit: maxChars.toString() }) + tStripped('modalMessageTooLongDescription', { limit: expectedMax.toString() }) ); await device.clickOnElementAll(new MessageLengthOkayButton(device)); await device.verifyElementNotPresent(new MessageBody(device, message)); - } else { - // Android: CTA appears, verify and dismiss - // Post-Pro is active on debug/qa builds by default - // This will be the default for both platforms once Pro is live - await device.checkCTA('longerMessages'); - await device.clickOnElementAll(new CTAButtonNegative(device)); - await device.verifyElementNotPresent(new MessageBody(device, message)); } }); diff --git a/run/test/utils/mock_pro.ts b/run/test/utils/mock_pro.ts index 32e8b12618..4a1d6833d1 100644 --- a/run/test/utils/mock_pro.ts +++ b/run/test/utils/mock_pro.ts @@ -69,7 +69,7 @@ function getWordlist(): string[] { return WORDLIST_CACHE; } - const wordlistPath = join(__dirname, '../../../../english_wordlist.txt'); + const wordlistPath = join(__dirname, '../../../english_wordlist.txt'); const content = readFileSync(wordlistPath, 'utf-8'); const words = content .split('\n') diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index f93cf31842..e11d0d0e9d 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -1833,7 +1833,8 @@ export class DeviceWrapper { public async inputText( textToInput: string, - args: LocatorsInterface | ({ maxWait?: number } & StrategyExtractionObj) + args: LocatorsInterface | ({ maxWait?: number } & StrategyExtractionObj), + paste: boolean = false ) { const locator = args instanceof LocatorsInterface ? args.build() : args; @@ -1844,7 +1845,23 @@ export class DeviceWrapper { throw new Error(`inputText: Did not find element with locator: ${JSON.stringify(locator)}`); } - await this.setValueImmediate(textToInput, el.ELEMENT); + if (paste) { + await this.click(el.ELEMENT); + if (this.isAndroid()) { + await this.toAndroid().setClipboard( + Buffer.from(textToInput).toString('base64'), + 'plaintext' + ); + // KEYCODE_PASTE = 279 + await this.toAndroid().pressKeyCode(279); + } else { + await this.toIOS().mobileSetPasteboard(textToInput, 'utf8'); + // XCUIKeyModifierCommand = 1 << 4 = 16 + await this.toIOS().mobileKeys([{ key: 'v', modifierFlags: 16 }]); + } + } else { + await this.setValueImmediate(textToInput, el.ELEMENT); + } } public async getAttribute(attribute: string, elementId: string) { From 99ab2c11179881b815a25c42f54dfe18a2ea04b0 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Mon, 16 Feb 2026 10:15:38 +1100 Subject: [PATCH 10/38] refactor: remove dead code --- appium_next.d.ts | 16 ---------------- run/localizer/englishStrippedStr.ts | 6 ------ run/types/tuple.d.ts | 9 --------- 3 files changed, 31 deletions(-) delete mode 100644 appium_next.d.ts delete mode 100644 run/localizer/englishStrippedStr.ts delete mode 100644 run/types/tuple.d.ts diff --git a/appium_next.d.ts b/appium_next.d.ts deleted file mode 100644 index bed033dc9e..0000000000 --- a/appium_next.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* eslint-disable @typescript-eslint/no-empty-object-type */ -import { ExternalDriver } from '@appium/types/build/lib/driver'; - -// typings comes from : -// node_modules/@appium/types/build/lib/driver.d.ts -// BUT. they are defined as optional, so here we just copy and paste the one we need, and hardcode the fact that they are defined. -// We need to do this, because the iosDriver and the androidDriver do not export the typings (where they defined that those function exists) - -export interface MightBeUndefinedDeviceType extends ExternalDriver {} - -export type AppiumNextDeviceType = { - pushFile(remotePath: string, payloadBase64: string): Promise; - - // not sure at all - touchMove(x: number, y: number): Promise; -}; diff --git a/run/localizer/englishStrippedStr.ts b/run/localizer/englishStrippedStr.ts deleted file mode 100644 index e90253adac..0000000000 --- a/run/localizer/englishStrippedStr.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { LocalizedStringBuilder, MergedLocalizerTokens } from "./lib"; - -export function englishStrippedStr(token: T) { - const builder = new LocalizedStringBuilder(token).stripIt().forceEnglish(); - return builder; -} diff --git a/run/types/tuple.d.ts b/run/types/tuple.d.ts deleted file mode 100644 index 8455b68740..0000000000 --- a/run/types/tuple.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -type TupleOf> = R['length'] extends N - ? R - : TupleOf; - -export type Tuple = N extends N - ? number extends N - ? Array - : TupleOf - : never; From d783973bef1f324f59e414f6ad0a72ccfa4c9ea0 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Mon, 16 Feb 2026 11:08:27 +1100 Subject: [PATCH 11/38] fix: more Android 1.32.0 fixes --- run/test/locators/settings.ts | 17 +++++++++ run/test/specs/slow_mode_background.spec.ts | 16 +++++++-- run/test/specs/voice_calls.spec.ts | 9 ++--- run/test/utils/mock_pro.ts | 39 ++++++++------------- run/types/DeviceWrapper.ts | 10 ++++++ run/types/testing.ts | 2 ++ 6 files changed, 62 insertions(+), 31 deletions(-) diff --git a/run/test/locators/settings.ts b/run/test/locators/settings.ts index 7761885bd8..c95e60a859 100644 --- a/run/test/locators/settings.ts +++ b/run/test/locators/settings.ts @@ -137,6 +137,23 @@ export class DonationsMenuItem extends LocatorsInterface { } } +export class EnableVoiceCalls extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'preferences-dialog-option-enable', + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Continue', + } as const; + } + } +} + export class HideRecoveryPasswordButton extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { diff --git a/run/test/specs/slow_mode_background.spec.ts b/run/test/specs/slow_mode_background.spec.ts index 62cff9a222..9d1a331f17 100644 --- a/run/test/specs/slow_mode_background.spec.ts +++ b/run/test/specs/slow_mode_background.spec.ts @@ -1,10 +1,11 @@ -import test, { TestInfo } from '@playwright/test'; +import { test, TestInfo } from '@playwright/test'; import { tStripped } from '../../localizer/lib'; import { TestSteps } from '../../types/allure'; import { androidIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; import { BackgroundPermsAllowButton } from '../locators/home'; +import { NotificationsMenuItem, UserSettings } from '../locators/settings'; import { newUser } from '../utils/create_account'; import { closeApp, @@ -48,7 +49,18 @@ async function slowModeBackgroundModal(platform: SupportedPlatformsType, testInf text: 'Allow', }); }); - // The test ends here since there is no good way to verify that the specific toggle is ON. + await test.step('Verify Background usage toggle is turned ON', async () => { + await device.clickOnElementAll(new UserSettings(device)); + await device.clickOnElementAll(new NotificationsMenuItem(device)); + await device.assertAttribute( + { + strategy: 'id', + selector: 'preferences-option-whitelist-toggle', + }, + 'checked', + 'true' + ); + }); } finally { // App must be uninstalled to prevent state pollution (background permission is tied to app install) await test.step(TestSteps.SETUP.CLOSE_APP, async () => { diff --git a/run/test/specs/voice_calls.spec.ts b/run/test/specs/voice_calls.spec.ts index 49925f26a5..d2807810e4 100644 --- a/run/test/specs/voice_calls.spec.ts +++ b/run/test/specs/voice_calls.spec.ts @@ -5,6 +5,7 @@ import { TestSteps } from '../../types/allure'; import { bothPlatformsItSeparate } from '../../types/sessionIt'; import { CloseSettings } from '../locators'; import { CallButton, NotificationsModalButton, NotificationSwitch } from '../locators/conversation'; +import { EnableVoiceCalls } from '../locators/settings'; import { open_Alice1_Bob1_friends } from '../state_builder'; import { sleepFor } from '../utils/index'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; @@ -58,7 +59,7 @@ async function voiceCallIos(platform: SupportedPlatformsType, testInfo: TestInfo tStripped('callsVoiceAndVideoModalDescription') ); }); - await alice1.clickOnByAccessibilityID('Continue'); + await alice1.clickOnElementAll(new EnableVoiceCalls(alice1)); // Need to allow microphone access await alice1.modalPopup({ strategy: 'accessibility id', selector: 'Allow' }); await sleepFor(1_000); @@ -103,7 +104,7 @@ async function voiceCallIos(platform: SupportedPlatformsType, testInfo: TestInfo tStripped('callsVoiceAndVideoBeta'), tStripped('callsVoiceAndVideoModalDescription') ); - await bob1.clickOnByAccessibilityID('Continue'); + await bob1.clickOnElementAll(new EnableVoiceCalls(bob1)); // Need to allow microphone access await bob1.modalPopup({ strategy: 'accessibility id', selector: 'Allow' }); await sleepFor(1_000); @@ -178,7 +179,7 @@ async function voiceCallAndroid(platform: SupportedPlatformsType, testInfo: Test tStripped('callsVoiceAndVideoBeta'), tStripped('callsVoiceAndVideoModalDescription') ); - await alice1.clickOnByAccessibilityID('Enable'); + await alice1.clickOnElementAll(new EnableVoiceCalls(alice1)); }); await alice1.clickOnElementById( 'com.android.permissioncontroller:id/permission_allow_foreground_only_button' @@ -231,7 +232,7 @@ async function voiceCallAndroid(platform: SupportedPlatformsType, testInfo: Test strategy: 'accessibility id', selector: 'Settings', }); - await bob1.clickOnByAccessibilityID('Enable'); + await bob1.clickOnElementAll(new EnableVoiceCalls(bob1)); await bob1.clickOnElementById( 'com.android.permissioncontroller:id/permission_allow_foreground_only_button' ); diff --git a/run/test/utils/mock_pro.ts b/run/test/utils/mock_pro.ts index 4a1d6833d1..9a612a4197 100644 --- a/run/test/utils/mock_pro.ts +++ b/run/test/utils/mock_pro.ts @@ -26,15 +26,15 @@ import { join } from 'path'; import { PRO_BACKEND_URL } from '../../constants'; -export type PaymentProvider = 'apple' | 'google'; +type PaymentProvider = 'apple' | 'google'; -export interface MakeAccountProParams { +type MakeAccountProParams = { mnemonic: string; provider: PaymentProvider; dryRun?: boolean; // If true, build and print the request but don't send it -} +}; -export interface AddProPaymentRequest { +type AddProPaymentRequest = { version: number; master_pkey: string; rotating_pkey: string; @@ -46,21 +46,21 @@ export interface AddProPaymentRequest { google_order_id?: string; apple_tx_id?: string; }; -} +}; -export interface ProProof { +type ProProof = { version: number; expiry_unix_ts_ms: number; gen_index_hash: string; rotating_pkey: string; sig: string; -} +}; -export interface AddProPaymentResponse { +type AddProPaymentResponse = { status: number; result?: ProProof; errors?: string[]; -} +}; let WORDLIST_CACHE: string[] | null = null; @@ -85,7 +85,7 @@ function getWordlist(): string[] { } // Decodes a 13-word recovery phrase a 16-byte seed hex string. */ -export function mnemonicToSeedHex(mnemonic: string): string { +function mnemonicToSeedHex(mnemonic: string): string { const wordlist = getWordlist(); const n = wordlist.length; // 1626 @@ -153,19 +153,8 @@ function padSeed(seedHex: string): Uint8Array { return padded; } -// Derives the account-level Ed25519 keypair by zero-padding the 16-byte seed to 32 bytes. -export function deriveAccountEd25519Keypair(seedHex: string): { - privateKey: Uint8Array; - publicKey: Uint8Array; -} { - const padded = padSeed(seedHex); - const privateKey = padded; - const publicKey = ed25519.getPublicKey(privateKey); - return { privateKey, publicKey }; -} - // Derives the Pro master keypair from the seed using Blake2b with "SessionProRandom" as the key. -export function deriveProMasterKey(seedHex: string): { +function deriveProMasterKey(seedHex: string): { privateKey: Uint8Array; publicKey: Uint8Array; } { @@ -184,7 +173,7 @@ export function deriveProMasterKey(seedHex: string): { } // Generates a random ephemeral rotating keypair for the payment request. -export function generateRotatingKey(): { privateKey: Uint8Array; publicKey: Uint8Array } { +function generateRotatingKey(): { privateKey: Uint8Array; publicKey: Uint8Array } { const privateKey = ed25519.utils.randomSecretKey(); const publicKey = ed25519.getPublicKey(privateKey); return { privateKey, publicKey }; @@ -236,7 +225,7 @@ function makeAddProPaymentHash( } // Builds a signed add_pro_payment request body with fake payment tokens. -export function buildAddProPaymentRequest( +function buildAddProPaymentRequest( masterKey: { privateKey: Uint8Array; publicKey: Uint8Array }, rotatingKey: { privateKey: Uint8Array; publicKey: Uint8Array }, provider: PaymentProvider @@ -293,7 +282,7 @@ export function buildAddProPaymentRequest( } // POSTs the payment request to the Pro backend with retries and timeout. -export async function addProPayment( +async function addProPayment( backendUrl: string, request: AddProPaymentRequest, { maxAttempts = 3, timeout = 10_000 } = {} diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index e11d0d0e9d..3847d71893 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -1868,6 +1868,16 @@ export class DeviceWrapper { return this.toShared().getAttribute(attribute, elementId); } + public async assertAttribute( + element: LocatorsInterface | StrategyExtractionObj, + attribute: string, + value: string + ) { + const el = await this.waitForTextElementToBePresent(element); + const received = await this.getAttribute(attribute, el.ELEMENT); + expect(received, 'Element attribute value mismatch').toBe(value); + } + public async disappearRadioButtonSelected( platform: SupportedPlatformsType, timeOption: DISAPPEARING_TIMES diff --git a/run/types/testing.ts b/run/types/testing.ts index f547a2efa5..2aec099f15 100644 --- a/run/types/testing.ts +++ b/run/types/testing.ts @@ -565,8 +565,10 @@ export type Id = | 'open-survey-button' | 'Open' | 'Open URL' + | 'preferences-dialog-option-enable' | 'preferences-option-blocked-contacts' | 'preferences-option-read-receipt' + | 'preferences-option-whitelist-toggle' | 'preferred-display-name' | 'Privacy' | 'Privacy policy button' From d379105c74483b57a0421bab6770c7be9a97c3d2 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Mon, 16 Feb 2026 12:32:45 +1100 Subject: [PATCH 12/38] feat: offset long click gesture --- run/test/specs/group_message_voice.spec.ts | 4 +- run/test/specs/message_voice.spec.ts | 4 +- run/types/DeviceWrapper.ts | 54 +++++++++++++++++----- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/run/test/specs/group_message_voice.spec.ts b/run/test/specs/group_message_voice.spec.ts index 028cd5ffdc..3cd346c330 100644 --- a/run/test/specs/group_message_voice.spec.ts +++ b/run/test/specs/group_message_voice.spec.ts @@ -40,7 +40,9 @@ async function sendVoiceMessageGroup(platform: SupportedPlatformsType, testInfo: device.waitForTextElementToBePresent(new VoiceMessage(device)) ) ); - await bob1.longPressMessage(new VoiceMessage(bob1)); + // The voice message long tap must be offset so that it doesn't tap the scrubber + // As this starts playback and does not open the long press menu + await bob1.longPressMessage(new VoiceMessage(bob1), { offset: { x: 0, y: 50 } }); await bob1.clickOnByAccessibilityID('Reply to message'); await sleepFor(500); // Let the UI settle before finding message input and typing await bob1.sendMessage(replyMessage); diff --git a/run/test/specs/message_voice.spec.ts b/run/test/specs/message_voice.spec.ts index e5802e79de..68164a123b 100644 --- a/run/test/specs/message_voice.spec.ts +++ b/run/test/specs/message_voice.spec.ts @@ -29,7 +29,9 @@ async function sendVoiceMessage(platform: SupportedPlatformsType, testInfo: Test await alice1.waitForTextElementToBePresent(new VoiceMessage(alice1)); await bob1.trustAttachments(alice.userName); await sleepFor(500); - await bob1.longPressMessage(new VoiceMessage(bob1)); + // The voice message long tap must be offset so that it doesn't tap the scrubber + // As this starts playback and does not open the long press menu + await bob1.longPressMessage(new VoiceMessage(bob1), { offset: { x: 0, y: 50 } }); await bob1.clickOnByAccessibilityID('Reply to message'); await sleepFor(500); // Let the UI settle before finding message input and typing await bob1.sendMessage(replyMessage); diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index 3847d71893..11b58adc3e 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -620,18 +620,47 @@ export class DeviceWrapper { } } - public async longClick(element: AppiumNextElementType, durationMs: number) { + // Appium taps elements in their center but sometimes that is not desirable + // The native methods apply the tap offset from the top left corner + // For a more intuitive offset calculation, this method allows us to + // define offsets based on the element center + private async calculateGestureOffset( + element: AppiumNextElementType, + offset: Coordinates + ): Promise { + const rect = await this.getElementRect(element.ELEMENT); + if (!rect) { + throw new Error('Failed to resolve element rect for offset calculation'); + } + const { width, height } = rect; + const centerX = Math.round(width / 2); + const centerY = Math.round(height / 2); + // Clamp offset to element bounds + const x = Math.min(Math.max(centerX + offset.x, 0), rect.width); + const y = Math.min(Math.max(centerY + offset.y, 0), rect.height); + return { x, y }; + } + + /** + * @param offset Pixel offset from the element center. + * If an offset is necessary, both x and y must be defined, otherwise Appium doesn't apply the offset parameter. + */ + public async longClick(element: AppiumNextElementType, durationMs: number, offset?: Coordinates) { + let xOffset: number | undefined; + let yOffset: number | undefined; + + if (offset) { + const offsetCoordinates = await this.calculateGestureOffset(element, offset); + xOffset = offsetCoordinates.x; + yOffset = offsetCoordinates.y; + } + if (this.isIOS()) { // iOS takes a number in seconds const duration = Math.floor(durationMs / 1000); - return this.toIOS().mobileTouchAndHold(duration, undefined, undefined, element.ELEMENT); + return this.toIOS().mobileTouchAndHold(duration, xOffset, yOffset, element.ELEMENT); } - return this.toAndroid().mobileLongClickGesture( - element.ELEMENT, - undefined, - undefined, - durationMs - ); + return this.toAndroid().mobileLongClickGesture(element.ELEMENT, xOffset, yOffset, durationMs); } public async clickOnByAccessibilityID( @@ -745,7 +774,8 @@ export class DeviceWrapper { * @throws if message not found or context menu fails to appear within maxWait */ public async longPressMessage( - args: { text?: string; maxWait?: number } & (LocatorsInterface | StrategyExtractionObj) + args: { text?: string; maxWait?: number } & (LocatorsInterface | StrategyExtractionObj), + options?: { offset?: Coordinates } ): Promise { const { text, maxWait = 10_000 } = args; const locator = args instanceof LocatorsInterface ? args.build() : args; @@ -768,9 +798,11 @@ export class DeviceWrapper { if (!el) { return { success: false, error: `Message not found: ${displayText}` }; } - + if (options?.offset) { + console.log(`Offsetting long press by x=${options?.offset?.x}, y=${options?.offset?.y}`); + } // Attempt long click - await this.longClick(el, 2000); + await this.longClick(el, 2000, options?.offset); // Check if context menu appeared const longPressSuccess = await this.waitForTextElementToBePresent({ From 48d290766dc7e68e9db5da32abce4c3391b7f481 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Mon, 16 Feb 2026 14:30:31 +1100 Subject: [PATCH 13/38] fix: remove duplicate definition --- run/types/DeviceWrapper.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index 11b58adc3e..28f7b7360c 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -80,6 +80,7 @@ import { SupportedPlatformsType } from '../test/utils/open_app'; import { isDeviceAndroid, isDeviceIOS, runScriptAndLog } from '../test/utils/utilities'; import { AccessibilityId, + Coordinates, DISAPPEARING_TIMES, Group, Id, @@ -90,10 +91,6 @@ import { XPath, } from './testing'; -export type Coordinates = { - x: number; - y: number; -}; export type ActionSequence = { actions: string; }; @@ -799,7 +796,7 @@ export class DeviceWrapper { return { success: false, error: `Message not found: ${displayText}` }; } if (options?.offset) { - console.log(`Offsetting long press by x=${options?.offset?.x}, y=${options?.offset?.y}`); + this.log(`Offsetting long press by x=${options?.offset?.x}, y=${options?.offset?.y}`); } // Attempt long click await this.longClick(el, 2000, options?.offset); From b810e872bd0ebf45499d412ddcd3c8336fe10c01 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Tue, 17 Feb 2026 09:40:18 +1100 Subject: [PATCH 14/38] fix: dismiss CTA if present --- run/test/specs/message_length.spec.ts | 2 ++ run/test/utils/utilities.ts | 3 +++ run/types/DeviceWrapper.ts | 14 +++++++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/run/test/specs/message_length.spec.ts b/run/test/specs/message_length.spec.ts index 81595d5888..21782420fe 100644 --- a/run/test/specs/message_length.spec.ts +++ b/run/test/specs/message_length.spec.ts @@ -102,7 +102,9 @@ for (const testCase of messageLengthTestCases) { mnemonic: alice.recoveryPhrase, provider: paymentProvider, }); + // Restart to notify app of Pro status change await forceStopAndRestart(device); + await device.dismissCTA(); } // Send message to self to bring up Note to Self conversation diff --git a/run/test/utils/utilities.ts b/run/test/utils/utilities.ts index e0461f1d70..c21434f910 100644 --- a/run/test/utils/utilities.ts +++ b/run/test/utils/utilities.ts @@ -5,6 +5,7 @@ import path from 'path'; import * as util from 'util'; import { DeviceWrapper } from '../../types/DeviceWrapper'; +import { PlusButton } from '../locators/home'; import { androidAppActivity, androidAppPackage } from './capabilities_android'; import { iOSBundleId } from './capabilities_ios'; import { sleepFor } from './sleep_for'; @@ -146,4 +147,6 @@ export async function forceStopAndRestart(device: DeviceWrapper): Promise await runScriptAndLog(`xcrun simctl launch ${device.udid} ${iOSBundleId}`, true); await sleepFor(1_000); } + // Ensure we're on the home screen again + await device.waitForTextElementToBePresent(new PlusButton(device)); } diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index 28f7b7360c..6b1e579f04 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -639,7 +639,7 @@ export class DeviceWrapper { } /** - * @param offset Pixel offset from the element center. + * @param offset Pixel offset from the element center. * If an offset is necessary, both x and y must be defined, otherwise Appium doesn't apply the offset parameter. */ public async longClick(element: AppiumNextElementType, durationMs: number, offset?: Coordinates) { @@ -2658,6 +2658,18 @@ export class DeviceWrapper { ]); } + // Dismiss any CTA if it shows + public async dismissCTA(): Promise { + const hasCTAAppeared = await this.doesElementExist({ + ...new CTAButtonNegative(this).build(), + maxWait: 8_000, + }); + if (hasCTAAppeared) { + this.log('Dismissing CTA'); + await this.clickOnElementAll(new CTAButtonNegative(this)); + } + } + public async getElementPixelColor(args: LocatorsInterface): Promise { // Wait for the element to be present const element = await this.waitForTextElementToBePresent(args); From 6d858b6e68de69ec7f7a02b2a8b3b8bcabc57907 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Tue, 17 Feb 2026 10:05:38 +1100 Subject: [PATCH 15/38] fix: change locators in calls tests --- run/test/locators/settings.ts | 36 ++++++++++++------------ run/test/specs/disappearing_call.spec.ts | 7 +++-- run/test/specs/voice_calls.spec.ts | 16 +++++------ 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/run/test/locators/settings.ts b/run/test/locators/settings.ts index c95e60a859..a1f6250771 100644 --- a/run/test/locators/settings.ts +++ b/run/test/locators/settings.ts @@ -137,23 +137,6 @@ export class DonationsMenuItem extends LocatorsInterface { } } -export class EnableVoiceCalls extends LocatorsInterface { - public build() { - switch (this.platform) { - case 'android': - return { - strategy: 'id', - selector: 'preferences-dialog-option-enable', - } as const; - case 'ios': - return { - strategy: 'accessibility id', - selector: 'Continue', - } as const; - } - } -} - export class HideRecoveryPasswordButton extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { @@ -170,6 +153,7 @@ export class HideRecoveryPasswordButton extends LocatorsInterface { } } } + export class NotificationsMenuItem extends LocatorsInterface { public build() { switch (this.platform) { @@ -187,7 +171,6 @@ export class NotificationsMenuItem extends LocatorsInterface { } } } - export class PathMenuItem extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { @@ -288,6 +271,7 @@ export class SaveNameChangeButton extends LocatorsInterface { } } } + export class SaveProfilePictureButton extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { @@ -321,6 +305,22 @@ export class SelectAppIcon extends LocatorsInterface { } } } +export class SettingsModalsEnableButton extends LocatorsInterface { + public build() { + switch (this.platform) { + case 'android': + return { + strategy: 'id', + selector: 'preferences-dialog-option-enable', + } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'Continue', + } as const; + } + } +} export class UserAvatar extends LocatorsInterface { public build() { diff --git a/run/test/specs/disappearing_call.spec.ts b/run/test/specs/disappearing_call.spec.ts index e7c16ac421..ceba5b8d02 100644 --- a/run/test/specs/disappearing_call.spec.ts +++ b/run/test/specs/disappearing_call.spec.ts @@ -5,7 +5,8 @@ import { TestSteps } from '../../types/allure'; import { bothPlatformsItSeparate } from '../../types/sessionIt'; import { DISAPPEARING_TIMES } from '../../types/testing'; import { CloseSettings } from '../locators'; -import { CallButton, NotificationsModalButton, NotificationSwitch } from '../locators/conversation'; +import { CallButton, NotificationSwitch } from '../locators/conversation'; +import { SettingsModalsEnableButton } from '../locators/settings'; import { open_Alice1_Bob1_friends } from '../state_builder'; import { sleepFor } from '../utils'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; @@ -133,11 +134,11 @@ async function disappearingCallMessage1o1Android( strategy: 'accessibility id', selector: 'Settings', }); - await alice1.clickOnByAccessibilityID('Enable'); + await alice1.clickOnElementAll(new SettingsModalsEnableButton(alice1)); await alice1.clickOnElementById( 'com.android.permissioncontroller:id/permission_allow_foreground_only_button' ); - await alice1.clickOnElementAll(new NotificationsModalButton(alice1)); + await alice1.clickOnElementAll(new SettingsModalsEnableButton(alice1)); await alice1.clickOnElementAll(new NotificationSwitch(alice1)); // Return to conversation await alice1.navigateBack(false); diff --git a/run/test/specs/voice_calls.spec.ts b/run/test/specs/voice_calls.spec.ts index d2807810e4..c71789ccf9 100644 --- a/run/test/specs/voice_calls.spec.ts +++ b/run/test/specs/voice_calls.spec.ts @@ -4,8 +4,8 @@ import { tStripped } from '../../localizer/lib'; import { TestSteps } from '../../types/allure'; import { bothPlatformsItSeparate } from '../../types/sessionIt'; import { CloseSettings } from '../locators'; -import { CallButton, NotificationsModalButton, NotificationSwitch } from '../locators/conversation'; -import { EnableVoiceCalls } from '../locators/settings'; +import { CallButton, NotificationSwitch } from '../locators/conversation'; +import { SettingsModalsEnableButton } from '../locators/settings'; import { open_Alice1_Bob1_friends } from '../state_builder'; import { sleepFor } from '../utils/index'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; @@ -59,7 +59,7 @@ async function voiceCallIos(platform: SupportedPlatformsType, testInfo: TestInfo tStripped('callsVoiceAndVideoModalDescription') ); }); - await alice1.clickOnElementAll(new EnableVoiceCalls(alice1)); + await alice1.clickOnElementAll(new SettingsModalsEnableButton(alice1)); // Need to allow microphone access await alice1.modalPopup({ strategy: 'accessibility id', selector: 'Allow' }); await sleepFor(1_000); @@ -104,7 +104,7 @@ async function voiceCallIos(platform: SupportedPlatformsType, testInfo: TestInfo tStripped('callsVoiceAndVideoBeta'), tStripped('callsVoiceAndVideoModalDescription') ); - await bob1.clickOnElementAll(new EnableVoiceCalls(bob1)); + await bob1.clickOnElementAll(new SettingsModalsEnableButton(bob1)); // Need to allow microphone access await bob1.modalPopup({ strategy: 'accessibility id', selector: 'Allow' }); await sleepFor(1_000); @@ -179,7 +179,7 @@ async function voiceCallAndroid(platform: SupportedPlatformsType, testInfo: Test tStripped('callsVoiceAndVideoBeta'), tStripped('callsVoiceAndVideoModalDescription') ); - await alice1.clickOnElementAll(new EnableVoiceCalls(alice1)); + await alice1.clickOnElementAll(new SettingsModalsEnableButton(alice1)); }); await alice1.clickOnElementById( 'com.android.permissioncontroller:id/permission_allow_foreground_only_button' @@ -189,7 +189,7 @@ async function voiceCallAndroid(platform: SupportedPlatformsType, testInfo: Test tStripped('sessionNotifications'), tStripped('callsNotificationsRequired') ); - await alice1.clickOnElementAll(new NotificationsModalButton(alice1)); + await alice1.clickOnElementAll(new SettingsModalsEnableButton(alice1)); await alice1.clickOnElementAll(new NotificationSwitch(alice1)); }); await alice1.navigateBack(false); @@ -232,11 +232,11 @@ async function voiceCallAndroid(platform: SupportedPlatformsType, testInfo: Test strategy: 'accessibility id', selector: 'Settings', }); - await bob1.clickOnElementAll(new EnableVoiceCalls(bob1)); + await bob1.clickOnElementAll(new SettingsModalsEnableButton(bob1)); await bob1.clickOnElementById( 'com.android.permissioncontroller:id/permission_allow_foreground_only_button' ); - await bob1.clickOnElementAll(new NotificationsModalButton(bob1)); + await bob1.clickOnElementAll(new SettingsModalsEnableButton(bob1)); await bob1.clickOnElementAll(new NotificationSwitch(bob1)); await bob1.navigateBack(false); await bob1.navigateBack(false); From 17822d1116174392a2b341796d7e19e51e1bea70 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Tue, 17 Feb 2026 10:55:49 +1100 Subject: [PATCH 16/38] feat: add share in session test --- .../user_actions_share_to_session.spec.ts | 57 ++++++++++++++++++- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/run/test/specs/user_actions_share_to_session.spec.ts b/run/test/specs/user_actions_share_to_session.spec.ts index 952c18384e..75cbcfef4e 100644 --- a/run/test/specs/user_actions_share_to_session.spec.ts +++ b/run/test/specs/user_actions_share_to_session.spec.ts @@ -2,10 +2,16 @@ import { test, type TestInfo } from '@playwright/test'; import { testImage } from '../../constants/testfiles'; import { TestSteps } from '../../types/allure'; -import { bothPlatformsIt } from '../../types/sessionIt'; +import { androidIt, bothPlatformsIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; import { ImageName, ShareExtensionIcon } from '../locators'; -import { MessageBody, MessageInput, SendButton } from '../locators/conversation'; +import { + ConversationHeaderName, + MediaMessage, + MessageBody, + MessageInput, + SendButton, +} from '../locators/conversation'; import { PhotoLibrary } from '../locators/external'; import { Contact } from '../locators/global'; import { open_Alice1_Bob1_friends } from '../state_builder'; @@ -14,7 +20,7 @@ import { handlePhotosFirstTimeOpen } from '../utils/handle_first_open'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; bothPlatformsIt({ - title: 'Share to session', + title: 'Share to Session', risk: 'medium', testCb: shareToSession, countOfDevicesNeeded: 2, @@ -25,6 +31,19 @@ bothPlatformsIt({ allureDescription: `Verifies that a user can share an image from the photo gallery to Session`, }); +// On iOS the Share button just opens the regular share sheet, same as 'Share to Session' - no need to test separately. +androidIt({ + title: 'Share within Session', + risk: 'medium', + testCb: shareInSession, + countOfDevicesNeeded: 2, + allureSuites: { + parent: 'User Actions', + suite: 'Share to Session', + }, + allureDescription: `Verifies that a user can share an image from one Session conversation to another (forwarding)`, +}); + async function shareToSession(platform: SupportedPlatformsType, testInfo: TestInfo) { const { devices: { alice1, bob1 }, @@ -76,3 +95,35 @@ async function shareToSession(platform: SupportedPlatformsType, testInfo: TestIn await closeApp(alice1, bob1); }); } + +async function shareInSession(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { + devices: { alice1, bob1 }, + } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return open_Alice1_Bob1_friends({ + platform, + focusFriendsConvo: true, + testInfo, + }); + }); + const testMessage = 'Testing forwarding an image within Session'; + await test.step(TestSteps.SEND.IMAGE, async () => { + await alice1.sendImage(testMessage); + }); + await test.step('Share image to another Session conversation', async () => { + await alice1.clickOnElementAll(new MediaMessage(alice1)); + await alice1.clickOnElementAll({ strategy: 'accessibility id', selector: 'Share' }); + await alice1.clickOnElementAll(new Contact(alice1, 'Note to Self')); + await alice1.inputText(testMessage, new MessageInput(alice1)); + await alice1.clickOnElementAll(new SendButton(alice1)); + await alice1.waitForLoadingOnboarding(); + }); + await test.step(TestSteps.VERIFY.MESSAGE_RECEIVED, async () => { + await alice1.waitForTextElementToBePresent(new ConversationHeaderName(alice1, 'Note to Self')); + await alice1.waitForTextElementToBePresent(new MessageBody(alice1, testMessage)); + await alice1.matchAndTapImage(new MediaMessage(alice1).build(), testImage); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1); + }); +} From 5b35ddadc3b6ff0ea49da43fa5d4d935804dd150 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Wed, 18 Feb 2026 09:09:44 +1100 Subject: [PATCH 17/38] chore: update screenshots --- run/screenshots/android/cta_donate.png | 4 ++-- run/screenshots/android/landingpage_new_account.png | 4 ++-- run/screenshots/android/settings_conversations.png | 4 ++-- run/screenshots/android/settings_notifications.png | 4 ++-- run/screenshots/android/settings_privacy.png | 4 ++-- run/test/specs/user_actions_share_to_session.spec.ts | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/run/screenshots/android/cta_donate.png b/run/screenshots/android/cta_donate.png index 4902856ebe..704211b7c5 100644 --- a/run/screenshots/android/cta_donate.png +++ b/run/screenshots/android/cta_donate.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6b5561d790a9d73b1afbf9c61262ce89b5e1cba85cecb8f445cd9634876943e4 -size 1146466 +oid sha256:3975c78efa3c6c09a91fc78f8d82db5d4d48a2fe1c2f11c681f11bb4c03eef07 +size 1115997 diff --git a/run/screenshots/android/landingpage_new_account.png b/run/screenshots/android/landingpage_new_account.png index 276e5373b9..9a40983b42 100644 --- a/run/screenshots/android/landingpage_new_account.png +++ b/run/screenshots/android/landingpage_new_account.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6cceb03631b9144e157f1c068f5917f58042c567adea70f3a9d74a9a478d9f02 -size 136014 +oid sha256:f0dcb7982a90edb0ea6b5de654429c670073dff99be415d9499d33bcacfc6868 +size 101390 diff --git a/run/screenshots/android/settings_conversations.png b/run/screenshots/android/settings_conversations.png index 983c94eab7..45534e20b8 100644 --- a/run/screenshots/android/settings_conversations.png +++ b/run/screenshots/android/settings_conversations.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:183f7aac856c61eb5f46638fc25dbb1755215712752b50f721698f3301a232d8 -size 156289 +oid sha256:c92c63b479821ffda45eba99884e4bd2cfa6fbe77e8d188508c43d9de35dfbfa +size 151360 diff --git a/run/screenshots/android/settings_notifications.png b/run/screenshots/android/settings_notifications.png index 0d9ee91e38..6229c00a96 100644 --- a/run/screenshots/android/settings_notifications.png +++ b/run/screenshots/android/settings_notifications.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e26cf860dbfb83a797c7465c826098601d275d15a4c69ee2dfe083d790661bfb -size 154470 +oid sha256:aec60ca8cc003a4d5e906a0b4c6a28e2b31c64ebfb5a601c998d5c22d3727c18 +size 147698 diff --git a/run/screenshots/android/settings_privacy.png b/run/screenshots/android/settings_privacy.png index 90b58f6b96..7da23a6d85 100644 --- a/run/screenshots/android/settings_privacy.png +++ b/run/screenshots/android/settings_privacy.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e37c1174f4651cf2adb148517fe86ae36a6006bc50b71c9f2a1a329ac38f2940 -size 197778 +oid sha256:a464b633f442efc4a4515e9e6b52222de79b5a1ecc08c37966ab1df8eff1017d +size 209443 diff --git a/run/test/specs/user_actions_share_to_session.spec.ts b/run/test/specs/user_actions_share_to_session.spec.ts index 75cbcfef4e..e5d6af1e55 100644 --- a/run/test/specs/user_actions_share_to_session.spec.ts +++ b/run/test/specs/user_actions_share_to_session.spec.ts @@ -31,7 +31,7 @@ bothPlatformsIt({ allureDescription: `Verifies that a user can share an image from the photo gallery to Session`, }); -// On iOS the Share button just opens the regular share sheet, same as 'Share to Session' - no need to test separately. +// On iOS the Share button just opens the regular share sheet, same as 'Share to Session' - no need to test separately. androidIt({ title: 'Share within Session', risk: 'medium', @@ -121,7 +121,7 @@ async function shareInSession(platform: SupportedPlatformsType, testInfo: TestIn await test.step(TestSteps.VERIFY.MESSAGE_RECEIVED, async () => { await alice1.waitForTextElementToBePresent(new ConversationHeaderName(alice1, 'Note to Self')); await alice1.waitForTextElementToBePresent(new MessageBody(alice1, testMessage)); - await alice1.matchAndTapImage(new MediaMessage(alice1).build(), testImage); + await alice1.waitForTextElementToBePresent(new MediaMessage(alice1)); }); await test.step(TestSteps.SETUP.CLOSE_APP, async () => { await closeApp(alice1, bob1); From 346fbd2a3201d225cdd97c550428deb3b768ba0b Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Wed, 18 Feb 2026 10:59:37 +1100 Subject: [PATCH 18/38] fix: adjust pro tests for ios --- run/test/locators/global.ts | 28 +++--- run/test/locators/index.ts | 10 ++- run/test/specs/cta_donate_review.spec.ts | 6 +- run/test/specs/cta_donate_time.spec.ts | 2 +- ...r_actions_animated_profile_picture.spec.ts | 11 ++- run/test/utils/mock_pro.ts | 20 ++--- run/types/DeviceWrapper.ts | 88 +++++++++++++------ run/types/testing.ts | 7 ++ scripts/create_ios_simulators.ts | 2 +- 9 files changed, 116 insertions(+), 58 deletions(-) diff --git a/run/test/locators/global.ts b/run/test/locators/global.ts index 87c991c6e9..4edae485f1 100644 --- a/run/test/locators/global.ts +++ b/run/test/locators/global.ts @@ -100,7 +100,7 @@ export class CopyURLButton extends LocatorsInterface { } } -// NOTE: This is meant to be a generic locator for all CTAs but for the time being the iOS implementation is limited to the Donate CTA +// NOTE: iOS Pro CTAs use accessibility IDs, Donate CTA requires XPath fallback (see DeviceWrapper) // See SES-4930 export class CTABody extends LocatorsInterface { public build() { @@ -112,14 +112,14 @@ export class CTABody extends LocatorsInterface { } as const; case 'ios': return { - strategy: 'xpath', - selector: `//XCUIElementTypeStaticText[starts-with(@name,'Powerful forces are trying to')]`, + strategy: 'accessibility id', + selector: 'cta-body', } as const; } } } -// NOTE: This is meant to be a generic locator for all CTAs but for the time being the iOS implementation is limited to the Donate CTA +// NOTE: iOS Pro CTAs use accessibility IDs, Donate CTA requires XPath fallback (see DeviceWrapper) // See SES-4930 export class CTAButtonNegative extends LocatorsInterface { public build() { @@ -133,13 +133,13 @@ export class CTAButtonNegative extends LocatorsInterface { case 'ios': return { strategy: 'accessibility id', - selector: 'Maybe Later', + selector: 'cta-button-negative', } as const; } } } -// NOTE: This is meant to be a generic locator for all CTAs but for the time being the iOS implementation is limited to the Donate CTA +// NOTE: iOS Pro CTAs use accessibility IDs, Donate CTA requires XPath fallback (see DeviceWrapper) // See SES-4930 export class CTAButtonPositive extends LocatorsInterface { public build() { @@ -153,13 +153,13 @@ export class CTAButtonPositive extends LocatorsInterface { case 'ios': return { strategy: 'accessibility id', - selector: 'Donate', + selector: 'cta-button-positive', } as const; } } } -// NOTE: This is meant to be a generic locator for all CTAs but for the time being the iOS implementation is not available +// NOTE: iOS Pro CTAs use accessibility IDs, Donate CTA doesn't have features // See SES-4930 export class CTAFeature extends LocatorsInterface { private index: number; @@ -177,12 +177,16 @@ export class CTAFeature extends LocatorsInterface { selector: `new UiSelector().resourceId("cta-feature-${this.index}").childSelector(new UiSelector().className("android.widget.TextView"))`, } as const; case 'ios': - throw new Error('CTAFeature locator is not available on iOS'); + // iOS feature indexing starts at 1, Android at 0 + return { + strategy: 'accessibility id', + selector: `cta-feature-${this.index + 1}`, + } as const; } } } -// NOTE: This is meant to be a generic locator for all CTAs but for the time being the iOS implementation is limited to the Donate CTA +// NOTE: iOS Pro CTAs use accessibility IDs, Donate CTA requires XPath fallback (see DeviceWrapper) // See SES-4930 export class CTAHeading extends LocatorsInterface { public build() { @@ -194,8 +198,8 @@ export class CTAHeading extends LocatorsInterface { } as const; case 'ios': return { - strategy: 'xpath', - selector: `//XCUIElementTypeStaticText[starts-with(@name,'Session Needs')]`, + strategy: 'accessibility id', + selector: 'cta-heading', } as const; } } diff --git a/run/test/locators/index.ts b/run/test/locators/index.ts index f59f39793f..603d02f336 100644 --- a/run/test/locators/index.ts +++ b/run/test/locators/index.ts @@ -550,6 +550,14 @@ export function describeLocator(locator: StrategyExtractionObj & { text?: string ? `${selector.substring(0, halfLength)}…${selector.substring(selector.length - halfLength)}` : selector; + // Trim text if too long, show beginning and end + const maxTextLength = 100; + const trimmedText = text + ? text.length > maxTextLength + ? `${text.substring(0, maxTextLength / 2)}…${text.substring(text.length - maxTextLength / 2)}` + : text + : undefined; + const base = `${strategy} "${trimmedSelector}"`; - return text ? `${base} and text "${text}"` : base; + return trimmedText ? `${base} and text "${trimmedText}"` : base; } diff --git a/run/test/specs/cta_donate_review.spec.ts b/run/test/specs/cta_donate_review.spec.ts index f34ba55aef..b99ccc816a 100644 --- a/run/test/specs/cta_donate_review.spec.ts +++ b/run/test/specs/cta_donate_review.spec.ts @@ -51,7 +51,11 @@ async function donateCTAReview(platform: SupportedPlatformsType, testInfo: TestI await verifyPageScreenshot(device, platform, 'cta_donate', testInfo); }); await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Open URL'), async () => { - await device.clickOnElementAll(new CTAButtonPositive(device)); + const positiveButton = await device.findWithFallback(new CTAButtonPositive(device), { + strategy: 'accessibility id', + selector: 'Donate', + } as const); + await device.click(positiveButton.ELEMENT); await device.checkModalStrings( tStripped('urlOpen'), tStripped('urlOpenDescription', { url: donateURL }) diff --git a/run/test/specs/cta_donate_time.spec.ts b/run/test/specs/cta_donate_time.spec.ts index c7e1f46c29..ee5308ba66 100644 --- a/run/test/specs/cta_donate_time.spec.ts +++ b/run/test/specs/cta_donate_time.spec.ts @@ -67,7 +67,7 @@ async function donateCTADoesntShowSixDaysAgo(platform: SupportedPlatformsType, t await test.step('Verify Donate CTA does not show', async () => { await Promise.all([ device.waitForTextElementToBePresent(new PlusButton(device)), - device.verifyNoCTAShows(), + device.verifyNoCTAShows('donate'), ]); }); await test.step(TestSteps.SETUP.CLOSE_APP, async () => { diff --git a/run/test/specs/user_actions_animated_profile_picture.spec.ts b/run/test/specs/user_actions_animated_profile_picture.spec.ts index 6f90f770ce..286d7bd1d8 100644 --- a/run/test/specs/user_actions_animated_profile_picture.spec.ts +++ b/run/test/specs/user_actions_animated_profile_picture.spec.ts @@ -4,6 +4,7 @@ import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; import { PathMenuItem, UserAvatar } from '../locators/settings'; +import { IOSTestContext } from '../utils/capabilities_ios'; import { newUser } from '../utils/create_account'; import { makeAccountPro } from '../utils/mock_pro'; import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; @@ -32,8 +33,11 @@ bothPlatformsIt({ }); async function nonProAnimatedDP(platform: SupportedPlatformsType, testInfo: TestInfo) { + const iosContext: IOSTestContext = { + sessionProEnabled: 'true', + }; const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { - const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo, iosContext); await newUser(device, USERNAME.ALICE, { saveUserData: false }); return { device }; }); @@ -47,8 +51,11 @@ async function nonProAnimatedDP(platform: SupportedPlatformsType, testInfo: Test }); } async function proAnimatedDP(platform: SupportedPlatformsType, testInfo: TestInfo) { + const iosContext: IOSTestContext = { + sessionProEnabled: 'true', + }; const { device, alice } = await test.step(TestSteps.SETUP.NEW_USER, async () => { - const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo, iosContext); const alice = await newUser(device, USERNAME.ALICE); return { device, alice }; }); diff --git a/run/test/utils/mock_pro.ts b/run/test/utils/mock_pro.ts index 9a612a4197..b9b3f6a3eb 100644 --- a/run/test/utils/mock_pro.ts +++ b/run/test/utils/mock_pro.ts @@ -327,20 +327,10 @@ async function addProPayment( // Registers a test account as a Pro subscriber against the dev backend. export async function makeAccountPro(params: MakeAccountProParams): Promise { const { mnemonic, provider, dryRun = false } = params; - - console.log('Deriving keys from mnemonic...'); const seedHex = mnemonicToSeedHex(mnemonic); - const masterKey = deriveProMasterKey(seedHex); - - console.log(` Master pubkey: ${Buffer.from(masterKey.publicKey).toString('hex')}`); - - // Generate rotating key const rotatingKey = generateRotatingKey(); - console.log(` Rotating pubkey: ${Buffer.from(rotatingKey.publicKey).toString('hex')}`); - // Build request - console.log(`\nBuilding add_pro_payment request (${provider})...`); const request = buildAddProPaymentRequest(masterKey, rotatingKey, provider); console.log('\nRequest body:'); console.log(JSON.stringify(request, null, 2)); @@ -368,9 +358,13 @@ if (require.main === module) { const args = process.argv.slice(2); if (args.length < 2) { - console.error('Usage: ts-node mock_pro.ts [--dry-run]'); - console.error('Example: ts-node mock_pro.ts "word1 word2 ..." google'); - console.error(' ts-node mock_pro.ts "word1 word2 ..." apple --dry-run'); + console.error( + 'Usage: npx ts-node run/test/utils/mock_pro.ts [--dry-run]' + ); + console.error('Example: npx ts-node run/test/utils/mock_pro.ts "word1 word2 ..." google'); + console.error( + ' npx ts-node run/test/utils/mock_pro.ts "word1 word2 ..." apple --dry-run' + ); process.exit(1); } diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index 6b1e579f04..48df6676ab 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -1866,27 +1866,24 @@ export class DeviceWrapper { paste: boolean = false ) { const locator = args instanceof LocatorsInterface ? args.build() : args; - - this.log('Locator being used:', locator); - const el = await this.waitForTextElementToBePresent({ ...locator }); - if (!el) { - throw new Error(`inputText: Did not find element with locator: ${JSON.stringify(locator)}`); - } if (paste) { - await this.click(el.ELEMENT); + // Set clipboard, press key-code for instant paste + await this.clickOnElementAll({ ...locator }); if (this.isAndroid()) { await this.toAndroid().setClipboard( Buffer.from(textToInput).toString('base64'), 'plaintext' ); - // KEYCODE_PASTE = 279 await this.toAndroid().pressKeyCode(279); } else { - await this.toIOS().mobileSetPasteboard(textToInput, 'utf8'); - // XCUIKeyModifierCommand = 1 << 4 = 16 - await this.toIOS().mobileKeys([{ key: 'v', modifierFlags: 16 }]); + // Use native paste UI, accept perms if needed + await this.toIOS().mobileSetPasteboard(textToInput); + await this.toIOS().mobileGetPasteboard(); + await this.processPermissions({ strategy: 'accessibility id', selector: 'Allow Paste' }); + await this.clickOnElementAll({ ...locator }); + await this.clickOnByAccessibilityID('Paste'); } } else { await this.setValueImmediate(textToInput, el.ELEMENT); @@ -2452,8 +2449,8 @@ export class DeviceWrapper { await this.clickOnElementAll(new CloseSettings(this)); } - public async processPermissions(locator: LocatorsInterface) { - const locatorConfig = locator.build(); + public async processPermissions(locator: LocatorsInterface | StrategyExtractionObj) { + const locatorConfig = locator instanceof LocatorsInterface ? locator.build() : locator; if (this.isAndroid()) { const permissions = await this.doesElementExist({ @@ -2610,17 +2607,35 @@ export class DeviceWrapper { throw new Error('CTAs must have 1-2 buttons'); } - // Find and check heading - const elHeading = await this.waitForTextElementToBePresent(new CTAHeading(this)); + // Fallback locators for Donate CTA on iOS (no accessibility IDs) + const headingFallback = { + strategy: 'xpath', + selector: `//XCUIElementTypeStaticText[starts-with(@name,'Session Needs')]`, + } as const; + const bodyFallback = { + strategy: 'xpath', + selector: `//XCUIElementTypeStaticText[starts-with(@name,'Powerful forces are trying to')]`, + } as const; + const positiveButtonFallback = { + strategy: 'accessibility id', + selector: 'Donate', + } as const; + const negativeButtonFallback = { + strategy: 'accessibility id', + selector: 'Maybe Later', + } as const; + + // Find and check heading (with fallback for Donate CTA) + const elHeading = await this.findWithFallback(new CTAHeading(this), headingFallback); const actualHeading = await this.getTextFromElement(elHeading); this.assertTextMatches(actualHeading, heading, 'CTA heading'); - // Find and check body - const elBody = await this.waitForTextElementToBePresent(new CTABody(this)); + // Find and check body (with fallback for Donate CTA) + const elBody = await this.findWithFallback(new CTABody(this), bodyFallback); const actualBody = await this.getTextFromElement(elBody); this.assertTextMatches(actualBody, body, 'CTA body'); - // Check features if expected + // Check features if expected (Pro CTAs only) if (features) { for (let i = 0; i < features.length; i++) { const featureLocator = new CTAFeature(this, i); @@ -2630,15 +2645,15 @@ export class DeviceWrapper { } } - // Check buttons + // Check buttons (with fallback for Donate CTA) const positiveLocator = new CTAButtonPositive(this); - const elPositive = await this.waitForTextElementToBePresent(positiveLocator); + const elPositive = await this.findWithFallback(positiveLocator, positiveButtonFallback); const actualPositive = await this.getTextFromElement(elPositive); this.assertTextMatches(actualPositive, buttons[0], 'CTA positive button'); if (buttons.length === 2) { const negativeLocator = new CTAButtonNegative(this); - const elNegative = await this.waitForTextElementToBePresent(negativeLocator); + const elNegative = await this.findWithFallback(negativeLocator, negativeButtonFallback); const actualNegative = await this.getTextFromElement(elNegative); this.assertTextMatches(actualNegative, buttons[1], 'CTA negative button'); } @@ -2650,12 +2665,31 @@ export class DeviceWrapper { // This is the bare minimum of a CTA so we only check these // Features may or may not exist anyway, same goes for negative buttons - public async verifyNoCTAShows(): Promise { - await Promise.all([ - this.verifyElementNotPresent(new CTAHeading(this)), - this.verifyElementNotPresent(new CTABody(this)), - this.verifyElementNotPresent(new CTAButtonPositive(this)), - ]); + public async verifyNoCTAShows(ctaType?: CTAType): Promise { + // For Donate CTA on iOS, check the XPath selectors since accessibility IDs don't exist + if (ctaType === 'donate' && this.isIOS()) { + await Promise.all([ + this.verifyElementNotPresent({ + strategy: 'xpath', + selector: `//XCUIElementTypeStaticText[starts-with(@name,'Session Needs')]`, + }), + this.verifyElementNotPresent({ + strategy: 'xpath', + selector: `//XCUIElementTypeStaticText[starts-with(@name,'Powerful forces are trying to')]`, + }), + this.verifyElementNotPresent({ + strategy: 'accessibility id', + selector: 'Donate', + }), + ]); + } else { + // For all other cases, use the standard CTA locators + await Promise.all([ + this.verifyElementNotPresent(new CTAHeading(this)), + this.verifyElementNotPresent(new CTABody(this)), + this.verifyElementNotPresent(new CTAButtonPositive(this)), + ]); + } } // Dismiss any CTA if it shows diff --git a/run/types/testing.ts b/run/types/testing.ts index 2aec099f15..e86d20d3b5 100644 --- a/run/types/testing.ts +++ b/run/types/testing.ts @@ -183,6 +183,7 @@ export type AccessibilityId = | 'Allow' | 'Allow Access to All Photos' | 'Allow Full Access' + | 'Allow Paste' | 'Allow voice and video calls' | 'All Photos' | 'Answer call' @@ -234,6 +235,10 @@ export type AccessibilityId = | 'Copy URL' | 'Create account button' | 'Create group' + | 'cta-body' + | 'cta-button-negative' + | 'cta-button-positive' + | 'cta-heading' | 'Decline message request' | 'Delete' | 'Delete Contact' @@ -352,6 +357,7 @@ export type AccessibilityId = | 'open-survey-button' | 'Open' | 'Open URL' + | 'Paste' | 'Path' | 'Photo library' | 'Photos' @@ -424,6 +430,7 @@ export type AccessibilityId = | 'Your message request has been accepted.' | `${DISAPPEARING_TIMES} - Radio` | `${GROUPNAME}` + | `cta-feature-${number}` | `Disappear after ${DisappearActions} option`; export type Id = diff --git a/scripts/create_ios_simulators.ts b/scripts/create_ios_simulators.ts index 79a008534d..051616aac8 100644 --- a/scripts/create_ios_simulators.ts +++ b/scripts/create_ios_simulators.ts @@ -44,7 +44,7 @@ const DEVICE_CONFIG = { const MEDIA_ROOT = path.join('run', 'test', 'media'); const MEDIA_FILES = { - images: ['profile_picture.jpg', 'test_image.jpg'], + images: ['profile_picture.jpg', 'test_image.jpg', 'animated_profile_picture.gif'], videos: ['test_video.mp4'], pdfs: ['test_file.pdf'], }; From 5508edc96bedbbd5697b18113ccbea220a6d4d82 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Wed, 18 Feb 2026 14:24:37 +1100 Subject: [PATCH 19/38] fix: new media picker locators --- run/types/DeviceWrapper.ts | 4 ++-- run/types/testing.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index 48df6676ab..c811d1d282 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -1992,12 +1992,12 @@ export class DeviceWrapper { await sleepFor(500); await this.clickOnElementAll({ strategy: 'id', - selector: 'network.loki.messenger:id/mediapicker_folder_item_thumbnail', + selector: 'mediapicker-folder-item-thumbnail-0', }); await sleepFor(100); await this.clickOnElementAll({ strategy: 'id', - selector: 'network.loki.messenger:id/mediapicker_image_item_thumbnail', + selector: 'mediapicker-image-item-thumbnail-0', }); } await this.inputText(message, new MessageInput(this)); diff --git a/run/types/testing.ts b/run/types/testing.ts index e86d20d3b5..08a0ef406a 100644 --- a/run/types/testing.ts +++ b/run/types/testing.ts @@ -528,6 +528,8 @@ export type Id = | 'manage-admins-menu-option' | 'manage-members-menu-option' | 'Market cap amount' + | 'mediapicker-folder-item-thumbnail-0' + | 'mediapicker-image-item-thumbnail-0' | 'MeetingSE option' | 'Modal description' | 'Modal heading' @@ -546,8 +548,6 @@ export type Id = | 'network.loki.messenger:id/endCallButton' | 'network.loki.messenger:id/layout_emoji_container' | 'network.loki.messenger:id/linkPreviewView' - | 'network.loki.messenger:id/mediapicker_folder_item_thumbnail' - | 'network.loki.messenger:id/mediapicker_image_item_thumbnail' | 'network.loki.messenger:id/messageStatusTextView' | 'network.loki.messenger:id/openGroupTitleTextView' | 'network.loki.messenger:id/play_overlay' From 28d65c4515795303f4c5c1beebd84d04dcb1562c Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Wed, 18 Feb 2026 14:29:43 +1100 Subject: [PATCH 20/38] feat: recovery banner tests --- run/constants/community.ts | 24 +++- run/test/locators/conversation.ts | 6 +- run/test/specs/community_ban.spec.ts | 18 +-- run/test/specs/community_emoji_react.spec.ts | 15 +- run/test/specs/community_requests_off.spec.ts | 13 +- run/test/specs/community_requests_on.spec.ts | 19 +-- run/test/specs/community_tests_image.spec.ts | 8 +- run/test/specs/community_tests_join.spec.ts | 19 ++- .../disappearing_community_invite.spec.ts | 4 +- .../specs/linked_device_community_ban.spec.ts | 26 ++-- .../message_community_invitation.spec.ts | 10 +- run/test/specs/recovery_banner.spec.ts | 128 ++++++++++++++++++ run/types/allure.ts | 5 +- 13 files changed, 230 insertions(+), 65 deletions(-) create mode 100644 run/test/specs/recovery_banner.spec.ts diff --git a/run/constants/community.ts b/run/constants/community.ts index 5a575110c5..4b2d3b36e1 100644 --- a/run/constants/community.ts +++ b/run/constants/community.ts @@ -1,3 +1,21 @@ -export const testCommunityLink = `https://chat.lokinet.dev/testing-all-the-things?public_key=1d7e7f92b1ed3643855c98ecac02fc7274033a3467653f047d6e433540c03f17`; -export const testCommunityName = `Testing All The Things!`; -export const unresolvedTestcommunityName = 'testing-all-the-things'; +type CommunityConfig = { + link: string; + name: string; + roomName?: string; +}; + +export const communities: Record = { + testCommunity: { + link: 'https://chat.lokinet.dev/testing-all-the-things?public_key=1d7e7f92b1ed3643855c98ecac02fc7274033a3467653f047d6e433540c03f17', + name: 'Testing All The Things!', + roomName: 'testing-all-the-things', + }, + lokinetUpdates: { + link: 'https://open.getsession.org/lokinet-updates?public_key=a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238', + name: 'Lokinet Updates', + }, + sessionNetworkUpdates: { + link: 'https://open.getsession.org/oxen-updates?public_key=a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238', + name: 'Session Network Updates', + }, +}; diff --git a/run/test/locators/conversation.ts b/run/test/locators/conversation.ts index 2adeca44be..f132ce34d2 100644 --- a/run/test/locators/conversation.ts +++ b/run/test/locators/conversation.ts @@ -1,6 +1,6 @@ import type { DeviceWrapper } from '../../types/DeviceWrapper'; -import { testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { tStripped } from '../../localizer/lib'; import { StrategyExtractionObj } from '../../types/testing'; import { getAppDisplayName } from '../utils/devnet'; @@ -64,13 +64,13 @@ export class CommunityInvitation extends LocatorsInterface { return { strategy: 'id', selector: 'network.loki.messenger:id/openGroupTitleTextView', - text: testCommunityName, + text: communities.testCommunity.name, } as const; case 'ios': return { strategy: 'accessibility id', selector: 'Community invitation', - text: testCommunityName, + text: communities.testCommunity.name, } as const; } } diff --git a/run/test/specs/community_ban.spec.ts b/run/test/specs/community_ban.spec.ts index 94e289679f..0495dfd412 100644 --- a/run/test/specs/community_ban.spec.ts +++ b/run/test/specs/community_ban.spec.ts @@ -1,6 +1,6 @@ import test, { type TestInfo } from '@playwright/test'; -import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { tStripped } from '../../localizer/lib'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; @@ -68,15 +68,15 @@ async function banUserCommunity(platform: SupportedPlatformsType, testInfo: Test }); await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { const adminJoined = await alice1.doesElementExist( - new ConversationItem(alice1, testCommunityName) + new ConversationItem(alice1, communities.testCommunity.name) ); if (!adminJoined) { - await joinCommunity(alice1, testCommunityLink, testCommunityName); + await joinCommunity(alice1, communities.testCommunity.link, communities.testCommunity.name); } else { - await alice1.clickOnElementAll(new ConversationItem(alice1, testCommunityName)); + await alice1.clickOnElementAll(new ConversationItem(alice1, communities.testCommunity.name)); await alice1.scrollToBottom(); } - await joinCommunity(bob1, testCommunityLink, testCommunityName); + await joinCommunity(bob1, communities.testCommunity.link, communities.testCommunity.name); }); await test.step(TestSteps.SEND.MESSAGE('Bob', 'community'), async () => { await bob1.sendMessage(msg1); @@ -138,15 +138,15 @@ async function banAndDelete(platform: SupportedPlatformsType, testInfo: TestInfo }); await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { const adminJoined = await alice1.doesElementExist( - new ConversationItem(alice1, testCommunityName) + new ConversationItem(alice1, communities.testCommunity.name) ); if (!adminJoined) { - await joinCommunity(alice1, testCommunityLink, testCommunityName); + await joinCommunity(alice1, communities.testCommunity.link, communities.testCommunity.name); } else { - await alice1.clickOnElementAll(new ConversationItem(alice1, testCommunityName)); + await alice1.clickOnElementAll(new ConversationItem(alice1, communities.testCommunity.name)); await alice1.scrollToBottom(); } - await joinCommunity(bob1, testCommunityLink, testCommunityName); + await joinCommunity(bob1, communities.testCommunity.link, communities.testCommunity.name); }); await test.step(TestSteps.SEND.MESSAGE('Bob', 'community'), async () => { await bob1.sendMessage(msg1); diff --git a/run/test/specs/community_emoji_react.spec.ts b/run/test/specs/community_emoji_react.spec.ts index 06e612a7f1..d17581a4be 100644 --- a/run/test/specs/community_emoji_react.spec.ts +++ b/run/test/specs/community_emoji_react.spec.ts @@ -1,6 +1,6 @@ import { test, type TestInfo } from '@playwright/test'; -import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { EmojiReactsPill, FirstEmojiReact, MessageBody } from '../locators/conversation'; @@ -36,11 +36,16 @@ async function sendEmojiReactionCommunity(platform: SupportedPlatformsType, test }); }); await Promise.all( - [alice1, bob1].map(device => joinCommunity(device, testCommunityLink, testCommunityName)) + [alice1, bob1].map(device => + joinCommunity(device, communities.testCommunity.link, communities.testCommunity.name) + ) + ); + await test.step( + TestSteps.SEND.MESSAGE(alice.userName, communities.testCommunity.name), + async () => { + await alice1.sendMessage(message); + } ); - await test.step(TestSteps.SEND.MESSAGE(alice.userName, testCommunityName), async () => { - await alice1.sendMessage(message); - }); await test.step(TestSteps.SEND.EMOJI_REACT, async () => { await bob1.scrollToBottom(); await bob1.longPressMessage(new MessageBody(bob1, message)); diff --git a/run/test/specs/community_requests_off.spec.ts b/run/test/specs/community_requests_off.spec.ts index b1e04cbf24..9a72f2b0d9 100644 --- a/run/test/specs/community_requests_off.spec.ts +++ b/run/test/specs/community_requests_off.spec.ts @@ -1,7 +1,7 @@ import { test, type TestInfo } from '@playwright/test'; import { USERNAME } from '@session-foundation/qa-seeder'; -import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { CommunityMessageAuthor, UPMMessageButton } from '../locators/conversation'; @@ -33,13 +33,16 @@ async function blindedMessageRequests(platform: SupportedPlatformsType, testInfo await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { await Promise.all( [device1, device2].map(async device => { - await joinCommunity(device, testCommunityLink, testCommunityName); + await joinCommunity(device, communities.testCommunity.link, communities.testCommunity.name); }) ); }); - await test.step(TestSteps.SEND.MESSAGE(USERNAME.BOB, testCommunityName), async () => { - await device2.sendMessage(message); - }); + await test.step( + TestSteps.SEND.MESSAGE(USERNAME.BOB, communities.testCommunity.name), + async () => { + await device2.sendMessage(message); + } + ); await device1.clickOnElementAll(new CommunityMessageAuthor(device1, message)); await test.step(`Verify the 'Message' button in the User Profile Modal is disabled`, async () => { // brief sleep to let the UI settle diff --git a/run/test/specs/community_requests_on.spec.ts b/run/test/specs/community_requests_on.spec.ts index 7862cd7d24..caefd37a86 100644 --- a/run/test/specs/community_requests_on.spec.ts +++ b/run/test/specs/community_requests_on.spec.ts @@ -1,7 +1,7 @@ import { test, type TestInfo } from '@playwright/test'; import { USERNAME } from '@session-foundation/qa-seeder'; -import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { CloseSettings } from '../locators'; @@ -55,17 +55,20 @@ async function blindedMessageRequests(platform: SupportedPlatformsType, testInfo await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { await Promise.all( [device1, device2].map(async device => { - await joinCommunity(device, testCommunityLink, testCommunityName); + await joinCommunity(device, communities.testCommunity.link, communities.testCommunity.name); }) ); }); - await test.step(TestSteps.SEND.MESSAGE(bob.userName, testCommunityName), async () => { - // brief sleep to let the UI settle - await sleepFor(1000); - await device2.sendMessage(message); - await device2.navigateBack(); - }); + await test.step( + TestSteps.SEND.MESSAGE(bob.userName, communities.testCommunity.name), + async () => { + // brief sleep to let the UI settle + await sleepFor(1000); + await device2.sendMessage(message); + await device2.navigateBack(); + } + ); await test.step(TestSteps.SEND.MESSAGE(alice.userName, bob.userName), async () => { await device1.clickOnElementAll(new CommunityMessageAuthor(device1, message)); await sleepFor(500); // brief sleep to let the UI settle diff --git a/run/test/specs/community_tests_image.spec.ts b/run/test/specs/community_tests_image.spec.ts index 380a4d21f0..6cc65028b6 100644 --- a/run/test/specs/community_tests_image.spec.ts +++ b/run/test/specs/community_tests_image.spec.ts @@ -1,6 +1,6 @@ import { test, type TestInfo } from '@playwright/test'; -import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { MessageBody } from '../locators/conversation'; @@ -31,7 +31,9 @@ async function sendImageCommunity(platform: SupportedPlatformsType, testInfo: Te const testImageMessage = `Image message + ${new Date().getTime()} - ${platform}`; await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { await Promise.all( - [alice1, bob1].map(device => joinCommunity(device, testCommunityLink, testCommunityName)) + [alice1, bob1].map(device => + joinCommunity(device, communities.testCommunity.link, communities.testCommunity.name) + ) ); }); await test.step(TestSteps.SEND.IMAGE, async () => { @@ -40,7 +42,7 @@ async function sendImageCommunity(platform: SupportedPlatformsType, testInfo: Te await test.step(TestSteps.VERIFY.MESSAGE_RECEIVED, async () => { await sleepFor(2000); // Give bob some time to receive the image await bob1.scrollToBottom(); - await bob1.onAndroid().trustAttachments(testCommunityName); + await bob1.onAndroid().trustAttachments(communities.testCommunity.name); await bob1.onAndroid().scrollToBottom(); // Trusting attachments scrolls the viewport up a bit so gotta scroll to bottom again await bob1.waitForTextElementToBePresent(new MessageBody(bob1, testImageMessage)); }); diff --git a/run/test/specs/community_tests_join.spec.ts b/run/test/specs/community_tests_join.spec.ts index cc24909ddf..dd9bc8b4ff 100644 --- a/run/test/specs/community_tests_join.spec.ts +++ b/run/test/specs/community_tests_join.spec.ts @@ -1,6 +1,6 @@ import { test, type TestInfo } from '@playwright/test'; -import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { ConversationItem } from '../locators/home'; @@ -31,16 +31,21 @@ async function joinCommunityTest(platform: SupportedPlatformsType, testInfo: Tes }); const testMessage = `Test message + ${new Date().getTime()}`; await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { - await joinCommunity(alice1, testCommunityLink, testCommunityName); + await joinCommunity(alice1, communities.testCommunity.link, communities.testCommunity.name); await sleepFor(5000); }); - await test.step(TestSteps.SEND.MESSAGE(alice.userName, testCommunityName), async () => { - await alice1.scrollToBottom(); - await alice1.sendMessage(testMessage); - }); + await test.step( + TestSteps.SEND.MESSAGE(alice.userName, communities.testCommunity.name), + async () => { + await alice1.scrollToBottom(); + await alice1.sendMessage(testMessage); + } + ); await test.step(TestSteps.VERIFY.MESSAGE_SYNCED, async () => { // Has community synced to device 2? - await alice2.waitForTextElementToBePresent(new ConversationItem(alice2, testCommunityName)); + await alice2.waitForTextElementToBePresent( + new ConversationItem(alice2, communities.testCommunity.name) + ); }); await test.step(TestSteps.SETUP.CLOSE_APP, async () => { await closeApp(alice1, alice2); diff --git a/run/test/specs/disappearing_community_invite.spec.ts b/run/test/specs/disappearing_community_invite.spec.ts index 620c027bfd..a7bb7b1487 100644 --- a/run/test/specs/disappearing_community_invite.spec.ts +++ b/run/test/specs/disappearing_community_invite.spec.ts @@ -1,6 +1,6 @@ import type { TestInfo } from '@playwright/test'; -import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { bothPlatformsIt } from '../../types/sessionIt'; import { DISAPPEARING_TIMES } from '../../types/testing'; import { InviteContactsMenuItem } from '../locators'; @@ -50,7 +50,7 @@ async function disappearingCommunityInviteMessage( await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); // await alice1.navigateBack(); await alice1.navigateBack(); - await joinCommunity(alice1, testCommunityLink, testCommunityName); + await joinCommunity(alice1, communities.testCommunity.link, communities.testCommunity.name); await alice1.clickOnElementAll(new ConversationSettings(alice1)); await sleepFor(1000); await alice1.clickOnElementAll(new InviteContactsMenuItem(alice1)); diff --git a/run/test/specs/linked_device_community_ban.spec.ts b/run/test/specs/linked_device_community_ban.spec.ts index 2cba89667a..c09eb3a300 100644 --- a/run/test/specs/linked_device_community_ban.spec.ts +++ b/run/test/specs/linked_device_community_ban.spec.ts @@ -1,10 +1,6 @@ import test, { type TestInfo } from '@playwright/test'; -import { - testCommunityLink, - testCommunityName, - unresolvedTestcommunityName, -} from '../../constants/community'; +import { communities } from '../../constants/community'; import { tStripped } from '../../localizer/lib'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; @@ -78,15 +74,15 @@ async function banUnbanLinked(platform: SupportedPlatformsType, testInfo: TestIn }); await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { const adminJoined = await alice1.doesElementExist( - new ConversationItem(alice1, testCommunityName) + new ConversationItem(alice1, communities.testCommunity.name) ); if (!adminJoined) { - await joinCommunity(alice1, testCommunityLink, testCommunityName); + await joinCommunity(alice1, communities.testCommunity.link, communities.testCommunity.name); } else { - await alice1.clickOnElementAll(new ConversationItem(alice1, testCommunityName)); + await alice1.clickOnElementAll(new ConversationItem(alice1, communities.testCommunity.name)); await alice1.scrollToBottom(); } - await joinCommunity(bob1, testCommunityLink, testCommunityName); + await joinCommunity(bob1, communities.testCommunity.link, communities.testCommunity.name); }); await test.step(TestSteps.SEND.MESSAGE('Bob', 'community'), async () => { await bob1.sendMessage(msg1); @@ -107,7 +103,7 @@ async function banUnbanLinked(platform: SupportedPlatformsType, testInfo: TestIn }); await test.step(TestSteps.SETUP.RESTORE_ACCOUNT('Bob'), async () => { await restoreAccount(bob2, bob, 'bob2'); - await bob2.clickOnElementAll(new ConversationItem(alice1, unresolvedTestcommunityName)); // Since we're banned we don't get the "real" name + await bob2.clickOnElementAll(new ConversationItem(alice1, communities.testCommunity.roomName)); // Since we're banned we don't get the "real" name await bob2.waitForTextElementToBePresent(new EmptyConversation(bob2)); await bob2.onIOS().waitForTextElementToBePresent({ strategy: 'xpath', @@ -152,15 +148,15 @@ async function banAndDeleteLinked(platform: SupportedPlatformsType, testInfo: Te }); await test.step(TestSteps.NEW_CONVERSATION.JOIN_COMMUNITY, async () => { const adminJoined = await alice1.doesElementExist( - new ConversationItem(alice1, testCommunityName) + new ConversationItem(alice1, communities.testCommunity.name) ); if (!adminJoined) { - await joinCommunity(alice1, testCommunityLink, testCommunityName); + await joinCommunity(alice1, communities.testCommunity.link, communities.testCommunity.name); } else { - await alice1.clickOnElementAll(new ConversationItem(alice1, testCommunityName)); + await alice1.clickOnElementAll(new ConversationItem(alice1, communities.testCommunity.name)); await alice1.scrollToBottom(); } - await joinCommunity(bob1, testCommunityLink, testCommunityName); + await joinCommunity(bob1, communities.testCommunity.link, communities.testCommunity.name); }); await test.step(TestSteps.SEND.MESSAGE('Bob', 'community'), async () => { await bob1.sendMessage(msg1); @@ -178,7 +174,7 @@ async function banAndDeleteLinked(platform: SupportedPlatformsType, testInfo: Te }); await test.step(TestSteps.SETUP.RESTORE_ACCOUNT('Bob'), async () => { await restoreAccount(bob2, bob, 'bob2'); - await bob2.clickOnElementAll(new ConversationItem(alice1, unresolvedTestcommunityName)); // Since we're banned we don't get the "real" name + await bob2.clickOnElementAll(new ConversationItem(alice1, communities.testCommunity.roomName)); // Since we're banned we don't get the "real" name await bob2.waitForTextElementToBePresent(new EmptyConversation(bob2)); }); await test.step('Verify Bob cannot send messages in community on either device', async () => { diff --git a/run/test/specs/message_community_invitation.spec.ts b/run/test/specs/message_community_invitation.spec.ts index 7d622ae4d3..20de78138e 100644 --- a/run/test/specs/message_community_invitation.spec.ts +++ b/run/test/specs/message_community_invitation.spec.ts @@ -1,6 +1,6 @@ import type { TestInfo } from '@playwright/test'; -import { testCommunityLink, testCommunityName } from '../../constants/community'; +import { communities } from '../../constants/community'; import { tStripped } from '../../localizer/lib'; import { bothPlatformsIt } from '../../types/sessionIt'; import { InviteContactsMenuItem, JoinCommunityModalButton } from '../locators'; @@ -35,7 +35,7 @@ async function sendCommunityInvitation(platform: SupportedPlatformsType, testInf // Join community on device 1 // Click on plus button await alice1.navigateBack(); - await joinCommunity(alice1, testCommunityLink, testCommunityName); + await joinCommunity(alice1, communities.testCommunity.link, communities.testCommunity.name); await alice1.clickOnElementAll(new ConversationSettings(alice1)); await sleepFor(500); await alice1.clickOnElementAll(new InviteContactsMenuItem(alice1)); @@ -45,10 +45,12 @@ async function sendCommunityInvitation(platform: SupportedPlatformsType, testInf await bob1.clickOnElementAll(new CommunityInvitation(bob1)); await bob1.checkModalStrings( tStripped('communityJoin'), - tStripped('communityJoinDescription', { community_name: testCommunityName }) + tStripped('communityJoinDescription', { community_name: communities.testCommunity.name }) ); await bob1.clickOnElementAll(new JoinCommunityModalButton(bob1)); await bob1.navigateBack(); - await bob1.waitForTextElementToBePresent(new ConversationItem(bob1, testCommunityName)); + await bob1.waitForTextElementToBePresent( + new ConversationItem(bob1, communities.testCommunity.name) + ); await closeApp(alice1, bob1); } diff --git a/run/test/specs/recovery_banner.spec.ts b/run/test/specs/recovery_banner.spec.ts new file mode 100644 index 0000000000..abf3c048f6 --- /dev/null +++ b/run/test/specs/recovery_banner.spec.ts @@ -0,0 +1,128 @@ +import { test, type TestInfo } from '@playwright/test'; +import { USERNAME } from '@session-foundation/qa-seeder'; + +import { communities } from '../../constants/community'; +import { TestSteps } from '../../types/allure'; +import { DeviceWrapper } from '../../types/DeviceWrapper'; +import { androidIt } from '../../types/sessionIt'; +import { ConversationItem, PlusButton } from '../locators/home'; +import { RecoveryPhraseContainer, RevealRecoveryPhraseButton } from '../locators/settings'; +import { joinCommunity } from '../utils/community'; +import { newUser } from '../utils/create_account'; +import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; + +androidIt({ + title: 'Recovery password banner only shows after >2 conversations', + risk: 'medium', + testCb: bannerShowsThreeConvos, + countOfDevicesNeeded: 1, + allureSuites: { + parent: 'Settings', + suite: 'Recovery Password', + }, + allureDescription: + 'Verifies that the recovery password banner only shows after the user has at least three conversations.', +}); + +androidIt({ + title: 'Recovery password banner disappears after being opened', + risk: 'medium', + testCb: bannerDisappearsAfterOpened, + countOfDevicesNeeded: 1, + allureSuites: { + parent: 'Settings', + suite: 'Recovery Password', + }, + allureDescription: 'Verifies that the recovery password banner disappears after first opened.', +}); + +androidIt({ + title: 'Recovery password banner persists with <3 conversations', + risk: 'medium', + testCb: bannerPersists, + countOfDevicesNeeded: 1, + allureSuites: { + parent: 'Settings', + suite: 'Recovery Password', + }, + allureDescription: + 'Verifies that the recovery password banner does not disappear if the conversation count drops below 3', +}); + +async function bannerShouldNotshow(device: DeviceWrapper) { + await device.waitForTextElementToBePresent(new PlusButton(device)); + await device.verifyElementNotPresent(new RevealRecoveryPhraseButton(device)); + device.log('On home screen, banner did not appear'); +} + +async function bannerShouldShow(device: DeviceWrapper) { + await device.waitForTextElementToBePresent(new PlusButton(device)); + await device.waitForTextElementToBePresent(new RevealRecoveryPhraseButton(device)); + device.log('On home screen, banner appeared'); +} + +async function bannerShowsThreeConvos(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + await newUser(device, USERNAME.ALICE, { saveUserData: false }); + return { device }; + }); + await test.step('Create three conversations, verify banner only appears after the third', async () => { + for (const community of Object.values(communities).slice(0, 3)) { + await bannerShouldNotshow(device); + await joinCommunity(device, community.link, community.name); + await device.navigateBack(); + } + await bannerShouldShow(device); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} + +async function bannerDisappearsAfterOpened(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + await newUser(device, USERNAME.ALICE, { saveUserData: false }); + return { device }; + }); + await test.step('Create three conversations, verify banner does not reappear after being opened', async () => { + for (const community of Object.values(communities).slice(0, 3)) { + await joinCommunity(device, community.link, community.name); + await device.navigateBack(); + } + await bannerShouldShow(device); + await device.clickOnElementAll(new RevealRecoveryPhraseButton(device)); + await device.waitForTextElementToBePresent(new RecoveryPhraseContainer(device)); + await device.navigateBack(); + await bannerShouldNotshow(device); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} + +async function bannerPersists(platform: SupportedPlatformsType, testInfo: TestInfo) { + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + await newUser(device, USERNAME.ALICE, { saveUserData: false }); + return { device }; + }); + await test.step('Create three conversations, verify banner persists after a conversation is deleted', async () => { + for (const community of Object.values(communities).slice(0, 3)) { + await joinCommunity(device, community.link, community.name); + await device.navigateBack(); + } + await bannerShouldShow(device); + await device.longPressConversation(communities.testCommunity.name); + await device.clickOnElementAll({ strategy: 'accessibility id', selector: 'Leave' }); // Long press options + await device.clickOnElementAll({ strategy: 'accessibility id', selector: 'Leave' }); // Modal confirm + await device.verifyElementNotPresent( + new ConversationItem(device, communities.testCommunity.name) + ); + await bannerShouldShow(device); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} diff --git a/run/types/allure.ts b/run/types/allure.ts index a94054f41a..801ae744f2 100644 --- a/run/types/allure.ts +++ b/run/types/allure.ts @@ -31,7 +31,10 @@ export type AllureSuiteConfig = parent: 'Sending Messages'; suite: 'Emoji reacts' | 'Mentions' | 'Message types' | 'Performance' | 'Rules'; } - | { parent: 'Settings'; suite: 'App Disguise' | 'Community Message Requests' | 'Notifications' } + | { + parent: 'Settings'; + suite: 'App Disguise' | 'Community Message Requests' | 'Notifications' | 'Recovery Password'; + } | { parent: 'User Actions'; suite: From c0a0d834033a2d6cca385598808a53a14d956d9e Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Wed, 18 Feb 2026 14:45:19 +1100 Subject: [PATCH 21/38] fix: simplify animated element logic --- run/types/DeviceWrapper.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index c811d1d282..f3b16249e4 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -2719,9 +2719,6 @@ export class DeviceWrapper { const colors = new Set(); for (let i = 0; i < SAMPLE_SIZE; i++) { colors.add(await this.getElementPixelColor(args)); - if (i < SAMPLE_SIZE - 1) { - await sleepFor(300); - } } expect( colors.size, From 6cfe2b603c531aec49ef8fcee6e0371ae396e2d4 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Wed, 18 Feb 2026 15:26:12 +1100 Subject: [PATCH 22/38] fix: simplify makeAccountPro signature --- run/test/specs/message_length.spec.ts | 6 +--- ...r_actions_animated_profile_picture.spec.ts | 5 +-- .../user_actions_share_to_session.spec.ts | 2 +- run/test/utils/mock_pro.ts | 33 ++++++++++--------- 4 files changed, 20 insertions(+), 26 deletions(-) diff --git a/run/test/specs/message_length.spec.ts b/run/test/specs/message_length.spec.ts index 21782420fe..006cdc7325 100644 --- a/run/test/specs/message_length.spec.ts +++ b/run/test/specs/message_length.spec.ts @@ -97,11 +97,7 @@ for (const testCase of messageLengthTestCases) { }); if (testCase.pro) { - const paymentProvider = platform === 'ios' ? 'apple' : 'google'; - await makeAccountPro({ - mnemonic: alice.recoveryPhrase, - provider: paymentProvider, - }); + await makeAccountPro({ user: alice, platform }); // Restart to notify app of Pro status change await forceStopAndRestart(device); await device.dismissCTA(); diff --git a/run/test/specs/user_actions_animated_profile_picture.spec.ts b/run/test/specs/user_actions_animated_profile_picture.spec.ts index 286d7bd1d8..18ff75bae6 100644 --- a/run/test/specs/user_actions_animated_profile_picture.spec.ts +++ b/run/test/specs/user_actions_animated_profile_picture.spec.ts @@ -59,10 +59,7 @@ async function proAnimatedDP(platform: SupportedPlatformsType, testInfo: TestInf const alice = await newUser(device, USERNAME.ALICE); return { device, alice }; }); - await makeAccountPro({ - mnemonic: alice.recoveryPhrase, - provider: 'google', - }); + await makeAccountPro({ user: alice, platform }); await forceStopAndRestart(device); await test.step(TestSteps.USER_ACTIONS.CHANGE_PROFILE_PICTURE, async () => { await device.uploadProfilePicture(true); diff --git a/run/test/specs/user_actions_share_to_session.spec.ts b/run/test/specs/user_actions_share_to_session.spec.ts index 7cb2ad288d..e5d6af1e55 100644 --- a/run/test/specs/user_actions_share_to_session.spec.ts +++ b/run/test/specs/user_actions_share_to_session.spec.ts @@ -31,7 +31,7 @@ bothPlatformsIt({ allureDescription: `Verifies that a user can share an image from the photo gallery to Session`, }); -// On iOS the Share button just opens the regular share sheet, same as 'Share to Session' - no need to test separately. +// On iOS the Share button just opens the regular share sheet, same as 'Share to Session' - no need to test separately. androidIt({ title: 'Share within Session', risk: 'medium', diff --git a/run/test/utils/mock_pro.ts b/run/test/utils/mock_pro.ts index b9b3f6a3eb..cd8a2a2c16 100644 --- a/run/test/utils/mock_pro.ts +++ b/run/test/utils/mock_pro.ts @@ -10,10 +10,7 @@ * Usage: * import { makeAccountPro } from './mock_pro'; * - * await makeAccountPro({ - * mnemonic: 'word1 word2 ... word13', - * provider: 'google' // or 'apple' - * }); + * await makeAccountPro({ user: alice, platform }); * * In order for the changes to take effect in the clients it's best to force close and restart the app */ @@ -25,12 +22,14 @@ import { readFileSync } from 'fs'; import { join } from 'path'; import { PRO_BACKEND_URL } from '../../constants'; +import { User } from '../../types/testing'; +import { SupportedPlatformsType } from './open_app'; type PaymentProvider = 'apple' | 'google'; type MakeAccountProParams = { - mnemonic: string; - provider: PaymentProvider; + user: User; + platform: SupportedPlatformsType; dryRun?: boolean; // If true, build and print the request but don't send it }; @@ -84,7 +83,7 @@ function getWordlist(): string[] { return words; } -// Decodes a 13-word recovery phrase a 16-byte seed hex string. */ +// Decodes a 13-word recovery phrase to a 16-byte seed hex string. */ function mnemonicToSeedHex(mnemonic: string): string { const wordlist = getWordlist(); const n = wordlist.length; // 1626 @@ -326,14 +325,16 @@ async function addProPayment( // Registers a test account as a Pro subscriber against the dev backend. export async function makeAccountPro(params: MakeAccountProParams): Promise { - const { mnemonic, provider, dryRun = false } = params; + const { user, platform, dryRun = false } = params; + const mnemonic = user.recoveryPhrase; + const provider: PaymentProvider = platform === 'ios' ? 'apple' : 'google'; const seedHex = mnemonicToSeedHex(mnemonic); const masterKey = deriveProMasterKey(seedHex); const rotatingKey = generateRotatingKey(); // Build request const request = buildAddProPaymentRequest(masterKey, rotatingKey, provider); - console.log('\nRequest body:'); - console.log(JSON.stringify(request, null, 2)); + console.log(`\nRequest body: + ${JSON.stringify(request, null, 2)}`); if (dryRun) { console.log('\nDRY RUN - Request not sent'); @@ -359,22 +360,22 @@ if (require.main === module) { if (args.length < 2) { console.error( - 'Usage: npx ts-node run/test/utils/mock_pro.ts [--dry-run]' + 'Usage: npx ts-node run/test/utils/mock_pro.ts [--dry-run]' ); - console.error('Example: npx ts-node run/test/utils/mock_pro.ts "word1 word2 ..." google'); + console.error('Example: npx ts-node run/test/utils/mock_pro.ts "word1 word2 ..." android'); console.error( - ' npx ts-node run/test/utils/mock_pro.ts "word1 word2 ..." apple --dry-run' + ' npx ts-node run/test/utils/mock_pro.ts "word1 word2 ..." ios --dry-run' ); process.exit(1); } const dryRun = args.includes('--dry-run'); const filteredArgs = args.filter(a => a !== '--dry-run'); - const [mnemonic, provider] = filteredArgs; + const [mnemonic, platform] = filteredArgs; makeAccountPro({ - mnemonic, - provider: provider as PaymentProvider, + user: { userName: '' as any, accountID: '', recoveryPhrase: mnemonic }, + platform: platform as SupportedPlatformsType, dryRun, }) .then(() => process.exit(0)) From 4f6717d6a5e84581a6b71fc99871fc123faaed30 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Thu, 19 Feb 2026 09:54:40 +1100 Subject: [PATCH 23/38] chore: update simulators --- ci-simulators.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/ci-simulators.json b/ci-simulators.json index 57286b1125..3c668df112 100644 --- a/ci-simulators.json +++ b/ci-simulators.json @@ -1,62 +1,62 @@ [ { "name": "Auto-17-0", - "udid": "319EF767-3947-47BD-8403-A2810A900352", + "udid": "38A6770A-89CB-48AE-AB76-4B7253887040", "wdaPort": 1253 }, { "name": "Auto-17-1", - "udid": "22C2F845-C59B-4AEC-9869-35AE2F3B78DE", + "udid": "178068B0-84DC-4FD0-A69B-90EF9C3B6B22", "wdaPort": 1254 }, { "name": "Auto-17-2", - "udid": "6ABF1BCD-7D4E-4ED3-998C-3B0A0F630658", + "udid": "84141341-BB1C-45B1-9F23-28BAC850B1B7", "wdaPort": 1255 }, { "name": "Auto-17-3", - "udid": "116D725B-DB87-4D76-900D-43510C5CA3BD", + "udid": "82999D57-1270-4ACF-9D5A-DC3A159577F7", "wdaPort": 1256 }, { "name": "Auto-17-4", - "udid": "AC308EC5-C18A-4B94-8780-18B3C8BE202D", + "udid": "12895DA0-F007-4AFB-BA06-7C201A482821", "wdaPort": 1257 }, { "name": "Auto-17-5", - "udid": "D0982657-C25C-45C7-9339-A1DA7B5C8F25", + "udid": "0EADC288-C020-4BBA-BA81-5E0936E2B424", "wdaPort": 1258 }, { "name": "Auto-17-6", - "udid": "2ADA6ACA-CE46-403F-AD91-53432A334C80", + "udid": "AF1A03EC-C335-4906-9C2C-F9BFBC3489A5", "wdaPort": 1259 }, { "name": "Auto-17-7", - "udid": "74511AFF-9687-4100-86EF-923A9B7537B4", + "udid": "EB9E97BF-15DB-4FCC-9B8D-EB6F336315B0", "wdaPort": 1260 }, { "name": "Auto-17-8", - "udid": "E81674B2-E37A-42CA-B7FB-206B9BCC53D6", + "udid": "FAE61862-F057-4C85-BB68-7CD204BA1648", "wdaPort": 1261 }, { "name": "Auto-17-9", - "udid": "7FA71A81-BD37-4CB5-B45A-E1615E697E1F", + "udid": "68BA9CC1-148D-49B6-9C30-28F86F2F16CA", "wdaPort": 1262 }, { "name": "Auto-17-10", - "udid": "A03EA1A6-F02C-4BC0-92FB-1A3FBCB422A2", + "udid": "A7AB2F64-127D-4696-A11B-F3A9F773739F", "wdaPort": 1263 }, { "name": "Auto-17-11", - "udid": "43E9330C-F024-4684-8B1C-6F1355867550", + "udid": "91CCA847-BFAA-459C-A368-4C13BE71ED02", "wdaPort": 1264 } ] \ No newline at end of file From 9646f39eafbadceff1c3cc973402d8dfdcab112b Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Thu, 19 Feb 2026 10:41:34 +1100 Subject: [PATCH 24/38] fix: remove donate cta workarounds on ios --- run/test/locators/global.ts | 8 --- run/test/specs/cta_donate_review.spec.ts | 10 +--- run/test/specs/cta_donate_time.spec.ts | 2 +- run/types/DeviceWrapper.ts | 76 ++++++------------------ 4 files changed, 23 insertions(+), 73 deletions(-) diff --git a/run/test/locators/global.ts b/run/test/locators/global.ts index 4edae485f1..58d264af47 100644 --- a/run/test/locators/global.ts +++ b/run/test/locators/global.ts @@ -119,8 +119,6 @@ export class CTABody extends LocatorsInterface { } } -// NOTE: iOS Pro CTAs use accessibility IDs, Donate CTA requires XPath fallback (see DeviceWrapper) -// See SES-4930 export class CTAButtonNegative extends LocatorsInterface { public build() { switch (this.platform) { @@ -139,8 +137,6 @@ export class CTAButtonNegative extends LocatorsInterface { } } -// NOTE: iOS Pro CTAs use accessibility IDs, Donate CTA requires XPath fallback (see DeviceWrapper) -// See SES-4930 export class CTAButtonPositive extends LocatorsInterface { public build() { switch (this.platform) { @@ -159,8 +155,6 @@ export class CTAButtonPositive extends LocatorsInterface { } } -// NOTE: iOS Pro CTAs use accessibility IDs, Donate CTA doesn't have features -// See SES-4930 export class CTAFeature extends LocatorsInterface { private index: number; @@ -186,8 +180,6 @@ export class CTAFeature extends LocatorsInterface { } } -// NOTE: iOS Pro CTAs use accessibility IDs, Donate CTA requires XPath fallback (see DeviceWrapper) -// See SES-4930 export class CTAHeading extends LocatorsInterface { public build() { switch (this.platform) { diff --git a/run/test/specs/cta_donate_review.spec.ts b/run/test/specs/cta_donate_review.spec.ts index b99ccc816a..267cfa95b0 100644 --- a/run/test/specs/cta_donate_review.spec.ts +++ b/run/test/specs/cta_donate_review.spec.ts @@ -10,7 +10,7 @@ import { ReviewPromptItsGreatButton } from '../locators/home'; import { PathMenuItem, UserSettings } from '../locators/settings'; import { newUser } from '../utils/create_account'; import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; -import { forceStopAndRestart as forceStopAndRestartApp } from '../utils/utilities'; +import { forceStopAndRestart } from '../utils/utilities'; import { verifyPageScreenshot } from '../utils/verify_screenshots'; bothPlatformsIt({ @@ -41,7 +41,7 @@ async function donateCTAReview(platform: SupportedPlatformsType, testInfo: TestI await test.step('Dismiss review prompt and restart the app', async () => { await device.clickOnElementAll(new ReviewPromptItsGreatButton(device)); await device.clickOnElementAll(new CloseSettings(device)); - await forceStopAndRestartApp(device); + await forceStopAndRestart(device); }); await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Donate CTA'), async () => { await device.checkCTA('donate'); @@ -51,11 +51,7 @@ async function donateCTAReview(platform: SupportedPlatformsType, testInfo: TestI await verifyPageScreenshot(device, platform, 'cta_donate', testInfo); }); await test.step(TestSteps.VERIFY.SPECIFIC_MODAL('Open URL'), async () => { - const positiveButton = await device.findWithFallback(new CTAButtonPositive(device), { - strategy: 'accessibility id', - selector: 'Donate', - } as const); - await device.click(positiveButton.ELEMENT); + await device.clickOnElementAll(new CTAButtonPositive(device)); await device.checkModalStrings( tStripped('urlOpen'), tStripped('urlOpenDescription', { url: donateURL }) diff --git a/run/test/specs/cta_donate_time.spec.ts b/run/test/specs/cta_donate_time.spec.ts index ee5308ba66..c7e1f46c29 100644 --- a/run/test/specs/cta_donate_time.spec.ts +++ b/run/test/specs/cta_donate_time.spec.ts @@ -67,7 +67,7 @@ async function donateCTADoesntShowSixDaysAgo(platform: SupportedPlatformsType, t await test.step('Verify Donate CTA does not show', async () => { await Promise.all([ device.waitForTextElementToBePresent(new PlusButton(device)), - device.verifyNoCTAShows('donate'), + device.verifyNoCTAShows(), ]); }); await test.step(TestSteps.SETUP.CLOSE_APP, async () => { diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index f3b16249e4..5e9015657d 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -2607,53 +2607,33 @@ export class DeviceWrapper { throw new Error('CTAs must have 1-2 buttons'); } - // Fallback locators for Donate CTA on iOS (no accessibility IDs) - const headingFallback = { - strategy: 'xpath', - selector: `//XCUIElementTypeStaticText[starts-with(@name,'Session Needs')]`, - } as const; - const bodyFallback = { - strategy: 'xpath', - selector: `//XCUIElementTypeStaticText[starts-with(@name,'Powerful forces are trying to')]`, - } as const; - const positiveButtonFallback = { - strategy: 'accessibility id', - selector: 'Donate', - } as const; - const negativeButtonFallback = { - strategy: 'accessibility id', - selector: 'Maybe Later', - } as const; - - // Find and check heading (with fallback for Donate CTA) - const elHeading = await this.findWithFallback(new CTAHeading(this), headingFallback); + // CTA heading + const elHeading = await this.waitForTextElementToBePresent(new CTAHeading(this)); const actualHeading = await this.getTextFromElement(elHeading); this.assertTextMatches(actualHeading, heading, 'CTA heading'); - // Find and check body (with fallback for Donate CTA) - const elBody = await this.findWithFallback(new CTABody(this), bodyFallback); + // CTA body + const elBody = await this.waitForTextElementToBePresent(new CTABody(this)); const actualBody = await this.getTextFromElement(elBody); this.assertTextMatches(actualBody, body, 'CTA body'); - // Check features if expected (Pro CTAs only) + // CTA features if present if (features) { for (let i = 0; i < features.length; i++) { - const featureLocator = new CTAFeature(this, i); - const elFeature = await this.waitForTextElementToBePresent(featureLocator); + const elFeature = await this.waitForTextElementToBePresent(new CTAFeature(this, i)); const actualFeature = await this.getTextFromElement(elFeature); - this.assertTextMatches(actualFeature, features[i], `CTA feature ${i}`); + this.assertTextMatches(actualFeature, features[i], `CTA feature ${i + 1}`); } } - // Check buttons (with fallback for Donate CTA) - const positiveLocator = new CTAButtonPositive(this); - const elPositive = await this.findWithFallback(positiveLocator, positiveButtonFallback); + // CTA positive button + const elPositive = await this.waitForTextElementToBePresent(new CTAButtonPositive(this)); const actualPositive = await this.getTextFromElement(elPositive); this.assertTextMatches(actualPositive, buttons[0], 'CTA positive button'); + // CTA negative button if present if (buttons.length === 2) { - const negativeLocator = new CTAButtonNegative(this); - const elNegative = await this.findWithFallback(negativeLocator, negativeButtonFallback); + const elNegative = await this.waitForTextElementToBePresent(new CTAButtonNegative(this)); const actualNegative = await this.getTextFromElement(elNegative); this.assertTextMatches(actualNegative, buttons[1], 'CTA negative button'); } @@ -2664,35 +2644,17 @@ export class DeviceWrapper { } // This is the bare minimum of a CTA so we only check these - // Features may or may not exist anyway, same goes for negative buttons - public async verifyNoCTAShows(ctaType?: CTAType): Promise { - // For Donate CTA on iOS, check the XPath selectors since accessibility IDs don't exist - if (ctaType === 'donate' && this.isIOS()) { - await Promise.all([ - this.verifyElementNotPresent({ - strategy: 'xpath', - selector: `//XCUIElementTypeStaticText[starts-with(@name,'Session Needs')]`, - }), - this.verifyElementNotPresent({ - strategy: 'xpath', - selector: `//XCUIElementTypeStaticText[starts-with(@name,'Powerful forces are trying to')]`, - }), - this.verifyElementNotPresent({ - strategy: 'accessibility id', - selector: 'Donate', - }), - ]); - } else { - // For all other cases, use the standard CTA locators - await Promise.all([ - this.verifyElementNotPresent(new CTAHeading(this)), - this.verifyElementNotPresent(new CTABody(this)), - this.verifyElementNotPresent(new CTAButtonPositive(this)), - ]); - } + public async verifyNoCTAShows(): Promise { + await Promise.all([ + this.verifyElementNotPresent(new CTAHeading(this)), + this.verifyElementNotPresent(new CTABody(this)), + this.verifyElementNotPresent(new CTAButtonPositive(this)), + ]); } // Dismiss any CTA if it shows + // Note that not every CTA has negative buttons but a vast majority of them do + // And the ones that show and block the automation are likely to be ones with negative button public async dismissCTA(): Promise { const hasCTAAppeared = await this.doesElementExist({ ...new CTAButtonNegative(this).build(), From 37be43eac4460f2ecf37ade52132059d84ba1043 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Thu, 19 Feb 2026 15:05:23 +1100 Subject: [PATCH 25/38] feat: pro activated cta test --- run/screenshots/android/cta_pro_activated.png | 3 ++ ...r_actions_animated_profile_picture.spec.ts | 41 ++++++++++++++++++- run/types/DeviceWrapper.ts | 25 +++++------ run/types/allure.ts | 1 + run/{test/utils/check_cta.ts => types/cta.ts} | 21 +++++++--- run/types/testing.ts | 1 + 6 files changed, 73 insertions(+), 19 deletions(-) create mode 100644 run/screenshots/android/cta_pro_activated.png rename run/{test/utils/check_cta.ts => types/cta.ts} (57%) diff --git a/run/screenshots/android/cta_pro_activated.png b/run/screenshots/android/cta_pro_activated.png new file mode 100644 index 0000000000..c47bd68391 --- /dev/null +++ b/run/screenshots/android/cta_pro_activated.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:35fbdb49f97531ef2cb25b3027846d03ec4dc3590a02be30057770da80db8a27 +size 428879 diff --git a/run/test/specs/user_actions_animated_profile_picture.spec.ts b/run/test/specs/user_actions_animated_profile_picture.spec.ts index 18ff75bae6..34bd4ff22f 100644 --- a/run/test/specs/user_actions_animated_profile_picture.spec.ts +++ b/run/test/specs/user_actions_animated_profile_picture.spec.ts @@ -1,14 +1,16 @@ import { test, type TestInfo } from '@playwright/test'; +import { tStripped } from '../../localizer/lib'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; -import { PathMenuItem, UserAvatar } from '../locators/settings'; +import { PathMenuItem, UserAvatar, UserSettings } from '../locators/settings'; import { IOSTestContext } from '../utils/capabilities_ios'; import { newUser } from '../utils/create_account'; import { makeAccountPro } from '../utils/mock_pro'; import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; import { forceStopAndRestart } from '../utils/utilities'; +import { verifyPageScreenshot } from '../utils/verify_screenshots'; bothPlatformsIt({ title: 'Upload animated profile picture (non Pro)', @@ -32,6 +34,16 @@ bothPlatformsIt({ }, }); +bothPlatformsIt({ + title: 'Pro Activated CTA', + risk: 'medium', + countOfDevicesNeeded: 1, + testCb: proActivatedCTA, + allureSuites: { + parent: 'Session Pro', + }, +}); + async function nonProAnimatedDP(platform: SupportedPlatformsType, testInfo: TestInfo) { const iosContext: IOSTestContext = { sessionProEnabled: 'true', @@ -50,6 +62,33 @@ async function nonProAnimatedDP(platform: SupportedPlatformsType, testInfo: Test await closeApp(device); }); } +async function proActivatedCTA(platform: SupportedPlatformsType, testInfo: TestInfo) { + const iosContext: IOSTestContext = { + sessionProEnabled: 'true', + }; + const { device, alice } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo, iosContext); + const alice = await newUser(device, USERNAME.ALICE); + return { device, alice }; + }); + await makeAccountPro({ user: alice, platform }); + await forceStopAndRestart(device); + await test.step('Verify Pro Activated CTA', async () => { + await device.clickOnElementAll(new UserSettings(device)); + await device.clickOnElementAll(new UserAvatar(device)); + await device.clickOnElementAll({ + strategy: 'id', + selector: 'pro-badge-text', + text: tStripped('proAnimatedDisplayPictureModalDescription'), + }); + await verifyPageScreenshot(device, platform, 'cta_pro_activated', testInfo); + await device.checkCTA('alreadyActivated'); + }); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} + async function proAnimatedDP(platform: SupportedPlatformsType, testInfo: TestInfo) { const iosContext: IOSTestContext = { sessionProEnabled: 'true', diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index 5e9015657d..5583dad076 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -75,9 +75,9 @@ import { clickOnCoordinates, sleepFor } from '../test/utils'; import { getAdbFullPath } from '../test/utils/binaries'; import { parseDataImage } from '../test/utils/check_colour'; import { isSameColor } from '../test/utils/check_colour'; -import { CTAConfig, ctaConfigs, CTAType } from '../test/utils/check_cta'; import { SupportedPlatformsType } from '../test/utils/open_app'; import { isDeviceAndroid, isDeviceIOS, runScriptAndLog } from '../test/utils/utilities'; +import { CTAConfig, ctaConfigs, CTAType } from './cta'; import { AccessibilityId, Coordinates, @@ -2626,16 +2626,18 @@ export class DeviceWrapper { } } - // CTA positive button - const elPositive = await this.waitForTextElementToBePresent(new CTAButtonPositive(this)); - const actualPositive = await this.getTextFromElement(elPositive); - this.assertTextMatches(actualPositive, buttons[0], 'CTA positive button'); + /** + * buttons[0] = negative/dismiss (always present); + * buttons[1] = positive/action (optional) + */ + const elNegative = await this.waitForTextElementToBePresent(new CTAButtonNegative(this)); + const actualNegative = await this.getTextFromElement(elNegative); + this.assertTextMatches(actualNegative, buttons[0], 'CTA negative button'); - // CTA negative button if present if (buttons.length === 2) { - const elNegative = await this.waitForTextElementToBePresent(new CTAButtonNegative(this)); - const actualNegative = await this.getTextFromElement(elNegative); - this.assertTextMatches(actualNegative, buttons[1], 'CTA negative button'); + const elPositive = await this.waitForTextElementToBePresent(new CTAButtonPositive(this)); + const actualPositive = await this.getTextFromElement(elPositive); + this.assertTextMatches(actualPositive, buttons[1], 'CTA positive button'); } } @@ -2648,13 +2650,11 @@ export class DeviceWrapper { await Promise.all([ this.verifyElementNotPresent(new CTAHeading(this)), this.verifyElementNotPresent(new CTABody(this)), - this.verifyElementNotPresent(new CTAButtonPositive(this)), + this.verifyElementNotPresent(new CTAButtonNegative(this)), ]); } // Dismiss any CTA if it shows - // Note that not every CTA has negative buttons but a vast majority of them do - // And the ones that show and block the automation are likely to be ones with negative button public async dismissCTA(): Promise { const hasCTAAppeared = await this.doesElementExist({ ...new CTAButtonNegative(this).build(), @@ -2674,6 +2674,7 @@ export class DeviceWrapper { const pixelColor = await parseDataImage(base64image); return pixelColor; } + // Sample an element's centre pixel color SAMPLE_SIZE times to determine whether it is animated or not. // If the set contains more than 1 color it is likely animated. public async verifyElementIsAnimated(args: LocatorsInterface): Promise { diff --git a/run/types/allure.ts b/run/types/allure.ts index 801ae744f2..5872894b71 100644 --- a/run/types/allure.ts +++ b/run/types/allure.ts @@ -31,6 +31,7 @@ export type AllureSuiteConfig = parent: 'Sending Messages'; suite: 'Emoji reacts' | 'Mentions' | 'Message types' | 'Performance' | 'Rules'; } + | { parent: 'Session Pro' } | { parent: 'Settings'; suite: 'App Disguise' | 'Community Message Requests' | 'Notifications' | 'Recovery Password'; diff --git a/run/test/utils/check_cta.ts b/run/types/cta.ts similarity index 57% rename from run/test/utils/check_cta.ts rename to run/types/cta.ts index 96cc65665e..6e0aa59b1a 100644 --- a/run/test/utils/check_cta.ts +++ b/run/types/cta.ts @@ -1,11 +1,15 @@ -import { tStripped } from '../../localizer/lib'; +import { tStripped } from '../localizer/lib'; -export type CTAType = 'animatedProfilePicture' | 'donate' | 'longerMessages'; +export type CTAType = 'alreadyActivated' | 'animatedProfilePicture' | 'donate' | 'longerMessages'; +/** + * buttons[0] is the negative/dismiss button (always present); + * buttons[1] is the optional positive/action button + */ export type CTAConfig = { heading: string; body: string; - buttons: string[]; + buttons: [string, string] | [string]; features?: string[]; }; @@ -13,12 +17,12 @@ export const ctaConfigs: Record = { donate: { heading: tStripped('donateSessionHelp'), body: tStripped('donateSessionDescription'), - buttons: [tStripped('donate'), tStripped('maybeLater')], + buttons: [tStripped('maybeLater'), tStripped('donate')], }, longerMessages: { heading: tStripped('upgradeTo'), body: tStripped('proCallToActionLongerMessages'), - buttons: [tStripped('theContinue'), tStripped('cancel')], + buttons: [tStripped('cancel'), tStripped('theContinue')], features: [ tStripped('proFeatureListLongerMessages'), tStripped('proFeatureListPinnedConversations'), @@ -28,11 +32,16 @@ export const ctaConfigs: Record = { animatedProfilePicture: { heading: tStripped('upgradeTo'), body: tStripped('proAnimatedDisplayPictureCallToActionDescription'), - buttons: [tStripped('theContinue'), tStripped('cancel')], + buttons: [tStripped('cancel'), tStripped('theContinue')], features: [ tStripped('proFeatureListAnimatedDisplayPicture'), tStripped('proFeatureListLongerMessages'), tStripped('proFeatureListLoadsMore'), ], }, + alreadyActivated: { + heading: tStripped('proActivated'), + body: tStripped('proAnimatedDisplayPicture'), + buttons: [tStripped('close')], + }, }; diff --git a/run/types/testing.ts b/run/types/testing.ts index 08a0ef406a..5c6710d6e3 100644 --- a/run/types/testing.ts +++ b/run/types/testing.ts @@ -634,6 +634,7 @@ export type ScreenshotFileNames = | 'conversation_alice' | 'conversation_bob' | 'cta_donate' + | 'cta_pro_activated' | 'landingpage_new_account' | 'landingpage_restore_account' | 'settings_appearance' From c91d7605d443dfbc3e8c26651fe78d3cfaba2f10 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Fri, 20 Feb 2026 16:20:34 +1100 Subject: [PATCH 26/38] refactor: translate stateuser output to user type --- .../specs/group_tests_add_accountid.spec.ts | 2 +- run/test/specs/upm_homescreen.spec.ts | 2 +- run/test/state_builder/index.ts | 93 +++++++++++++------ run/test/utils/get_account_id.ts | 12 +-- 4 files changed, 71 insertions(+), 38 deletions(-) diff --git a/run/test/specs/group_tests_add_accountid.spec.ts b/run/test/specs/group_tests_add_accountid.spec.ts index 2b70d03f4d..283da19a0d 100644 --- a/run/test/specs/group_tests_add_accountid.spec.ts +++ b/run/test/specs/group_tests_add_accountid.spec.ts @@ -52,7 +52,7 @@ async function addAccountIDToGroup(platform: SupportedPlatformsType, testInfo: T const userD = await test.step(TestSteps.SETUP.NEW_USER, async () => { return newUser(unknown1, USERNAME.DRACULA); }); - const aliceTruncatedPubkey = truncatePubkey(alice.sessionId, platform); + const aliceTruncatedPubkey = truncatePubkey(alice.accountID, platform); const historicMsg = `Hello from ${alice.userName}`; const userDTruncatedPubkey = truncatePubkey(userD.accountID, platform); const userDMsg = `Hello from ${userD.userName}`; diff --git a/run/test/specs/upm_homescreen.spec.ts b/run/test/specs/upm_homescreen.spec.ts index 1ef5c7ee65..aad3222c87 100644 --- a/run/test/specs/upm_homescreen.spec.ts +++ b/run/test/specs/upm_homescreen.spec.ts @@ -48,7 +48,7 @@ async function upmHomeScreen(platform: SupportedPlatformsType, testInfo: TestInf }); const elText = await alice1.getTextFromElement(el); const normalized = elText.replace(/\s+/g, ''); // account id comes in two lines - const expected = bob.sessionId.trim(); + const expected = bob.accountID.trim(); if (normalized !== expected) { console.log(`Expected: ${expected} Observed: ${normalized}`); diff --git a/run/test/state_builder/index.ts b/run/test/state_builder/index.ts index e448a90121..f6a33901b7 100644 --- a/run/test/state_builder/index.ts +++ b/run/test/state_builder/index.ts @@ -14,16 +14,26 @@ import { } from '@session-foundation/qa-seeder'; import type { DeviceWrapper } from '../../types/DeviceWrapper'; +import type { User } from '../../types/testing'; import { ConversationItem } from '../locators/home'; +import { IOSTestContext } from '../utils/capabilities_ios'; import { getNetworkTarget } from '../utils/devnet'; import { openAppMultipleDevices, type SupportedPlatformsType } from '../utils/open_app'; import { restoreAccountNoFallback } from '../utils/restore_account'; -type WithAlice = { alice: StateUser }; -type WithBob = { bob: StateUser }; -type WithCharlie = { charlie: StateUser }; -type WithDracula = { dracula: StateUser }; +function toUser(stateUser: StateUser): User { + return { + userName: stateUser.userName, + accountID: stateUser.sessionId, + recoveryPhrase: stateUser.seedPhrase, + }; +} + +type WithAlice = { alice: User }; +type WithBob = { bob: User }; +type WithCharlie = { charlie: User }; +type WithDracula = { dracula: User }; type WithFocusFriendsConvo = { focusFriendsConvo: boolean }; type WithFocusGroupConvo = { focusGroupConvo: boolean }; @@ -91,14 +101,16 @@ async function openAppsWithState m.seedPhrase); await linkDevices(result.devices, seedPhrases); - const alice = result.prebuilt.users[0]; - const bob = result.prebuilt.users[1]; + const alice = toUser(result.prebuilt.users[0]); + const bob = toUser(result.prebuilt.users[1]); const alice1 = result.devices[0]; const bob1 = result.devices[1]; const formattedDevices = { @@ -156,10 +170,12 @@ export async function open_Alice1_Bob1_Charlie1_friends_group({ groupName, focusGroupConvo, testInfo, + iOSContext, }: WithPlatform & WithFocusGroupConvo & { groupName: string; testInfo: TestInfo; + iOSContext?: IOSTestContext; }) { const stateToBuildKey = '3friendsInGroup'; const appsToOpen = 3; @@ -169,6 +185,7 @@ export async function open_Alice1_Bob1_Charlie1_friends_group({ stateToBuildKey, groupName, testInfo, + iOSContext, }); result.devices[0].setDeviceIdentity('alice1'); result.devices[1].setDeviceIdentity('bob1'); @@ -177,9 +194,9 @@ export async function open_Alice1_Bob1_Charlie1_friends_group({ const seedPhrases = result.prebuilt.users.map(m => m.seedPhrase); await linkDevices(result.devices, seedPhrases); - const alice = result.prebuilt.users[0]; - const bob = result.prebuilt.users[1]; - const charlie = result.prebuilt.users[2]; + const alice = toUser(result.prebuilt.users[0]); + const bob = toUser(result.prebuilt.users[1]); + const charlie = toUser(result.prebuilt.users[2]); const alice1 = result.devices[0]; const bob1 = result.devices[1]; @@ -214,10 +231,12 @@ export async function open_Alice2_Bob1_Charlie1_friends_group({ groupName, focusGroupConvo, testInfo, + iOSContext, }: WithPlatform & WithFocusGroupConvo & { groupName: string; testInfo: TestInfo; + iOSContext?: IOSTestContext; }) { const stateToBuildKey = '3friendsInGroup'; const appsToOpen = 4; @@ -227,15 +246,23 @@ export async function open_Alice2_Bob1_Charlie1_friends_group({ stateToBuildKey, groupName, testInfo, + iOSContext, }); result.devices[0].setDeviceIdentity('alice1'); result.devices[1].setDeviceIdentity('bob1'); result.devices[2].setDeviceIdentity('charlie1'); result.devices[3].setDeviceIdentity('alice2'); - const [alice, bob, charlie] = result.prebuilt.users; + const alice = toUser(result.prebuilt.users[0]); + const bob = toUser(result.prebuilt.users[1]); + const charlie = toUser(result.prebuilt.users[2]); - const seedPhrases = [alice.seedPhrase, bob.seedPhrase, charlie.seedPhrase, alice.seedPhrase]; + const seedPhrases = [ + alice.recoveryPhrase, + bob.recoveryPhrase, + charlie.recoveryPhrase, + alice.recoveryPhrase, + ]; await linkDevices(result.devices, seedPhrases); const [alice1, bob1, charlie1, alice2] = result.devices; @@ -275,10 +302,12 @@ export async function open_Alice1_Bob1_Charlie1_Unknown1({ groupName, focusGroupConvo = true, testInfo, + iOSContext, }: WithPlatform & WithFocusGroupConvo & { groupName: string; testInfo: TestInfo; + iOSContext?: IOSTestContext; }) { const stateToBuildKey = '3friendsInGroup'; const appsToOpen = 4; @@ -288,6 +317,7 @@ export async function open_Alice1_Bob1_Charlie1_Unknown1({ stateToBuildKey, groupName, testInfo, + iOSContext, }); result.devices[0].setDeviceIdentity('alice1'); result.devices[1].setDeviceIdentity('bob1'); @@ -308,9 +338,9 @@ export async function open_Alice1_Bob1_Charlie1_Unknown1({ charlie1, unknown1: result.devices[3], // not assigned yet }; - const alice = result.prebuilt.users[0]; - const bob = result.prebuilt.users[1]; - const charlie = result.prebuilt.users[2]; + const alice = toUser(result.prebuilt.users[0]); + const bob = toUser(result.prebuilt.users[1]); + const charlie = toUser(result.prebuilt.users[2]); const formattedUsers: WithUsers<3> = { alice, bob, @@ -330,7 +360,11 @@ export async function open_Alice1_Bob1_Charlie1_Unknown1({ }; } -export async function open_Alice2({ platform, testInfo }: WithPlatform & { testInfo: TestInfo }) { +export async function open_Alice2({ + platform, + testInfo, + iOSContext, +}: WithPlatform & { testInfo: TestInfo; iOSContext?: IOSTestContext }) { const prebuiltStateKey = '1user'; const appsToOpen = 2; const result = await openAppsWithState({ @@ -339,15 +373,16 @@ export async function open_Alice2({ platform, testInfo }: WithPlatform & { testI stateToBuildKey: prebuiltStateKey, groupName: undefined, testInfo, + iOSContext, }); result.devices[0].setDeviceIdentity('alice1'); result.devices[1].setDeviceIdentity('alice2'); // we want the first user to have the first 2 devices linked - const alice = result.prebuilt.users[0]; + const alice = toUser(result.prebuilt.users[0]); const alice1 = result.devices[0]; const alice2 = result.devices[1]; - const seedPhrases = [alice.seedPhrase, alice.seedPhrase]; + const seedPhrases = [alice.recoveryPhrase, alice.recoveryPhrase]; await linkDevices(result.devices, seedPhrases); const formattedUsers: WithUsers<1> = { @@ -370,7 +405,8 @@ export async function open_Alice2({ platform, testInfo }: WithPlatform & { testI export async function open_Alice1_bob1_notfriends({ platform, testInfo, -}: WithPlatform & { testInfo: TestInfo }) { + iOSContext, +}: WithPlatform & { testInfo: TestInfo; iOSContext?: IOSTestContext }) { const appsToOpen = 2; const result = await openAppsWithState({ platform, @@ -378,16 +414,17 @@ export async function open_Alice1_bob1_notfriends({ stateToBuildKey: '2users', groupName: undefined, testInfo, + iOSContext, }); result.devices[0].setDeviceIdentity('alice1'); result.devices[1].setDeviceIdentity('bob1'); - const alice = result.prebuilt.users[0]; - const bob = result.prebuilt.users[1]; + const alice = toUser(result.prebuilt.users[0]); + const bob = toUser(result.prebuilt.users[1]); const alice1 = result.devices[0]; const bob1 = result.devices[1]; - const seedPhrases = [alice.seedPhrase, bob.seedPhrase]; + const seedPhrases = [alice.recoveryPhrase, bob.recoveryPhrase]; await linkDevices(result.devices, seedPhrases); const formattedUsers: WithUsers<2> = { @@ -408,7 +445,8 @@ export async function open_Alice2_Bob1_friends({ platform, focusFriendsConvo, testInfo, -}: WithPlatform & WithFocusFriendsConvo & { testInfo: TestInfo }) { + iOSContext, +}: WithPlatform & WithFocusFriendsConvo & { testInfo: TestInfo; iOSContext?: IOSTestContext }) { const prebuiltStateKey = '2friends'; const appsToOpen = 3; const result = await openAppsWithState({ @@ -417,14 +455,15 @@ export async function open_Alice2_Bob1_friends({ stateToBuildKey: prebuiltStateKey, groupName: undefined, testInfo, + iOSContext, }); result.devices[0].setDeviceIdentity('alice1'); result.devices[1].setDeviceIdentity('alice2'); result.devices[2].setDeviceIdentity('bob1'); - const alice = result.prebuilt.users[0]; - const bob = result.prebuilt.users[1]; + const alice = toUser(result.prebuilt.users[0]); + const bob = toUser(result.prebuilt.users[1]); // we want the first user to have the first 2 devices linked - const seedPhrases = [alice.seedPhrase, alice.seedPhrase, bob.seedPhrase]; + const seedPhrases = [alice.recoveryPhrase, alice.recoveryPhrase, bob.recoveryPhrase]; await linkDevices(result.devices, seedPhrases); const alice1 = result.devices[0]; diff --git a/run/test/utils/get_account_id.ts b/run/test/utils/get_account_id.ts index 31f1b3b187..1c9a7bd2a3 100644 --- a/run/test/utils/get_account_id.ts +++ b/run/test/utils/get_account_id.ts @@ -1,16 +1,10 @@ -import { StateUser } from '@session-foundation/qa-seeder'; - import { User } from '../../types/testing'; import { SupportedPlatformsType } from './open_app'; -// Sorts users by pubkey hex (StateUser.sessionId from qa-seeder or User.accountID from local types) and returns usernames -export function sortByPubkey(...users: Array) { +// Sorts users by pubkey hex and returns their usernames +export function sortByPubkey(...users: Array) { return [...users] - .sort((a, b) => { - const aKey = 'accountID' in a ? a.accountID : String(a.sessionId); - const bKey = 'accountID' in b ? b.accountID : String(b.sessionId); - return aKey.localeCompare(bKey); - }) + .sort((a, b) => a.accountID.localeCompare(b.accountID)) .map(user => user.userName); } From 5f139c55a8da78f166ecd775e26b0438f2254a00 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Fri, 20 Feb 2026 16:21:11 +1100 Subject: [PATCH 27/38] refactor: standardize describelocator --- run/types/DeviceWrapper.ts | 75 +++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 41 deletions(-) diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index 5583dad076..0eb378eac3 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -82,7 +82,6 @@ import { AccessibilityId, Coordinates, DISAPPEARING_TIMES, - Group, Id, InteractionPoints, Strategy, @@ -571,6 +570,16 @@ export class DeviceWrapper { return []; } + private resolveLocator(args: LocatorsInterface | (StrategyExtractionObj & { text?: string })): { + locator: StrategyExtractionObj; + description: string; + } { + const built = args instanceof LocatorsInterface ? args.build() : args; + const text = args instanceof LocatorsInterface ? undefined : args.text; + const locator = text ? { ...built, text } : built; + return { locator, description: describeLocator(locator) }; + } + /** * Attempts to find an element using a primary locator, and if not found, falls back to a secondary locator. * This is useful for supporting UI transitions (e.g., between legacy and Compose Android screens) where @@ -588,13 +597,10 @@ export class DeviceWrapper { fallbackLocator: LocatorsInterface | StrategyExtractionObj, maxWait: number = 3000 ): Promise { - const primary = - primaryLocator instanceof LocatorsInterface ? primaryLocator.build() : primaryLocator; - const fallback = - fallbackLocator instanceof LocatorsInterface ? fallbackLocator.build() : fallbackLocator; - - const primaryDescription = describeLocator(primary); - const fallbackDescription = describeLocator(fallback); + const { locator: primary, description: primaryDescription } = + this.resolveLocator(primaryLocator); + const { locator: fallback, description: fallbackDescription } = + this.resolveLocator(fallbackLocator); try { return await this.waitForTextElementToBePresent({ ...primary, maxWait, skipHealing: true }); @@ -774,26 +780,22 @@ export class DeviceWrapper { args: { text?: string; maxWait?: number } & (LocatorsInterface | StrategyExtractionObj), options?: { offset?: Coordinates } ): Promise { - const { text, maxWait = 10_000 } = args; - const locator = args instanceof LocatorsInterface ? args.build() : args; + const { maxWait = 10_000 } = args; + const { locator, description } = this.resolveLocator(args); - // Merge text if provided - const finalLocator = text ? { ...locator, text } : locator; - - const displayText = describeLocator(finalLocator); - this.log(`Attempting long press on ${displayText}`); + this.log(`Attempting long press on ${description}`); await this.pollUntil( async () => { // Find the message - this.log(`Looking for: ${JSON.stringify(finalLocator)}`); + this.log(`Looking for: ${JSON.stringify(locator)}`); const el = await this.waitForTextElementToBePresent({ - ...finalLocator, + ...locator, maxWait: 1_000, }); if (!el) { - return { success: false, error: `Message not found: ${displayText}` }; + return { success: false, error: `Message not found: ${description}` }; } if (options?.offset) { this.log(`Offsetting long press by x=${options?.offset?.x}, y=${options?.offset?.y}`); @@ -815,7 +817,7 @@ export class DeviceWrapper { return { success: false, - error: `Long press didn't show context menu for ${displayText}`, + error: `Long press didn't show context menu for ${description}`, }; }, { @@ -1253,7 +1255,7 @@ export class DeviceWrapper { maxWait?: number; } & (LocatorsInterface | StrategyExtractionObj) ): Promise { - const locator = args instanceof LocatorsInterface ? args.build() : args; + const { locator, description } = this.resolveLocator(args); const maxWait = args.maxWait || 2_000; // Wait for any transitions to complete @@ -1261,8 +1263,6 @@ export class DeviceWrapper { const element = await this.findElementQuietly(locator, args.text); - const description = describeLocator({ ...locator, text: args.text }); - if (element) { // Elements can disappear in the GUI but still be present in the DOM let isVisible: boolean; @@ -1307,13 +1307,11 @@ export class DeviceWrapper { maxWait?: number; } & (LocatorsInterface | StrategyExtractionObj) ): Promise { - const locator = args instanceof LocatorsInterface ? args.build() : args; + const { locator, description } = this.resolveLocator(args); const text = args.text; const initialMaxWait = args.initialMaxWait ?? 10_000; const maxWait = args.maxWait ?? 30_000; - const description = describeLocator({ ...locator, text: args.text }); - // Track total time from start - disappearing timers begin on send, not on display const functionStartTime = Date.now(); // Phase 1: Wait for element to appear @@ -1358,13 +1356,11 @@ export class DeviceWrapper { maxWait?: number; } & (LocatorsInterface | StrategyExtractionObj) ): Promise { - const locator = args instanceof LocatorsInterface ? args.build() : args; + const { locator, description } = this.resolveLocator(args); const text = args.text; const initialMaxWait = args.initialMaxWait ?? 10_000; const maxWait = args.maxWait ?? 30_000; - const description = describeLocator({ ...locator, text: args.text }); - // Phase 1: Wait for element to appear this.log(`Waiting for element with ${description} to be deleted...`); await this.waitForElementToAppear(locator, initialMaxWait, text); @@ -1734,8 +1730,7 @@ export class DeviceWrapper { expectedColor: string, tolerance?: number ): Promise { - const locator = args instanceof LocatorsInterface ? args.build() : args; - const description = describeLocator({ ...locator, text: args.text }); + const { locator, description } = this.resolveLocator(args); this.log(`Waiting for ${description} to have color #${expectedColor}`); @@ -1820,14 +1815,6 @@ export class DeviceWrapper { await this.onAndroid().clickOnElementAll(new AcceptMessageRequestButton(this)); } - public async sendMessageTo(sender: User, receiver: Group | User) { - const message = `${sender.userName} to ${receiver.userName}`; - await this.clickOnElementAll(new ConversationItem(this, receiver.userName)); - this.log(`${sender.userName} + " sent message to ${receiver.userName}`); - await this.sendMessage(message); - this.log(`Message received by ${receiver.userName} from ${sender.userName}`); - return message; - } // TODO instead of blind sleeping, check presence of reply preview // Remove blind sleep from other tests that reply as well public async replyToMessage(user: Pick, body: string) { @@ -2666,7 +2653,9 @@ export class DeviceWrapper { } } - public async getElementPixelColor(args: LocatorsInterface): Promise { + public async getElementPixelColor( + args: LocatorsInterface | StrategyExtractionObj + ): Promise { // Wait for the element to be present const element = await this.waitForTextElementToBePresent(args); // Take a screenshot and return a hex color value @@ -2677,11 +2666,15 @@ export class DeviceWrapper { // Sample an element's centre pixel color SAMPLE_SIZE times to determine whether it is animated or not. // If the set contains more than 1 color it is likely animated. - public async verifyElementIsAnimated(args: LocatorsInterface): Promise { + public async verifyElementIsAnimated( + args: LocatorsInterface | StrategyExtractionObj + ): Promise { + const { locator, description } = this.resolveLocator(args); + this.log(`Checking if ${description} is animated`); const SAMPLE_SIZE = 3; const colors = new Set(); for (let i = 0; i < SAMPLE_SIZE; i++) { - colors.add(await this.getElementPixelColor(args)); + colors.add(await this.getElementPixelColor(locator)); } expect( colors.size, From 64ae1cdb583484009f6708628b0ae82281e678f4 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Fri, 20 Feb 2026 16:21:21 +1100 Subject: [PATCH 28/38] feat: add 2-device animated dp test --- ...r_actions_animated_profile_picture.spec.ts | 50 +++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/run/test/specs/user_actions_animated_profile_picture.spec.ts b/run/test/specs/user_actions_animated_profile_picture.spec.ts index 34bd4ff22f..1f1e0153b9 100644 --- a/run/test/specs/user_actions_animated_profile_picture.spec.ts +++ b/run/test/specs/user_actions_animated_profile_picture.spec.ts @@ -4,7 +4,11 @@ import { tStripped } from '../../localizer/lib'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { USERNAME } from '../../types/testing'; +import { CloseSettings } from '../locators'; +import { ConversationSettings, MessageBody } from '../locators/conversation'; +import { ConversationItem } from '../locators/home'; import { PathMenuItem, UserAvatar, UserSettings } from '../locators/settings'; +import { open_Alice1_Bob1_friends } from '../state_builder'; import { IOSTestContext } from '../utils/capabilities_ios'; import { newUser } from '../utils/create_account'; import { makeAccountPro } from '../utils/mock_pro'; @@ -14,7 +18,7 @@ import { verifyPageScreenshot } from '../utils/verify_screenshots'; bothPlatformsIt({ title: 'Upload animated profile picture (non Pro)', - risk: 'medium', + risk: 'high', countOfDevicesNeeded: 1, testCb: nonProAnimatedDP, allureSuites: { @@ -25,7 +29,7 @@ bothPlatformsIt({ bothPlatformsIt({ title: 'Upload animated profile picture (Pro)', - risk: 'medium', + risk: 'high', countOfDevicesNeeded: 1, testCb: proAnimatedDP, allureSuites: { @@ -36,7 +40,7 @@ bothPlatformsIt({ bothPlatformsIt({ title: 'Pro Activated CTA', - risk: 'medium', + risk: 'low', countOfDevicesNeeded: 1, testCb: proActivatedCTA, allureSuites: { @@ -44,6 +48,16 @@ bothPlatformsIt({ }, }); +bothPlatformsIt({ + title: 'Animated Profile Picture shows', + risk: 'high', + countOfDevicesNeeded: 2, + testCb: proAnimatedDPShows, + allureSuites: { + parent: 'Session Pro', + }, +}); + async function nonProAnimatedDP(platform: SupportedPlatformsType, testInfo: TestInfo) { const iosContext: IOSTestContext = { sessionProEnabled: 'true', @@ -110,3 +124,33 @@ async function proAnimatedDP(platform: SupportedPlatformsType, testInfo: TestInf await closeApp(device); }); } + +async function proAnimatedDPShows(platform: SupportedPlatformsType, testInfo: TestInfo) { + const iosContext: IOSTestContext = { + sessionProEnabled: 'true', + }; + const { devices, prebuilt } = await test.step(TestSteps.SETUP.QA_SEEDER, async () => { + return await open_Alice1_Bob1_friends({ + platform, + focusFriendsConvo: false, + testInfo, + iOSContext: iosContext, + }); + }); + const { alice1, bob1 } = devices; + const { alice, bob } = prebuilt; + await makeAccountPro({ user: alice, platform }); + await forceStopAndRestart(alice1); + await test.step(TestSteps.USER_ACTIONS.CHANGE_PROFILE_PICTURE, async () => { + await alice1.uploadProfilePicture(true); + }); + await alice1.clickOnElementAll(new CloseSettings(alice1)); + await alice1.clickOnElementAll(new ConversationItem(alice1, bob.userName)); + await alice1.sendMessage('Howdy'); + await bob1.clickOnElementAll(new ConversationItem(bob1, alice.userName)); + await bob1.waitForTextElementToBePresent(new MessageBody(bob1, 'Howdy')); + await bob1.verifyElementIsAnimated(new ConversationSettings(bob1)); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(alice1, bob1); + }); +} From 77566f0422baa0d0888a7458a909ec68e28a3b2a Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Mon, 23 Feb 2026 11:40:26 +1100 Subject: [PATCH 29/38] feat: log capture on test failure --- run/test/utils/device_registry.ts | 62 ++++++ ...eenshot_helper.ts => failure_artifacts.ts} | 192 +++++++++++------- run/test/utils/open_app.ts | 12 +- run/types/sessionIt.ts | 11 +- 4 files changed, 188 insertions(+), 89 deletions(-) create mode 100644 run/test/utils/device_registry.ts rename run/test/utils/{screenshot_helper.ts => failure_artifacts.ts} (57%) diff --git a/run/test/utils/device_registry.ts b/run/test/utils/device_registry.ts new file mode 100644 index 0000000000..6e29ba6b7c --- /dev/null +++ b/run/test/utils/device_registry.ts @@ -0,0 +1,62 @@ +import type { TestInfo } from '@playwright/test'; + +import { DeviceWrapper } from '../../types/DeviceWrapper'; +import { getAdbFullPath } from './binaries'; +import { androidAppPackage } from './capabilities_android'; +import { SupportedPlatformsType } from './open_app'; +import { runScriptAndLog } from './utilities'; + +export type LogContext = { + startMs: number; // epoch ms — iOS: compared against log file mtime; Android: derived to epoch seconds for adb -T + pid?: string | null; // Android only — null if pidof returned nothing (app not yet running or already dead) +}; + +export type DeviceContext = { + devices: DeviceWrapper[]; + platform: SupportedPlatformsType; + logCtxByUdid?: Map; +}; + +export const deviceRegistry = new Map(); + +export function registryKey(testInfo: TestInfo): string { + return `${testInfo.testId}-${testInfo.parallelIndex}-${testInfo.repeatEachIndex}`; +} + +// Async because Android registration fetches per-device PID for scoped logcat on failure. +export async function registerDevicesForTest( + testInfo: TestInfo, + devices: DeviceWrapper[], + platform: SupportedPlatformsType +) { + const key = registryKey(testInfo); + // Throw if registry already has an entry — indicates a previous test didn't unregister properly + if (deviceRegistry.has(key)) { + throw new Error(`Device registry already contains entry for test "${testInfo.title}"`); + } + + const startMs = Date.now(); + const logCtxByUdid = new Map(); + + if (platform === 'android') { + await Promise.all( + devices.map(async device => { + const pidOutput = await runScriptAndLog( + `${getAdbFullPath()} -s ${device.udid} shell pidof ${androidAppPackage}` + ); + const pid = pidOutput.trim() || null; + logCtxByUdid.set(device.udid, { startMs, pid }); + }) + ); + } else if (platform === 'ios') { + for (const device of devices) { + logCtxByUdid.set(device.udid, { startMs }); + } + } + + deviceRegistry.set(key, { devices, platform, logCtxByUdid }); +} + +export function unregisterDevicesForTest(testInfo: TestInfo) { + deviceRegistry.delete(registryKey(testInfo)); +} diff --git a/run/test/utils/screenshot_helper.ts b/run/test/utils/failure_artifacts.ts similarity index 57% rename from run/test/utils/screenshot_helper.ts rename to run/test/utils/failure_artifacts.ts index 9116ff257e..e0e8a98ab5 100644 --- a/run/test/utils/screenshot_helper.ts +++ b/run/test/utils/failure_artifacts.ts @@ -1,43 +1,19 @@ import type { TestInfo } from '@playwright/test'; +import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import sharp from 'sharp'; import { DeviceWrapper } from '../../types/DeviceWrapper'; +import { getAdbFullPath } from './binaries'; +import { iOSBundleId } from './capabilities_ios'; +import { deviceRegistry, LogContext, registryKey } from './device_registry'; import { SupportedPlatformsType } from './open_app'; +import { runScriptAndLog } from './utilities'; -// Screenshot context type -type ScreenshotContext = { - devices: DeviceWrapper[]; - testInfo: TestInfo; - platform: SupportedPlatformsType; -}; - -// Global registry to track devices for screenshot capture -const deviceRegistry = new Map(); - -// Register devices for a test -export function registerDevicesForTest( - testInfo: TestInfo, - devices: DeviceWrapper[], - platform: SupportedPlatformsType -) { - const testId = `${testInfo.testId}-${testInfo.parallelIndex}-${testInfo.repeatEachIndex}`; - // Throw if deviceRegistry already has an entry for this test - // Could indicate that previous test did not unregister properly - if (deviceRegistry.has(testId)) { - throw new Error(`Device registry already contains entry for test "${testInfo.title}"`); - } - - deviceRegistry.set(testId, { devices, testInfo, platform }); -} +// --- Screenshots --- -// Unregister devices after test -export function unregisterDevicesForTest(testInfo: TestInfo) { - const testId = `${testInfo.testId}-${testInfo.parallelIndex}-${testInfo.repeatEachIndex}`; - deviceRegistry.delete(testId); -} // Add device labels to screenshots (e.g. "Device: alice1") async function addDeviceLabel(screenshot: Buffer, device: DeviceWrapper): Promise { const { width } = await sharp(screenshot).metadata(); @@ -50,11 +26,11 @@ async function addDeviceLabel(screenshot: Buffer, device: DeviceWrapper): Promis - Device: ${deviceName} @@ -63,14 +39,7 @@ async function addDeviceLabel(screenshot: Buffer, device: DeviceWrapper): Promis // Composite label over screenshot return sharp(screenshot) - .composite([ - { - input: label, - top: 0, - left: 0, - blend: 'over', - }, - ]) + .composite([{ input: label, top: 0, left: 0, blend: 'over' }]) .png() .toBuffer(); } @@ -116,14 +85,7 @@ async function createComposite(screenshots: Buffer[]): Promise { const composites = screenshots.map((screenshot, index) => { const col = index % cols; const row = Math.floor(index / cols); - const x = col * (width + gap); - const y = row * (height + gap); - - return { - input: screenshot, - left: x, - top: y, - }; + return { input: screenshot, left: col * (width + gap), top: row * (height + gap) }; }); // Apply all screenshots to canvas @@ -132,8 +94,7 @@ async function createComposite(screenshots: Buffer[]): Promise { // Main screenshot capture function export async function captureScreenshotsOnFailure(testInfo: TestInfo): Promise { - const testId = `${testInfo.testId}-${testInfo.parallelIndex}-${testInfo.repeatEachIndex}`; - const context = deviceRegistry.get(testId); + const context = deviceRegistry.get(registryKey(testInfo)); if (!context || context.devices.length === 0) { console.log('No devices registered for screenshot capture'); @@ -143,30 +104,20 @@ export async function captureScreenshotsOnFailure(testInfo: TestInfo): Promise { try { const base64 = await device.getScreenshot(); - return { - device, - base64, - success: true, - }; + return { device, base64, success: true }; } catch (error) { console.error(`Failed to capture from ${device.getDeviceIdentity()}:`, error); - return { - device, - base64: null, - success: false, - }; + return { device, base64: null, success: false }; } }) ); // Filter out failed captures const successfulCaptures = rawCaptures.filter(c => c.success && c.base64); - if (successfulCaptures.length === 0) { console.log('No screenshots captured successfully'); return; @@ -180,16 +131,19 @@ export async function captureScreenshotsOnFailure(testInfo: TestInfo): Promise => { - if (result.status === 'rejected') { - console.error(`Failed to process screenshot:`, result.reason); - return false; + .filter( + ( + result + ): result is PromiseFulfilledResult<{ device: DeviceWrapper; labeledBuffer: Buffer }> => { + if (result.status === 'rejected') { + console.error(`Failed to process screenshot:`, result.reason); + return false; + } + return true; } - return true; - }) + ) .map(result => result.value.labeledBuffer); if (screenshots.length === 0) { @@ -198,7 +152,6 @@ export async function captureScreenshotsOnFailure(testInfo: TestInfo): Promise { + if (platform === 'android') { + const startEpochSec = (logCtx.startMs / 1000).toFixed(3); + const parts = [ + `${getAdbFullPath()} -s ${device.udid} logcat -d -T ${startEpochSec}`, + ...(logCtx.pid ? [`--pid=${logCtx.pid}`] : []), + ]; + const output = await runScriptAndLog(parts.join(' ')); + return Buffer.from(output); + } + + if (platform === 'ios') { + const containerPath = execSync( + `xcrun simctl get_app_container ${device.udid} ${iOSBundleId} data`, + { encoding: 'utf8' } + ).trim(); + + const logsDir = path.join(containerPath, 'Library', 'Caches', 'Logs'); + + if (!fs.existsSync(logsDir)) { + console.log(`No logs directory found for ${device.getDeviceIdentity()}`); + return null; + } + + const logFiles = fs + .readdirSync(logsDir) + .filter(f => f.startsWith(iOSBundleId) && f.endsWith('.log')) + .map(f => ({ name: f, mtime: fs.statSync(path.join(logsDir, f)).mtimeMs })) + .filter(f => f.mtime >= logCtx.startMs) + .sort((a, b) => b.mtime - a.mtime); + + if (logFiles.length === 0) { + console.log(`No log files found after test start for ${device.getDeviceIdentity()}`); + return null; + } + + return Buffer.from(fs.readFileSync(path.join(logsDir, logFiles[0].name), 'utf8')); + } + + return null; +} + +const MAX_LOG_BYTES = 2 * 1024 * 1024; // 2 MB — tail beyond this to keep reports lean + +function tailBuffer(raw: Buffer): Buffer { + if (raw.length <= MAX_LOG_BYTES) return raw; + + const tail = raw.subarray(raw.length - MAX_LOG_BYTES); + // Advance past any partial line at the cut point + const firstNewline = tail.indexOf('\n'.charCodeAt(0)); + return firstNewline > 0 ? tail.subarray(firstNewline + 1) : tail; +} + +export async function captureLogsOnFailure(testInfo: TestInfo): Promise { + const context = deviceRegistry.get(registryKey(testInfo)); + + if (!context?.logCtxByUdid) { + return; + } + + await Promise.all( + context.devices.map(async device => { + const logCtx = context.logCtxByUdid!.get(device.udid); + if (!logCtx) return; + + try { + const raw = await collectLogBuffer(context.platform, device, logCtx); + if (!raw) return; + + const buffer = tailBuffer(raw); + const label = device.getDeviceIdentity(); + const truncated = raw.length !== buffer.length; + await testInfo.attach(`log-${label}`, { body: buffer, contentType: 'text/plain' }); + console.log( + `Log captured for ${label} (${buffer.length} bytes${truncated ? `, truncated from ${raw.length}` : ''})` + ); + } catch (error) { + console.error(`Failed to capture log for ${device.getDeviceIdentity()}:`, error); + } + }) + ); +} diff --git a/run/test/utils/open_app.ts b/run/test/utils/open_app.ts index bb2821db13..cbc9c96c35 100644 --- a/run/test/utils/open_app.ts +++ b/run/test/utils/open_app.ts @@ -16,8 +16,8 @@ import { iOSBundleId, IOSTestContext, } from './capabilities_ios'; +import { registerDevicesForTest } from './device_registry'; import { cleanPermissions } from './permissions'; -import { registerDevicesForTest } from './screenshot_helper'; import { sleepFor } from './sleep_for'; import { runScriptAndLog } from './utilities'; @@ -42,7 +42,7 @@ export const openAppMultipleDevices = async ( // Map the result to return only the device objects const devices = apps.map(app => app.device); - registerDevicesForTest(testInfo, devices, platform); + await registerDevicesForTest(testInfo, devices, platform); return devices; }; @@ -70,7 +70,7 @@ export const openAppOnPlatformSingleDevice = async ( }> => { const result = await openAppOnPlatform(platform, 0, testInfo, iOSContext); - registerDevicesForTest(testInfo, [result.device], platform); + await registerDevicesForTest(testInfo, [result.device], platform); return result; }; @@ -90,7 +90,7 @@ export const openAppTwoDevices = async ( const result = { device1: app1.device, device2: app2.device }; - registerDevicesForTest(testInfo, Object.values(result), platform); + await registerDevicesForTest(testInfo, Object.values(result), platform); return result; }; @@ -116,7 +116,7 @@ export const openAppThreeDevices = async ( device3: app3.device, }; - registerDevicesForTest(testInfo, Object.values(result), platform); + await registerDevicesForTest(testInfo, Object.values(result), platform); return result; }; @@ -145,7 +145,7 @@ export const openAppFourDevices = async ( device4: app4.device, }; - registerDevicesForTest(testInfo, Object.values(result), platform); + await registerDevicesForTest(testInfo, Object.values(result), platform); return result; }; diff --git a/run/types/sessionIt.ts b/run/types/sessionIt.ts index 0b1be85bbd..e329b16be1 100644 --- a/run/types/sessionIt.ts +++ b/run/types/sessionIt.ts @@ -5,12 +5,10 @@ import { omit } from 'lodash'; import type { AppCountPerTest } from '../test/state_builder'; import { setupAllureTestInfo } from '../test/utils/allure/allureHelpers'; +import { unregisterDevicesForTest } from '../test/utils/device_registry'; import { getNetworkTarget } from '../test/utils/devnet'; +import { captureLogsOnFailure, captureScreenshotsOnFailure } from '../test/utils/failure_artifacts'; import { SupportedPlatformsType } from '../test/utils/open_app'; -import { - captureScreenshotsOnFailure, - unregisterDevicesForTest, -} from '../test/utils/screenshot_helper'; import { AllureSuiteConfig } from './allure'; import { TestRisk } from './testing'; @@ -105,9 +103,10 @@ function mobileIt({ testInfo.status === 'timedOut' ) { await captureScreenshotsOnFailure(testInfo); + await captureLogsOnFailure(testInfo); } - } catch (screenshotError) { - console.error('Failed to capture screenshot:', screenshotError); + } catch (artifactError) { + console.error('Failed to capture failure artifacts:', artifactError); } try { From 086b814697b84c8a453652bec5fa5c325a078fa6 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Mon, 23 Feb 2026 11:52:49 +1100 Subject: [PATCH 30/38] chore: rename tests to fit artifact upload rules --- run/test/specs/recovery_banner.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/run/test/specs/recovery_banner.spec.ts b/run/test/specs/recovery_banner.spec.ts index abf3c048f6..c71967f64c 100644 --- a/run/test/specs/recovery_banner.spec.ts +++ b/run/test/specs/recovery_banner.spec.ts @@ -12,7 +12,7 @@ import { newUser } from '../utils/create_account'; import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; androidIt({ - title: 'Recovery password banner only shows after >2 conversations', + title: 'Recovery password banner only shows after 3 conversations', risk: 'medium', testCb: bannerShowsThreeConvos, countOfDevicesNeeded: 1, @@ -37,7 +37,7 @@ androidIt({ }); androidIt({ - title: 'Recovery password banner persists with <3 conversations', + title: 'Recovery password banner persists with less than 3 conversations', risk: 'medium', testCb: bannerPersists, countOfDevicesNeeded: 1, From 946f0f9b8fa4515b3d1ed563d6d39dff6aa08b5c Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Mon, 23 Feb 2026 12:08:12 +1100 Subject: [PATCH 31/38] feat: differentiate between device and runner logs in allure --- package.json | 3 ++- patches/allure-playwright@3.4.5.patch | 38 +++++++++++++++++++++++++++ pnpm-lock.yaml | 15 ++++++----- run/test/utils/failure_artifacts.ts | 2 +- 4 files changed, 50 insertions(+), 8 deletions(-) create mode 100644 patches/allure-playwright@3.4.5.patch diff --git a/package.json b/package.json index 90d4a89148..7b4aa28d04 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,8 @@ "packageManager": "pnpm@10.28.1", "pnpm": { "patchedDependencies": { - "appium-uiautomator2-driver": "patches/appium-uiautomator2-driver.patch" + "appium-uiautomator2-driver": "patches/appium-uiautomator2-driver.patch", + "allure-playwright@3.4.5": "patches/allure-playwright@3.4.5.patch" }, "ignoredBuiltDependencies": [ "appium-ios-tuntap", diff --git a/patches/allure-playwright@3.4.5.patch b/patches/allure-playwright@3.4.5.patch new file mode 100644 index 0000000000..41c3e62670 --- /dev/null +++ b/patches/allure-playwright@3.4.5.patch @@ -0,0 +1,38 @@ +diff --git a/dist/cjs/index.js b/dist/cjs/index.js +index e09edfdc7f8ddc07f8865a9df6a8895b62f7998c..8af3d392a9a70b971f8fdb0f88e4ed336c5719e0 100644 +--- a/dist/cjs/index.js ++++ b/dist/cjs/index.js +@@ -504,12 +504,12 @@ var AllureReporter = exports.AllureReporter = /*#__PURE__*/function () { + testResult.stage = _allureJsCommons.Stage.FINISHED; + }); + if (result.stdout.length > 0) { +- this.allureRuntime.writeAttachment(testUuid, undefined, "stdout", Buffer.from((0, _sdk.stripAnsi)(result.stdout.join("")), "utf-8"), { ++ this.allureRuntime.writeAttachment(testUuid, undefined, "test runner logs", Buffer.from((0, _sdk.stripAnsi)(result.stdout.join("")), "utf-8"), { + contentType: _allureJsCommons.ContentType.TEXT + }); + } + if (result.stderr.length > 0) { +- this.allureRuntime.writeAttachment(testUuid, undefined, "stderr", Buffer.from((0, _sdk.stripAnsi)(result.stderr.join("")), "utf-8"), { ++ this.allureRuntime.writeAttachment(testUuid, undefined, "test runner errors", Buffer.from((0, _sdk.stripAnsi)(result.stderr.join("")), "utf-8"), { + contentType: _allureJsCommons.ContentType.TEXT + }); + } +diff --git a/dist/esm/index.js b/dist/esm/index.js +index 84ab12679c08c2738e7d9fb53267f950c60cfb86..b55ef16e6900f444aee1944cfa286baa37e6369c 100644 +--- a/dist/esm/index.js ++++ b/dist/esm/index.js +@@ -486,12 +486,12 @@ export var AllureReporter = /*#__PURE__*/function () { + testResult.stage = Stage.FINISHED; + }); + if (result.stdout.length > 0) { +- this.allureRuntime.writeAttachment(testUuid, undefined, "stdout", Buffer.from(stripAnsi(result.stdout.join("")), "utf-8"), { ++ this.allureRuntime.writeAttachment(testUuid, undefined, "test runner logs", Buffer.from(stripAnsi(result.stdout.join("")), "utf-8"), { + contentType: ContentType.TEXT + }); + } + if (result.stderr.length > 0) { +- this.allureRuntime.writeAttachment(testUuid, undefined, "stderr", Buffer.from(stripAnsi(result.stderr.join("")), "utf-8"), { ++ this.allureRuntime.writeAttachment(testUuid, undefined, "test runner errors", Buffer.from(stripAnsi(result.stderr.join("")), "utf-8"), { + contentType: ContentType.TEXT + }); + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b1d65c0b2..ce24ef4f2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ overrides: tar-fs@>=3.0.0 <3.0.7: '>=3.0.7' patchedDependencies: + allure-playwright@3.4.5: + hash: 8445e5b25f59024b1c2c8950f356f63676ad3ea2a153b051a41eff8ca5f3b305 + path: patches/allure-playwright@3.4.5.patch appium-uiautomator2-driver: hash: 8226be3d8d63cd3e3963f8450fc068a726a9a71eddecad1a612f92bdbd92d121 path: patches/appium-uiautomator2-driver.patch @@ -94,10 +97,10 @@ importers: version: 2.36.0 allure-js-commons: specifier: ^3.4.5 - version: 3.4.5(allure-playwright@3.4.5(@playwright/test@1.58.2)) + version: 3.4.5(allure-playwright@3.4.5(patch_hash=8445e5b25f59024b1c2c8950f356f63676ad3ea2a153b051a41eff8ca5f3b305)(@playwright/test@1.58.2)) allure-playwright: specifier: ^3.4.5 - version: 3.4.5(@playwright/test@1.58.2) + version: 3.4.5(patch_hash=8445e5b25f59024b1c2c8950f356f63676ad3ea2a153b051a41eff8ca5f3b305)(@playwright/test@1.58.2) eslint: specifier: ^10.0.0 version: 10.0.0 @@ -3786,16 +3789,16 @@ snapshots: allure-commandline@2.36.0: {} - allure-js-commons@3.4.5(allure-playwright@3.4.5(@playwright/test@1.58.2)): + allure-js-commons@3.4.5(allure-playwright@3.4.5(patch_hash=8445e5b25f59024b1c2c8950f356f63676ad3ea2a153b051a41eff8ca5f3b305)(@playwright/test@1.58.2)): dependencies: md5: 2.3.0 optionalDependencies: - allure-playwright: 3.4.5(@playwright/test@1.58.2) + allure-playwright: 3.4.5(patch_hash=8445e5b25f59024b1c2c8950f356f63676ad3ea2a153b051a41eff8ca5f3b305)(@playwright/test@1.58.2) - allure-playwright@3.4.5(@playwright/test@1.58.2): + allure-playwright@3.4.5(patch_hash=8445e5b25f59024b1c2c8950f356f63676ad3ea2a153b051a41eff8ca5f3b305)(@playwright/test@1.58.2): dependencies: '@playwright/test': 1.58.2 - allure-js-commons: 3.4.5(allure-playwright@3.4.5(@playwright/test@1.58.2)) + allure-js-commons: 3.4.5(allure-playwright@3.4.5(patch_hash=8445e5b25f59024b1c2c8950f356f63676ad3ea2a153b051a41eff8ca5f3b305)(@playwright/test@1.58.2)) ansi-regex@5.0.1: {} diff --git a/run/test/utils/failure_artifacts.ts b/run/test/utils/failure_artifacts.ts index e0e8a98ab5..3fd772322e 100644 --- a/run/test/utils/failure_artifacts.ts +++ b/run/test/utils/failure_artifacts.ts @@ -270,7 +270,7 @@ export async function captureLogsOnFailure(testInfo: TestInfo): Promise { const buffer = tailBuffer(raw); const label = device.getDeviceIdentity(); const truncated = raw.length !== buffer.length; - await testInfo.attach(`log-${label}`, { body: buffer, contentType: 'text/plain' }); + await testInfo.attach(`device-log-${label}`, { body: buffer, contentType: 'text/plain' }); console.log( `Log captured for ${label} (${buffer.length} bytes${truncated ? `, truncated from ${raw.length}` : ''})` ); From 9db8044d31c0324c72059b46c838a6fbb319b017 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Mon, 23 Feb 2026 15:49:11 +1100 Subject: [PATCH 32/38] chore: update community url --- run/constants/community.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run/constants/community.ts b/run/constants/community.ts index 04a67d5806..47d38483d5 100644 --- a/run/constants/community.ts +++ b/run/constants/community.ts @@ -1,2 +1,2 @@ -export const testCommunityLink = `https://chat.lokinet.dev/testing-all-the-things?public_key=1d7e7f92b1ed3643855c98ecac02fc7274033a3467653f047d6e433540c03f17`; +export const testCommunityLink = `https://test-chat.session.codes/testing-all-the-things?public_key=1d7e7f92b1ed3643855c98ecac02fc7274033a3467653f047d6e433540c03f17`; export const testCommunityName = `Testing All The Things!`; From b8f64ef62c86cd3b1ef8a288f55ddc4e1f247c6b Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Mon, 23 Feb 2026 16:41:26 +1100 Subject: [PATCH 33/38] refactor: speed up tests --- run/test/specs/disappear_after_read.spec.ts | 7 +- run/test/specs/disappear_after_send.spec.ts | 7 +- .../specs/disappear_after_send_groups.spec.ts | 2 +- .../disappear_after_send_note_to_self.spec.ts | 6 +- .../disappear_after_send_off_1o1.spec.ts | 9 +- run/test/specs/disappearing_call.spec.ts | 4 +- .../disappearing_community_invite.spec.ts | 2 +- run/test/specs/disappearing_gif.spec.ts | 2 +- run/test/specs/disappearing_image.spec.ts | 2 +- run/test/specs/disappearing_link.spec.ts | 4 +- .../disappearing_messages_defaults.spec.ts | 126 ++++++++++++++++++ ...appearing_messages_follow_settings.spec.ts | 60 +++++++++ run/test/specs/disappearing_video.spec.ts | 2 +- run/test/specs/disappearing_voice.spec.ts | 2 +- .../group_disappearing_messages_gif.spec.ts | 2 +- .../group_disappearing_messages_image.spec.ts | 2 +- .../group_disappearing_messages_link.spec.ts | 2 +- .../group_disappearing_messages_video.spec.ts | 2 +- .../group_disappearing_messages_voice.spec.ts | 2 +- run/test/specs/group_tests_promote.spec.ts | 8 +- run/test/utils/create_group.ts | 31 ++--- run/test/utils/restore_account.ts | 1 - run/test/utils/set_disappearing_messages.ts | 33 +---- 23 files changed, 220 insertions(+), 98 deletions(-) create mode 100644 run/test/specs/disappearing_messages_defaults.spec.ts create mode 100644 run/test/specs/disappearing_messages_follow_settings.spec.ts diff --git a/run/test/specs/disappear_after_read.spec.ts b/run/test/specs/disappear_after_read.spec.ts index 1b9773eba3..d80ac55070 100644 --- a/run/test/specs/disappear_after_read.spec.ts +++ b/run/test/specs/disappear_after_read.spec.ts @@ -41,12 +41,7 @@ async function disappearAfterRead(platform: SupportedPlatformsType, testInfo: Te let sentTimestamp: number; // Click conversation options menu (three dots) await test.step(TestSteps.DISAPPEARING_MESSAGES.SET(time), async () => { - await setDisappearingMessage( - platform, - alice1, - ['1:1', `Disappear after ${mode} option`, time], - bob1 - ); + await setDisappearingMessage(alice1, ['1:1', `Disappear after ${mode} option`, time]); }); // Check control message is correct on device 2 await test.step(TestSteps.VERIFY.DISAPPEARING_CONTROL_MESSAGES, async () => { diff --git a/run/test/specs/disappear_after_send.spec.ts b/run/test/specs/disappear_after_send.spec.ts index e1c6b6d702..780122c56a 100644 --- a/run/test/specs/disappear_after_send.spec.ts +++ b/run/test/specs/disappear_after_send.spec.ts @@ -35,12 +35,7 @@ async function disappearAfterSend(platform: SupportedPlatformsType, testInfo: Te const time = DISAPPEARING_TIMES.THIRTY_SECONDS; const maxWait = 35_000; // 30s plus buffer // Select disappearing messages option - await setDisappearingMessage( - platform, - alice1, - ['1:1', `Disappear after ${mode} option`, time], - bob1 - ); + await setDisappearingMessage(alice1, ['1:1', `Disappear after ${mode} option`, time]); // Get control message based on key from json file await checkDisappearingControlMessage( platform, diff --git a/run/test/specs/disappear_after_send_groups.spec.ts b/run/test/specs/disappear_after_send_groups.spec.ts index 0ad50a7e1e..dc16293b06 100644 --- a/run/test/specs/disappear_after_send_groups.spec.ts +++ b/run/test/specs/disappear_after_send_groups.spec.ts @@ -41,7 +41,7 @@ async function disappearAfterSendGroups(platform: SupportedPlatformsType, testIn }); }); await test.step(TestSteps.DISAPPEARING_MESSAGES.SET(time), async () => { - await setDisappearingMessage(platform, alice1, ['Group', `Disappear after send option`, time]); + await setDisappearingMessage(alice1, ['Group', `Disappear after send option`, time]); }); await test.step(TestSteps.VERIFY.DISAPPEARING_CONTROL_MESSAGES, async () => { // Get correct control message for You setting disappearing messages diff --git a/run/test/specs/disappear_after_send_note_to_self.spec.ts b/run/test/specs/disappear_after_send_note_to_self.spec.ts index f5d77399d3..42199065d8 100644 --- a/run/test/specs/disappear_after_send_note_to_self.spec.ts +++ b/run/test/specs/disappear_after_send_note_to_self.spec.ts @@ -46,11 +46,7 @@ async function disappearAfterSendNoteToSelf(platform: SupportedPlatformsType, te }); await test.step(TestSteps.DISAPPEARING_MESSAGES.SET(time), async () => { // Enable disappearing messages - await setDisappearingMessage(platform, device, [ - 'Note to Self', - 'Disappear after send option', - time, - ]); + await setDisappearingMessage(device, ['Note to Self', 'Disappear after send option', time]); await sleepFor(1000); await device.waitForControlMessageToBePresent( `You set messages to disappear ${time} after they have been ${controlMode}.` diff --git a/run/test/specs/disappear_after_send_off_1o1.spec.ts b/run/test/specs/disappear_after_send_off_1o1.spec.ts index fcef4670d0..998d9688c6 100644 --- a/run/test/specs/disappear_after_send_off_1o1.spec.ts +++ b/run/test/specs/disappear_after_send_off_1o1.spec.ts @@ -12,7 +12,6 @@ import { SetDisappearMessagesButton, } from '../locators/disappearing_messages'; import { open_Alice2_Bob1_friends } from '../state_builder'; -import { sleepFor } from '../utils'; import { checkDisappearingControlMessage } from '../utils/disappearing_control_messages'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; import { setDisappearingMessage } from '../utils/set_disappearing_messages'; @@ -40,12 +39,7 @@ async function disappearAfterSendOff1o1(platform: SupportedPlatformsType, testIn const controlMode: DisappearActions = 'sent'; const time = DISAPPEARING_TIMES.THIRTY_SECONDS; // Select disappearing messages option - await setDisappearingMessage( - platform, - alice1, - ['1:1', `Disappear after ${mode} option`, time], - bob1 - ); + await setDisappearingMessage(alice1, ['1:1', `Disappear after ${mode} option`, time]); // Get control message based on key from json file await checkDisappearingControlMessage( platform, @@ -78,7 +72,6 @@ async function disappearAfterSendOff1o1(platform: SupportedPlatformsType, testIn ]); // Follow setting on device 2 await bob1.clickOnElementAll(new FollowSettingsButton(bob1)); - await sleepFor(500); await bob1.checkModalStrings( tStripped('disappearingMessagesFollowSetting'), tStripped('disappearingMessagesFollowSettingOff') diff --git a/run/test/specs/disappearing_call.spec.ts b/run/test/specs/disappearing_call.spec.ts index ceba5b8d02..09ba62b0a4 100644 --- a/run/test/specs/disappearing_call.spec.ts +++ b/run/test/specs/disappearing_call.spec.ts @@ -44,7 +44,7 @@ async function disappearingCallMessage1o1Ios(platform: SupportedPlatformsType, t focusFriendsConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); await alice1.clickOnElementAll(new CallButton(alice1)); // Alice turns on all calls perms necessary (without checking every modal string) await alice1.clickOnByAccessibilityID('Settings'); @@ -127,7 +127,7 @@ async function disappearingCallMessage1o1Android( focusFriendsConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); await alice1.clickOnElementAll(new CallButton(alice1)); // Alice turns on all calls perms necessary (without checking every modal string) await alice1.clickOnElementAll({ diff --git a/run/test/specs/disappearing_community_invite.spec.ts b/run/test/specs/disappearing_community_invite.spec.ts index a7bb7b1487..d529f85464 100644 --- a/run/test/specs/disappearing_community_invite.spec.ts +++ b/run/test/specs/disappearing_community_invite.spec.ts @@ -47,7 +47,7 @@ async function disappearingCommunityInviteMessage( focusFriendsConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); // await alice1.navigateBack(); await alice1.navigateBack(); await joinCommunity(alice1, communities.testCommunity.link, communities.testCommunity.name); diff --git a/run/test/specs/disappearing_gif.spec.ts b/run/test/specs/disappearing_gif.spec.ts index 812e47773e..7a0a3853ce 100644 --- a/run/test/specs/disappearing_gif.spec.ts +++ b/run/test/specs/disappearing_gif.spec.ts @@ -33,7 +33,7 @@ async function disappearingGifMessage1o1(platform: SupportedPlatformsType, testI focusFriendsConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); const sentTimestamp = await alice1.sendGIF(); await bob1.trustAttachments(USERNAME.ALICE); await Promise.all( diff --git a/run/test/specs/disappearing_image.spec.ts b/run/test/specs/disappearing_image.spec.ts index afa7d4313e..cdd35573e5 100644 --- a/run/test/specs/disappearing_image.spec.ts +++ b/run/test/specs/disappearing_image.spec.ts @@ -33,7 +33,7 @@ async function disappearingImageMessage1o1(platform: SupportedPlatformsType, tes focusFriendsConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); const sentTimestamp = await alice1.sendImage(testMessage); await bob1.trustAttachments(alice.userName); if (platform === 'ios') { diff --git a/run/test/specs/disappearing_link.spec.ts b/run/test/specs/disappearing_link.spec.ts index adf01dc066..4ac26ff1b3 100644 --- a/run/test/specs/disappearing_link.spec.ts +++ b/run/test/specs/disappearing_link.spec.ts @@ -51,7 +51,7 @@ async function disappearingLinkMessage1o1Ios(platform: SupportedPlatformsType, t }); }); await test.step(TestSteps.DISAPPEARING_MESSAGES.SET(time), async () => { - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); }); await test.step(TestSteps.SEND.LINK, async () => { await alice1.inputText(testLink, new MessageInput(alice1)); @@ -105,7 +105,7 @@ async function disappearingLinkMessage1o1Android( }); }); await test.step(TestSteps.DISAPPEARING_MESSAGES.SET(time), async () => { - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time]); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); }); await test.step(TestSteps.SEND.LINK, async () => { await alice1.inputText(testLink, new MessageInput(alice1)); diff --git a/run/test/specs/disappearing_messages_defaults.spec.ts b/run/test/specs/disappearing_messages_defaults.spec.ts new file mode 100644 index 0000000000..a356de22e9 --- /dev/null +++ b/run/test/specs/disappearing_messages_defaults.spec.ts @@ -0,0 +1,126 @@ +import type { TestInfo } from '@playwright/test'; + +import { bothPlatformsIt } from '../../types/sessionIt'; +import { DISAPPEARING_TIMES, GROUPNAME, USERNAME } from '../../types/testing'; +import { ConversationSettings } from '../locators/conversation'; +import { + DisappearingMessagesMenuOption, + DisappearingMessagesTimerType, +} from '../locators/disappearing_messages'; +import { PlusButton } from '../locators/home'; +import { EnterAccountID, NewMessageOption, NextButton } from '../locators/start_conversation'; +import { + open_Alice1_Bob1_Charlie1_friends_group, + open_Alice1_Bob1_friends, +} from '../state_builder'; +import { newUser } from '../utils/create_account'; +import { closeApp, openAppOnPlatformSingleDevice, SupportedPlatformsType } from '../utils/open_app'; + +bothPlatformsIt({ + title: 'Disappearing messages defaults 1:1', + risk: 'medium', + testCb: disappearingMessagesDefaults1o1, + countOfDevicesNeeded: 2, + allureSuites: { + parent: 'Disappearing Messages', + suite: 'Conversation Types', + }, + allureDescription: 'Verifies the default selected timer for each DM mode in a 1:1 conversation', +}); + +bothPlatformsIt({ + title: 'Disappearing messages defaults group', + risk: 'medium', + testCb: disappearingMessagesDefaultsGroup, + countOfDevicesNeeded: 3, + allureSuites: { + parent: 'Disappearing Messages', + suite: 'Conversation Types', + }, + allureDescription: 'Verifies the default selected timer in a group conversation', +}); + +bothPlatformsIt({ + title: 'Disappearing messages defaults note to self', + risk: 'medium', + testCb: disappearingMessagesDefaultsNoteToSelf, + countOfDevicesNeeded: 1, + allureSuites: { + parent: 'Disappearing Messages', + suite: 'Conversation Types', + }, + allureDescription: 'Verifies the default selected timer in Note to Self', +}); + +async function disappearingMessagesDefaults1o1( + platform: SupportedPlatformsType, + testInfo: TestInfo +) { + const { + devices: { alice1, bob1 }, + } = await open_Alice1_Bob1_friends({ platform, focusFriendsConvo: true, testInfo }); + + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + await alice1.clickOnElementAll(new DisappearingMessagesMenuOption(alice1)); + + // Disappear after read: default should be 12 hours + await alice1.clickOnElementAll( + new DisappearingMessagesTimerType(alice1, 'Disappear after read option') + ); + await alice1.disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.TWELVE_HOURS); + + // Disappear after send: default should be 1 day + await alice1.clickOnElementAll( + new DisappearingMessagesTimerType(alice1, 'Disappear after send option') + ); + await alice1.disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.ONE_DAY); + + await closeApp(alice1, bob1); +} + +async function disappearingMessagesDefaultsGroup( + platform: SupportedPlatformsType, + testInfo: TestInfo +) { + const testGroupName: GROUPNAME = 'Testing disappearing messages'; + const { + devices: { alice1, bob1, charlie1 }, + } = await open_Alice1_Bob1_Charlie1_friends_group({ + platform, + groupName: testGroupName, + focusGroupConvo: true, + testInfo, + }); + + await alice1.clickOnElementAll(new ConversationSettings(alice1)); + await alice1.clickOnElementAll(new DisappearingMessagesMenuOption(alice1)); + + // Group defaults: disappear after send should be OFF + await alice1.onIOS().disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.OFF_IOS); + await alice1.onAndroid().disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.OFF_ANDROID); + + await closeApp(alice1, bob1, charlie1); +} + +async function disappearingMessagesDefaultsNoteToSelf( + platform: SupportedPlatformsType, + testInfo: TestInfo +) { + const { device } = await openAppOnPlatformSingleDevice(platform, testInfo); + const alice = await newUser(device, USERNAME.ALICE); + + await device.clickOnElementAll(new PlusButton(device)); + await device.clickOnElementAll(new NewMessageOption(device)); + await device.inputText(alice.accountID, new EnterAccountID(device)); + await device.scrollDown(); + await device.clickOnElementAll(new NextButton(device)); + + await device.clickOnElementAll(new ConversationSettings(device)); + await device.clickOnElementAll(new DisappearingMessagesMenuOption(device)); + + // Note to Self defaults: disappear after send should be OFF + await device.onIOS().disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.OFF_IOS); + await device.onAndroid().disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.OFF_ANDROID); + + await closeApp(device); +} diff --git a/run/test/specs/disappearing_messages_follow_settings.spec.ts b/run/test/specs/disappearing_messages_follow_settings.spec.ts new file mode 100644 index 0000000000..49955f0134 --- /dev/null +++ b/run/test/specs/disappearing_messages_follow_settings.spec.ts @@ -0,0 +1,60 @@ +import type { TestInfo } from '@playwright/test'; + +import { tStripped } from '../../localizer/lib'; +import { bothPlatformsIt } from '../../types/sessionIt'; +import { DISAPPEARING_TIMES } from '../../types/testing'; +import { + DisappearingMessagesSubtitle, + FollowSettingsButton, + SetModalButton, +} from '../locators/disappearing_messages'; +import { open_Alice1_Bob1_friends } from '../state_builder'; +import { closeApp, SupportedPlatformsType } from '../utils/open_app'; +import { setDisappearingMessage } from '../utils/set_disappearing_messages'; + +bothPlatformsIt({ + title: 'Disappearing messages follow setting 1:1', + risk: 'medium', + testCb: disappearingMessagesFollowSetting1o1, + countOfDevicesNeeded: 2, + allureSuites: { + parent: 'Disappearing Messages', + suite: 'Conversation Types', + }, + allureDescription: + 'Verifies that Bob sees the Follow Setting banner when Alice sets disappearing messages in a 1:1 conversation, and that following applies the setting to both sides', +}); + +const time = DISAPPEARING_TIMES.THIRTY_SECONDS; +const timerType = 'Disappear after send option'; + +async function disappearingMessagesFollowSetting1o1( + platform: SupportedPlatformsType, + testInfo: TestInfo +) { + const { + devices: { alice1, bob1 }, + } = await open_Alice1_Bob1_friends({ platform, focusFriendsConvo: true, testInfo }); + + await setDisappearingMessage(alice1, ['1:1', timerType, time]); + + // Bob should see the follow settings banner after Alice sets DM + await bob1.clickOnElementAll(new FollowSettingsButton(bob1)); + await bob1.checkModalStrings( + tStripped('disappearingMessagesFollowSetting'), + tStripped('disappearingMessagesFollowSettingOn', { + time, + disappearing_messages_type: 'sent', + }) + ); + await bob1.clickOnElementAll(new SetModalButton(bob1)); + + // Both should now show the DM subtitle + await Promise.all( + [alice1, bob1].map(device => + device.waitForTextElementToBePresent(new DisappearingMessagesSubtitle(device)) + ) + ); + + await closeApp(alice1, bob1); +} diff --git a/run/test/specs/disappearing_video.spec.ts b/run/test/specs/disappearing_video.spec.ts index 9c81c6d418..fa36394514 100644 --- a/run/test/specs/disappearing_video.spec.ts +++ b/run/test/specs/disappearing_video.spec.ts @@ -35,7 +35,7 @@ async function disappearingVideoMessage1o1(platform: SupportedPlatformsType, tes focusFriendsConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); let sentTimestamp: number; if (platform === 'ios') { sentTimestamp = await alice1.onIOS().sendVideoiOS(testMessage); diff --git a/run/test/specs/disappearing_voice.spec.ts b/run/test/specs/disappearing_voice.spec.ts index ba6718a829..26c218904c 100644 --- a/run/test/specs/disappearing_voice.spec.ts +++ b/run/test/specs/disappearing_voice.spec.ts @@ -32,7 +32,7 @@ async function disappearingVoiceMessage1o1(platform: SupportedPlatformsType, tes focusFriendsConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['1:1', timerType, time], bob1); + await setDisappearingMessage(alice1, ['1:1', timerType, time]); const sentTimestamp = await alice1.sendVoiceMessage(); await bob1.trustAttachments(alice.userName); await Promise.all( diff --git a/run/test/specs/group_disappearing_messages_gif.spec.ts b/run/test/specs/group_disappearing_messages_gif.spec.ts index 9c9c70eef0..cec83e9dfd 100644 --- a/run/test/specs/group_disappearing_messages_gif.spec.ts +++ b/run/test/specs/group_disappearing_messages_gif.spec.ts @@ -35,7 +35,7 @@ async function disappearingGifMessageGroup(platform: SupportedPlatformsType, tes focusGroupConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['Group', timerType, time]); + await setDisappearingMessage(alice1, ['Group', timerType, time]); // Click on attachments button const sentTimestamp = await alice1.sendGIF(); await Promise.all( diff --git a/run/test/specs/group_disappearing_messages_image.spec.ts b/run/test/specs/group_disappearing_messages_image.spec.ts index 47ffbddf62..762bced731 100644 --- a/run/test/specs/group_disappearing_messages_image.spec.ts +++ b/run/test/specs/group_disappearing_messages_image.spec.ts @@ -34,7 +34,7 @@ async function disappearingImageMessageGroup(platform: SupportedPlatformsType, t testInfo, }); - await setDisappearingMessage(platform, alice1, ['Group', timerType, time]); + await setDisappearingMessage(alice1, ['Group', timerType, time]); const sentTimestamp = await alice1.sendImage(testMessage); if (platform === 'ios') { await Promise.all( diff --git a/run/test/specs/group_disappearing_messages_link.spec.ts b/run/test/specs/group_disappearing_messages_link.spec.ts index 226834583d..ec9ef96f91 100644 --- a/run/test/specs/group_disappearing_messages_link.spec.ts +++ b/run/test/specs/group_disappearing_messages_link.spec.ts @@ -47,7 +47,7 @@ async function disappearingLinkMessageGroup(platform: SupportedPlatformsType, te }); }); await test.step(TestSteps.DISAPPEARING_MESSAGES.SET(time), async () => { - await setDisappearingMessage(platform, alice1, ['Group', timerType, time]); + await setDisappearingMessage(alice1, ['Group', timerType, time]); }); await test.step(TestSteps.SEND.LINK, async () => { await alice1.inputText(testLink, new MessageInput(alice1)); diff --git a/run/test/specs/group_disappearing_messages_video.spec.ts b/run/test/specs/group_disappearing_messages_video.spec.ts index 20d3d048c1..1121e05a57 100644 --- a/run/test/specs/group_disappearing_messages_video.spec.ts +++ b/run/test/specs/group_disappearing_messages_video.spec.ts @@ -36,7 +36,7 @@ async function disappearingVideoMessageGroup(platform: SupportedPlatformsType, t focusGroupConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['Group', timerType, time]); + await setDisappearingMessage(alice1, ['Group', timerType, time]); let sentTimestamp: number; if (platform === 'ios') { sentTimestamp = await alice1.sendVideoiOS(testMessage); diff --git a/run/test/specs/group_disappearing_messages_voice.spec.ts b/run/test/specs/group_disappearing_messages_voice.spec.ts index 25862d0a7c..b933dcc83c 100644 --- a/run/test/specs/group_disappearing_messages_voice.spec.ts +++ b/run/test/specs/group_disappearing_messages_voice.spec.ts @@ -31,7 +31,7 @@ async function disappearingVoiceMessageGroup(platform: SupportedPlatformsType, t focusGroupConvo: true, testInfo, }); - await setDisappearingMessage(platform, alice1, ['Group', timerType, time]); + await setDisappearingMessage(alice1, ['Group', timerType, time]); const sentTimestamp = await alice1.sendVoiceMessage(); await Promise.all( [bob1, charlie1].map(device => device.onAndroid().trustAttachments(testGroupName)) diff --git a/run/test/specs/group_tests_promote.spec.ts b/run/test/specs/group_tests_promote.spec.ts index 0420b68853..5ad2cf7971 100644 --- a/run/test/specs/group_tests_promote.spec.ts +++ b/run/test/specs/group_tests_promote.spec.ts @@ -134,7 +134,7 @@ async function promoteSoloToAdmin(platform: SupportedPlatformsType, testInfo: Te await alice1.navigateBack(); await test.step(`Verify ${bob.userName} has admin powers by setting disappearing messages`, async () => { // Check to see if Bob has admin powers by setting disappearing messages - await setDisappearingMessage(platform, bob1, ['Group', timerType, time]); + await setDisappearingMessage(bob1, ['Group', timerType, time]); await Promise.all( [alice1, charlie1].map(device => device.waitForControlMessageToBePresent( @@ -224,7 +224,7 @@ async function promoteSoloLinked(platform: SupportedPlatformsType, testInfo: Tes await device1.navigateBack(); await test.step(`Verify ${bob.userName} has admin powers by setting disappearing messages`, async () => { // Check to see if Bob has admin powers by setting disappearing messages - await setDisappearingMessage(platform, device2, ['Group', timerType, time]); + await setDisappearingMessage(device2, ['Group', timerType, time]); await Promise.all( [device1, device3].map(device => device.waitForControlMessageToBePresent( @@ -331,7 +331,7 @@ async function promoteMultiToAdmin(platform: SupportedPlatformsType, testInfo: T await alice1.navigateBack(); await test.step(`Verify ${bob.userName} has admin powers by setting disappearing messages`, async () => { // Check to see if Bob has admin powers by setting disappearing messages - await setDisappearingMessage(platform, bob1, ['Group', timerType, time]); + await setDisappearingMessage(bob1, ['Group', timerType, time]); await Promise.all( [alice1, charlie1].map(device => device.waitForControlMessageToBePresent( @@ -351,7 +351,7 @@ async function promoteMultiToAdmin(platform: SupportedPlatformsType, testInfo: T await test.step(`Verify ${charlie.userName} has admin powers by setting disappearing messages`, async () => { // Check to see if Bob has admin powers by setting disappearing messages const charlieTime = DISAPPEARING_TIMES.TWELVE_HOURS; - await setDisappearingMessage(platform, charlie1, ['Group', timerType, charlieTime]); + await setDisappearingMessage(charlie1, ['Group', timerType, charlieTime]); await Promise.all( [alice1, bob1].map(device => device.waitForControlMessageToBePresent( diff --git a/run/test/utils/create_group.ts b/run/test/utils/create_group.ts index cbf49f2491..55820a0aa1 100644 --- a/run/test/utils/create_group.ts +++ b/run/test/utils/create_group.ts @@ -9,7 +9,6 @@ import { CreateGroupOption } from '../locators/start_conversation'; import { newContact } from './create_contact'; import { sortByPubkey } from './get_account_id'; import { SupportedPlatformsType } from './open_app'; -import { sleepFor } from './sleep_for'; export const createGroup = async ( platform: SupportedPlatformsType, @@ -48,7 +47,6 @@ export const createGroup = async ( await device1.clickOnElementAll({ ...new Contact(device1).build(), text: userThree.userName }); // Select tick await device1.clickOnElementAll(new CreateGroupButton(device1)); - await sleepFor(3000); // Enter group chat on device 2 and 3 await Promise.all([ device2.onAndroid().navigateBack(false), @@ -76,26 +74,17 @@ export const createGroup = async ( ), ]); } - // Send message from User A to group to verify all working - await device1.sendMessage(aliceMessage); - // Did the other devices receive alice's message? - await Promise.all( - [device2, device3].map(device => - device.waitForTextElementToBePresent(new MessageBody(device, aliceMessage)) - ) - ); - // Send message from User B to group - await device2.sendMessage(bobMessage); - await Promise.all( - [device1, device3].map(device => - device.waitForTextElementToBePresent(new MessageBody(device, bobMessage)) - ) - ); - // Send message to User C to group - await device3.sendMessage(charlieMessage); + // Send messages from all three users simultaneously to verify group is working + await Promise.all([ + device1.sendMessage(aliceMessage), + device2.sendMessage(bobMessage), + device3.sendMessage(charlieMessage), + ]); + // Verify all messages are visible on all devices + const allMessages = [aliceMessage, bobMessage, charlieMessage]; await Promise.all( - [device1, device2].map(device => - device.waitForTextElementToBePresent(new MessageBody(device, charlieMessage)) + [device1, device2, device3].flatMap(device => + allMessages.map(message => device.waitForTextElementToBePresent(new MessageBody(device, message))) ) ); return { userName, userOne, userTwo, userThree }; diff --git a/run/test/utils/restore_account.ts b/run/test/utils/restore_account.ts index ae93f62d8d..26b7c95923 100644 --- a/run/test/utils/restore_account.ts +++ b/run/test/utils/restore_account.ts @@ -82,7 +82,6 @@ export const restoreAccountNoFallback = async ( // Wait for permissions modal to pop up await sleepFor(500); await handleNotificationPermissions(device, allowNotificationPermissions); - await sleepFor(1000); // Check that we're on the home screen await device.waitForTextElementToBePresent(new PlusButton(device)); }; diff --git a/run/test/utils/set_disappearing_messages.ts b/run/test/utils/set_disappearing_messages.ts index b35ad1b257..29683962ad 100644 --- a/run/test/utils/set_disappearing_messages.ts +++ b/run/test/utils/set_disappearing_messages.ts @@ -6,52 +6,21 @@ import { DisappearingMessagesMenuOption, DisappearingMessagesSubtitle, DisappearingMessagesTimerType, - FollowSettingsButton, SetDisappearMessagesButton, - SetModalButton, } from '../locators/disappearing_messages'; -import { SupportedPlatformsType } from './open_app'; -import { sleepFor } from './sleep_for'; export const setDisappearingMessage = async ( - platform: SupportedPlatformsType, device: DeviceWrapper, - [conversationType, timerType, timerDuration = DISAPPEARING_TIMES.THIRTY_SECONDS]: MergedOptions, - device2?: DeviceWrapper + [conversationType, timerType, timerDuration = DISAPPEARING_TIMES.THIRTY_SECONDS]: MergedOptions ) => { const enforcedType: ConversationType = conversationType; await device.clickOnElementAll(new ConversationSettings(device)); - // Wait for UI to load conversation options menu - await sleepFor(500); await device.clickOnElementAll(new DisappearingMessagesMenuOption(device)); if (enforcedType === '1:1') { await device.clickOnElementAll(new DisappearingMessagesTimerType(device, timerType)); } - if (timerType === 'Disappear after read option') { - if (enforcedType === '1:1') { - await device.disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.TWELVE_HOURS); - } else { - await device.disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.ONE_DAY); - } - } else if ( - enforcedType === 'Group' || - (enforcedType === 'Note to Self' && timerType === 'Disappear after send option') - ) { - await device.onIOS().disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.OFF_IOS); - await device.onAndroid().disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.OFF_ANDROID); - } else { - await device.disappearRadioButtonSelected(platform, DISAPPEARING_TIMES.ONE_DAY); - } - await device.clickOnElementAll(new DisappearingMessageRadial(device, timerDuration)); await device.clickOnElementAll(new SetDisappearMessagesButton(device)); await device.navigateBack(); - // Extended the wait for the Follow settings button to settle in the UI, it was moving and confusing appium - await sleepFor(2000); - if (device2) { - await device2.clickOnElementAll(new FollowSettingsButton(device2)); - await sleepFor(500); - await device2.clickOnElementAll(new SetModalButton(device2)); - } await device.waitForTextElementToBePresent(new DisappearingMessagesSubtitle(device)); }; From 427fdadc309b2b99fb52ba7c65b49804db324f36 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Mon, 23 Feb 2026 17:08:16 +1100 Subject: [PATCH 34/38] refactor: parallelize matchAndTapImage --- run/test/utils/click_by_coordinates.ts | 2 - run/test/utils/create_group.ts | 4 +- run/types/DeviceWrapper.ts | 168 +++++++++++-------------- 3 files changed, 78 insertions(+), 96 deletions(-) diff --git a/run/test/utils/click_by_coordinates.ts b/run/test/utils/click_by_coordinates.ts index 707649b88d..83320ff976 100644 --- a/run/test/utils/click_by_coordinates.ts +++ b/run/test/utils/click_by_coordinates.ts @@ -1,10 +1,8 @@ import { DeviceWrapper } from '../../types/DeviceWrapper'; import { Coordinates } from '../../types/testing'; -import { sleepFor } from './sleep_for'; export const clickOnCoordinates = async (device: DeviceWrapper, coordinates: Coordinates) => { const { x, y } = coordinates; - await sleepFor(1000); await device.pressCoordinates(x, y); device.log(`Tapped coordinates ${x}, ${y}`); }; diff --git a/run/test/utils/create_group.ts b/run/test/utils/create_group.ts index 55820a0aa1..a5e1bc4910 100644 --- a/run/test/utils/create_group.ts +++ b/run/test/utils/create_group.ts @@ -84,7 +84,9 @@ export const createGroup = async ( const allMessages = [aliceMessage, bobMessage, charlieMessage]; await Promise.all( [device1, device2, device3].flatMap(device => - allMessages.map(message => device.waitForTextElementToBePresent(new MessageBody(device, message))) + allMessages.map(message => + device.waitForTextElementToBePresent(new MessageBody(device, message)) + ) ) ); return { userName, userOne, userTwo, userThree }; diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index 0eb378eac3..d72de0c5d3 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -1096,120 +1096,105 @@ export class DeviceWrapper { */ public async matchAndTapImage( locator: StrategyExtractionObj, - referenceImageName: string, - earlyMatch: boolean = true + referenceImageName: string ): Promise { const threshold = 0.85; - const earlyMatchThreshold = 0.97; - // Find all candidate elements matching the locator - const elements = await this.findElements(locator.strategy, locator.selector); + // Retry findElements with exponential backoff — photo picker may not have rendered yet + let elements = await this.findElements(locator.strategy, locator.selector); + if (elements.length === 0) { + let delay = 100; + const maxWait = 5000; + const start = Date.now(); + while (elements.length === 0 && Date.now() - start < maxWait) { + await sleepFor(delay); + delay = Math.min(delay * 2, 1600); + elements = await this.findElements(locator.strategy, locator.selector); + } + } + this.info( `[matchAndTapImage] Starting image matching: ${elements.length} elements with ${locator.strategy} "${locator.selector}"` ); - // Load the reference image buffer from disk + // Load the reference image buffer from disk once const referencePath = path.join('run', 'test', 'media', referenceImageName); await fs.access(referencePath).catch(() => { throw new Error(`Reference image not found: ${referencePath}`); }); const referenceBuffer = await fs.readFile(referencePath); + // Hoist reference metadata — it never changes across elements + const refMeta = await sharp(referenceBuffer).metadata(); - let bestMatch: { - center: { x: number; y: number }; - score: number; - } | null = null; - - // Iterate over each candidate element - for (const el of elements) { - // Take a screenshot of the element - const base64 = await this.getElementScreenshot(el.ELEMENT); - const elementBuffer = Buffer.from(base64, 'base64'); + // Phase 1: screenshot + comparison in parallel — no rect yet + const results = await Promise.all( + elements.map(async el => { + const base64 = await this.getElementScreenshot(el.ELEMENT); + const elementBuffer = Buffer.from(base64, 'base64'); - // Get the element's rectangle (position and size) - const rect = await this.getElementRect(el.ELEMENT); - if (!rect) { - continue; - } - // Get actual pixel dimensions of the element screenshot - const elementMeta = await sharp(elementBuffer).metadata(); - // Get original reference image dimensions - const refMeta = await sharp(referenceBuffer).metadata(); + const elementMeta = await sharp(elementBuffer).metadata(); - let resizedRef: Buffer; + let resizedRef: Buffer; + let resizedMeta: Awaited>; - if (elementMeta.width === refMeta.width && elementMeta.height === refMeta.height) { - // Skip resizing if reference already matches the screenshot dimensions - resizedRef = referenceBuffer; - } else { - // Resize the reference image to exactly match the screenshot dimensions - const targetWidth = elementMeta.width; - const targetHeight = elementMeta.height; + if (elementMeta.width === refMeta.width && elementMeta.height === refMeta.height) { + // Skip resizing if reference already matches the screenshot dimensions + resizedRef = referenceBuffer; + resizedMeta = refMeta; + } else { + resizedRef = await sharp(referenceBuffer) + .resize(elementMeta.width, elementMeta.height) + .toBuffer(); + resizedMeta = await sharp(resizedRef).metadata(); + } - resizedRef = await sharp(referenceBuffer).resize(targetWidth, targetHeight).toBuffer(); - } + try { + const { rect: matchRect, score } = await getImageOccurrence(elementBuffer, resizedRef, { + threshold, + }); + return { el, matchRect, score, resizedMeta }; + } catch { + return null; + } + }) + ); - try { - const { rect: matchRect, score } = await getImageOccurrence(elementBuffer, resizedRef, { - threshold, - }); + type MatchResult = NonNullable<(typeof results)[number]>; + const bestResult = results + .filter((r): r is MatchResult => r !== null) + .reduce((best, r) => (!best || r.score > best.score ? r : best), null); - /** - * Matching is done on a resized reference image to account for device pixel density. - * However, the coordinates returned by getImageOccurrence are relative to the resized buffer, - * *not* the original screen element. This leads to incorrect tap positions unless we - * scale the match result back down to the actual dimensions of the element. - * The logic below handles this scaling correction, ensuring the tap lands at the correct - * screen coordinates — even when Retina displays and image resizing are involved. - */ - - // Calculate scale between resized image and element dimensions - const resizedMeta = await sharp(resizedRef).metadata(); - const scaleX = rect.width / (resizedMeta.width ?? rect.width); - const scaleY = rect.height / (resizedMeta.height ?? rect.height); - - // Calculate center of the match rectangle (in buffer space) - const matchCenterX = matchRect.x + Math.floor(matchRect.width / 2); - const matchCenterY = matchRect.y + Math.floor(matchRect.height / 2); - - // Scale match center down to element space - const scaledCenterX = matchCenterX * scaleX; - const scaledCenterY = matchCenterY * scaleY; - - // Final absolute coordinates - const tapX = Math.round(rect.x + scaledCenterX); - const tapY = Math.round(rect.y + scaledCenterY); - - const center = { x: tapX, y: tapY }; - - // If earlyMatch is enabled and the score is high enough, tap immediately - if (earlyMatch && score >= earlyMatchThreshold) { - this.info( - `[matchAndTapImage] Tapping first high-confidence match (${(score * 100).toFixed(2)}%)` - ); - await clickOnCoordinates(this, center); - return; - } - // Otherwise, keep track of the best match so far - if (!bestMatch || score > bestMatch.score) { - bestMatch = { center, score }; - } - } catch { - continue; // No match in this element, try next - } - } - // If no good match was found, throw an error - if (!bestMatch) { + if (!bestResult) { console.log( `[matchAndTapImage] No matching image found among ${elements.length} elements for ${locator.strategy} "${locator.selector}"` ); throw new Error('Unable to find the expected UI element on screen'); } - // Tap the best match found - this.info( - `[matchAndTapImage] Tapping best match with ${(bestMatch.score * 100).toFixed(2)}% confidence` - ); - await clickOnCoordinates(this, bestMatch.center); + + // Phase 2: fetch rect only for the winning element + const rect = await this.getElementRect(bestResult.el.ELEMENT); + + if (!rect) { + throw new Error('Unable to get rect for matched element'); + } + + /** + * Matching is done on a resized reference image to account for device pixel density. + * However, the coordinates returned by getImageOccurrence are relative to the resized buffer, + * *not* the original screen element. This leads to incorrect tap positions unless we + * scale the match result back down to the actual dimensions of the element. + * The logic below handles this scaling correction, ensuring the tap lands at the correct + * screen coordinates — even when Retina displays and image resizing are involved. + */ + const { matchRect, resizedMeta } = bestResult; + const scaleX = rect.width / (resizedMeta.width ?? rect.width); + const scaleY = rect.height / (resizedMeta.height ?? rect.height); + const matchCenterX = matchRect.x + Math.floor(matchRect.width / 2); + const matchCenterY = matchRect.y + Math.floor(matchRect.height / 2); + const tapX = Math.round(rect.x + matchCenterX * scaleX); + const tapY = Math.round(rect.y + matchCenterY * scaleY); + + await clickOnCoordinates(this, { x: tapX, y: tapY }); } /** * Checks if an element exists on the screen without throwing an error. @@ -1961,7 +1946,6 @@ export class DeviceWrapper { await this.clickOnElementAll(new ImagesFolderButton(this)); await sleepFor(1000); await this.modalPopup({ strategy: 'accessibility id', selector: 'Allow Full Access' }); - await sleepFor(2000); // Appium needs a moment, matchAndTapImage sometimes finds 0 elements otherwise await this.matchAndTapImage( { strategy: 'xpath', selector: `//XCUIElementTypeCell` }, testImage @@ -2008,7 +1992,6 @@ export class DeviceWrapper { strategy: 'accessibility id', selector: 'Allow Full Access', }); - await sleepFor(2000); // Appium needs a moment, matchAndTapImage sometimes finds 0 elements otherwise // A video can't be matched by its thumbnail so we use a video thumbnail file await this.matchAndTapImage( { strategy: 'xpath', selector: `//XCUIElementTypeCell` }, @@ -2227,7 +2210,6 @@ export class DeviceWrapper { // iOS files are pre-loaded on simulator creation, no need to push if (this.isIOS()) { await this.modalPopup({ strategy: 'accessibility id', selector: 'Allow Full Access' }); - await sleepFor(5000); // sometimes Appium doesn't recognize the XPATH immediately await this.matchAndTapImage( { strategy: 'xpath', selector: `//XCUIElementTypeImage` }, uploadPicture From 5cd28de18270f205ae39cbffd50a68be0178529f Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Mon, 23 Feb 2026 17:12:03 +1100 Subject: [PATCH 35/38] chore: update comment --- run/types/DeviceWrapper.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index d72de0c5d3..73c7fa47ce 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -1083,15 +1083,14 @@ export class DeviceWrapper { const message = await this.findMatchingTextAndAccessibilityId('Message body', textToLookFor); return message; } + /** - * Attempts to visually match a reference image against all elements found by the given locator, - * and taps the best match (or the first high-confidence match if earlyMatch is enabled). - * This is useful for scenarios where UI elements cannot be reliably identified, - * such as elements with date-based accessibility IDs. + * Attempts to visually match a reference image against all instances found by the given locator, and taps the best match. + * All element screenshots are taken in parallel. + * If the method finds 0 results for a locator, retries with exponential backoff up to 5 seconds. * * @param locator - The strategy and selector to find candidate elements. * @param referenceImageName - The filename of the reference image (in the media directory). - * @param earlyMatch - If true, taps immediately on the first match above the earlyMatchThreshold. * @throws If no suitable match is found among the candidate elements. */ public async matchAndTapImage( @@ -1123,10 +1122,10 @@ export class DeviceWrapper { throw new Error(`Reference image not found: ${referencePath}`); }); const referenceBuffer = await fs.readFile(referencePath); - // Hoist reference metadata — it never changes across elements + // Reference metadata never changes across elements const refMeta = await sharp(referenceBuffer).metadata(); - // Phase 1: screenshot + comparison in parallel — no rect yet + // Phase 1: screenshot + comparison in parallel const results = await Promise.all( elements.map(async el => { const base64 = await this.getElementScreenshot(el.ELEMENT); @@ -1171,7 +1170,7 @@ export class DeviceWrapper { throw new Error('Unable to find the expected UI element on screen'); } - // Phase 2: fetch rect only for the winning element + // Phase 2: fetch rect only for the winning element to determine tap coords const rect = await this.getElementRect(bestResult.el.ELEMENT); if (!rect) { @@ -1196,6 +1195,7 @@ export class DeviceWrapper { await clickOnCoordinates(this, { x: tapX, y: tapY }); } + /** * Checks if an element exists on the screen without throwing an error. * Only useful for scenarios where you want to interact with an element if it exists From 4f14521b231b1d1fb59b1e0f60c9905da48f41d6 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Tue, 24 Feb 2026 10:04:43 +1100 Subject: [PATCH 36/38] chore: trim logs more aggressively --- run/test/utils/failure_artifacts.ts | 2 +- run/types/DeviceWrapper.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/run/test/utils/failure_artifacts.ts b/run/test/utils/failure_artifacts.ts index 3fd772322e..9f82121c34 100644 --- a/run/test/utils/failure_artifacts.ts +++ b/run/test/utils/failure_artifacts.ts @@ -240,7 +240,7 @@ async function collectLogBuffer( return null; } -const MAX_LOG_BYTES = 2 * 1024 * 1024; // 2 MB — tail beyond this to keep reports lean +const MAX_LOG_BYTES = 1024 * 1024; // 1 MB — tail beyond this to keep reports lean function tailBuffer(raw: Buffer): Buffer { if (raw.length <= MAX_LOG_BYTES) return raw; diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index 73c7fa47ce..550fb419b6 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -1918,6 +1918,9 @@ export class DeviceWrapper { }); if (this.isIOS()) { // Push file to simulator + this.warn( + `pushMediaToDevice on iOS is deprecated. Consider pre-loading it on simulator creation` + ); await runScriptAndLog(`xcrun simctl addmedia ${this.udid} ${filePath}`, true); } else if (this.isAndroid()) { const ANDROID_DOWNLOAD_DIR = '/storage/emulated/0/Download'; From b0ffb012149395c8cfe247e6109717688e0e6d17 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Tue, 24 Feb 2026 10:18:40 +1100 Subject: [PATCH 37/38] fix: wait for empty state to disappear when joining community --- run/test/utils/community.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run/test/utils/community.ts b/run/test/utils/community.ts index 9001f333b5..d547bf27de 100644 --- a/run/test/utils/community.ts +++ b/run/test/utils/community.ts @@ -16,7 +16,7 @@ export const joinCommunity = async ( await device.inputText(communityLink, new CommunityInput(device)); await device.clickOnElementAll(new JoinCommunityButton(device)); await device.waitForTextElementToBePresent(new ConversationHeaderName(device, communityName)); - await device.verifyElementNotPresent(new EmptyConversation(device)); // checking that messages loaded already + await device.hasElementBeenDeleted(new EmptyConversation(device)); // checking that messages loaded already await device.scrollToBottom(); }; From 7fe749f03df42f975950b7293a98bb983e157c75 Mon Sep 17 00:00:00 2001 From: Miklos Mandoki Date: Tue, 24 Feb 2026 11:03:18 +1100 Subject: [PATCH 38/38] refactor: get rid of disappearing control messages util --- run/test/specs/disappear_after_read.spec.ts | 25 ++++---- run/test/specs/disappear_after_send.spec.ts | 27 +++++---- .../disappear_after_send_off_1o1.spec.ts | 27 +++++---- .../utils/disappearing_control_messages.ts | 60 ------------------- 4 files changed, 46 insertions(+), 93 deletions(-) delete mode 100644 run/test/utils/disappearing_control_messages.ts diff --git a/run/test/specs/disappear_after_read.spec.ts b/run/test/specs/disappear_after_read.spec.ts index d80ac55070..68964e5454 100644 --- a/run/test/specs/disappear_after_read.spec.ts +++ b/run/test/specs/disappear_after_read.spec.ts @@ -1,11 +1,11 @@ import { test, type TestInfo } from '@playwright/test'; +import { tStripped } from '../../localizer/lib'; import { TestSteps } from '../../types/allure'; import { bothPlatformsIt } from '../../types/sessionIt'; import { DISAPPEARING_TIMES, DisappearModes } from '../../types/testing'; import { MessageBody } from '../locators/conversation'; import { open_Alice1_Bob1_friends } from '../state_builder'; -import { checkDisappearingControlMessage } from '../utils/disappearing_control_messages'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; import { setDisappearingMessage } from '../utils/set_disappearing_messages'; @@ -43,17 +43,20 @@ async function disappearAfterRead(platform: SupportedPlatformsType, testInfo: Te await test.step(TestSteps.DISAPPEARING_MESSAGES.SET(time), async () => { await setDisappearingMessage(alice1, ['1:1', `Disappear after ${mode} option`, time]); }); - // Check control message is correct on device 2 + // Check control messages on both devices await test.step(TestSteps.VERIFY.DISAPPEARING_CONTROL_MESSAGES, async () => { - await checkDisappearingControlMessage( - platform, - alice.userName, - bob.userName, - alice1, - bob1, - time, - mode - ); + await Promise.all([ + alice1.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSetYou', { time, disappearing_messages_type: mode }) + ), + bob1.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSet', { + name: alice.userName, + time, + disappearing_messages_type: mode, + }) + ), + ]); }); // Send message to verify that deletion is working await test.step(TestSteps.SEND.MESSAGE(alice.userName, bob.userName), async () => { diff --git a/run/test/specs/disappear_after_send.spec.ts b/run/test/specs/disappear_after_send.spec.ts index 780122c56a..7e5638c4a3 100644 --- a/run/test/specs/disappear_after_send.spec.ts +++ b/run/test/specs/disappear_after_send.spec.ts @@ -1,10 +1,10 @@ import type { TestInfo } from '@playwright/test'; +import { tStripped } from '../../localizer/lib'; import { bothPlatformsIt } from '../../types/sessionIt'; import { DisappearActions, DISAPPEARING_TIMES, DisappearModes } from '../../types/testing'; import { MessageBody } from '../locators/conversation'; import { open_Alice1_Bob1_friends } from '../state_builder'; -import { checkDisappearingControlMessage } from '../utils/disappearing_control_messages'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; import { setDisappearingMessage } from '../utils/set_disappearing_messages'; @@ -23,7 +23,7 @@ bothPlatformsIt({ async function disappearAfterSend(platform: SupportedPlatformsType, testInfo: TestInfo) { const { devices: { alice1, bob1 }, - prebuilt: { alice, bob }, + prebuilt: { alice }, } = await open_Alice1_Bob1_friends({ platform, focusFriendsConvo: true, @@ -36,16 +36,19 @@ async function disappearAfterSend(platform: SupportedPlatformsType, testInfo: Te const maxWait = 35_000; // 30s plus buffer // Select disappearing messages option await setDisappearingMessage(alice1, ['1:1', `Disappear after ${mode} option`, time]); - // Get control message based on key from json file - await checkDisappearingControlMessage( - platform, - alice.userName, - bob.userName, - alice1, - bob1, - time, - controlMode - ); + // Check control messages on both devices + await Promise.all([ + alice1.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSetYou', { time, disappearing_messages_type: controlMode }) + ), + bob1.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSet', { + name: alice.userName, + time, + disappearing_messages_type: controlMode, + }) + ), + ]); // Send message to verify that deletion is working const sentTimestamp = await alice1.sendMessage(testMessage); // Wait for message to disappear diff --git a/run/test/specs/disappear_after_send_off_1o1.spec.ts b/run/test/specs/disappear_after_send_off_1o1.spec.ts index 998d9688c6..ac1dd9e5a9 100644 --- a/run/test/specs/disappear_after_send_off_1o1.spec.ts +++ b/run/test/specs/disappear_after_send_off_1o1.spec.ts @@ -11,8 +11,8 @@ import { FollowSettingsButton, SetDisappearMessagesButton, } from '../locators/disappearing_messages'; +import { ConversationItem } from '../locators/home'; import { open_Alice2_Bob1_friends } from '../state_builder'; -import { checkDisappearingControlMessage } from '../utils/disappearing_control_messages'; import { closeApp, SupportedPlatformsType } from '../utils/open_app'; import { setDisappearingMessage } from '../utils/set_disappearing_messages'; @@ -40,17 +40,24 @@ async function disappearAfterSendOff1o1(platform: SupportedPlatformsType, testIn const time = DISAPPEARING_TIMES.THIRTY_SECONDS; // Select disappearing messages option await setDisappearingMessage(alice1, ['1:1', `Disappear after ${mode} option`, time]); - // Get control message based on key from json file - await checkDisappearingControlMessage( - platform, - alice.userName, - bob.userName, - alice1, - bob1, + // Check control messages on both devices and sync to linked device + const setYouMsg = tStripped('disappearingMessagesSetYou', { time, - controlMode, + disappearing_messages_type: controlMode, + }); + await Promise.all([ + alice1.waitForControlMessageToBePresent(setYouMsg), + bob1.waitForControlMessageToBePresent( + tStripped('disappearingMessagesSet', { + name: alice.userName, + time, + disappearing_messages_type: controlMode, + }) + ), alice2 - ); + .clickOnElementAll(new ConversationItem(alice2, bob.userName)) + .then(() => alice2.waitForControlMessageToBePresent(setYouMsg)), + ]); // Turn off disappearing messages on device 1 await alice1.clickOnElementAll(new ConversationSettings(alice1)); diff --git a/run/test/utils/disappearing_control_messages.ts b/run/test/utils/disappearing_control_messages.ts deleted file mode 100644 index db29c27023..0000000000 --- a/run/test/utils/disappearing_control_messages.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { UserNameType } from '@session-foundation/qa-seeder'; - -import { tStripped } from '../../localizer/lib'; -import { DeviceWrapper } from '../../types/DeviceWrapper'; -import { DisappearActions, DISAPPEARING_TIMES } from '../../types/testing'; -import { ConversationItem } from '../locators/home'; -import { SupportedPlatformsType } from './open_app'; - -export const checkDisappearingControlMessage = async ( - platform: SupportedPlatformsType, - userNameA: UserNameType, - userNameB: UserNameType, - device1: DeviceWrapper, - device2: DeviceWrapper, - time: DISAPPEARING_TIMES, - mode: DisappearActions, - linkedDevice?: DeviceWrapper -) => { - // Two control messages to check - You have set and other user has set - // "disappearingMessagesSet": "{name} has set messages to disappear {time} after they have been {disappearing_messages_type}.", - const disappearingMessagesSetAlice = tStripped('disappearingMessagesSet', { - name: userNameA, - time, - disappearing_messages_type: mode, - }); - const disappearingMessagesSetBob = tStripped('disappearingMessagesSet', { - name: userNameB, - time, - disappearing_messages_type: mode, - }); - // "disappearingMessagesSetYou": "You set messages to disappear {time} after they have been {disappearing_messages_type}.", - const disappearingMessagesSetYou = tStripped('disappearingMessagesSetYou', { - time, - disappearing_messages_type: mode, - }); - // Check device 1 - if (platform === 'android') { - await Promise.all([ - device1.waitForControlMessageToBePresent(disappearingMessagesSetYou), - device1.waitForControlMessageToBePresent(disappearingMessagesSetBob), - ]); - // Check device 2 - await Promise.all([ - device2.waitForControlMessageToBePresent(disappearingMessagesSetYou), - device2.waitForControlMessageToBePresent(disappearingMessagesSetAlice), - ]); - } - if (platform === 'ios') { - await Promise.all([ - device1.waitForControlMessageToBePresent(disappearingMessagesSetYou), - device2.waitForControlMessageToBePresent(disappearingMessagesSetAlice), - ]); - } - // Check if control messages are syncing from both user A and user B - if (linkedDevice) { - await linkedDevice.clickOnElementAll(new ConversationItem(linkedDevice, userNameB)); - await linkedDevice.waitForControlMessageToBePresent(disappearingMessagesSetYou); - await linkedDevice.waitForControlMessageToBePresent(disappearingMessagesSetBob); - } -};