diff --git a/CLAUDE.md b/CLAUDE.md index 8e1b76169..e0a6f8490 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -327,6 +327,39 @@ an existing spec (e.g. `run/test/specs/app_disguise_icons.spec.ts`). - The `text` filter is an **exact** match after normalisation (`findMatchingTextInElementArray`), not a substring one — asserting a prefix of a long message fails against a body that is present and correct. +- **Address by id, then assert the copy.** An id says the client rendered the right *control*; only the + copy says it rendered the right *words* in it, and the two fail independently — a control keeps its + identifier through a copy change, so an id-only lookup stays green against a wrong, empty or swapped + string. On a destructive or irreversible flow that is the difference between pressing Cancel and + pressing Clear. So a spec that acts on a labelled control should check both. + + Where the copy lives is per-platform, and it is not a preference: + + - **iOS** puts it on `label`. An accessibility identifier becomes the element's `name` and displaces + the display text, so `label` is the only place left (`findMatchingLabelInElementArray`). + - **Desktop** takes `text` in `waitForElement`'s options — `:has-text()`, already a substring match. + - **Android** Compose *controls* report no text of their own: the label is a child node, so the node + addressed by id has nothing to compare. Only text-bearing nodes (a dialog body, a heading) can be + checked in place. `expectControlCopy` (`run/test/utils/element_copy.ts`) does the platform split and + skips loudly rather than passing quietly. + + A trap worth knowing about copy that spans a `
`. `tStripped` collapses the break to a single + space, and a locator `text`/`label` filter compares against the raw rendered value — where the break + is a newline on mobile and nothing at all in a DOM `textContent`. So the filter never matches. Read + the element, **collapse its whitespace** (`replace(/\s+/g, ' ').trim()`), and assert `toContain` the + whole `tStripped` token; do not assert a fragment, which pins less. And where an Android id is *derived from* the + display string — `AlertDialog` falls back to a button's own text when the call site gives it no + `qaTag` — the id lookup already covers the copy, but say so, because that is a property of the call + site and not of the locator. +- **Desktop already has the primitives for both halves — use them rather than hand-rolling.** + `clickOnWithText(locator, text)` is the id-plus-copy click. `checkModalStrings(heading, description, + modalId)` asserts a modal's title and body together, scoped to `[data-modal-id="…"]` so another modal + carrying the same generic `modal-description` slot cannot satisfy it. Reach for a bare + `waitForElement` on `modal-description` only when there is no modal id to scope to. + + `checkModalStrings` does that normalisation for you: it reads `innerText`, where a break renders as a + newline, then collapses whitespace — landing on exactly what `tStripped` produces for the same token. + Verified against `proClearAllDataDevice`, which spans two breaks. - `runOnlyOnIOS` / `runOnlyOnAndroid` (`run/test/utils/run_on.ts`) gate platform-specific steps inside a shared spec. - Lint/format: `pnpm lint` (prettier + eslint). `pnpm tsc` for typecheck. diff --git a/run/test/locators/settings.ts b/run/test/locators/settings.ts index 7cf914570..5a13ccc38 100644 --- a/run/test/locators/settings.ts +++ b/run/test/locators/settings.ts @@ -71,6 +71,113 @@ export class ClassicLightThemeOption extends LocatorsInterface { } } +/** The clear-data dialog's cancel action, tagged the same way as {@link ClearDataConfirmButton}. */ +export class ClearDataCancelButton extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { strategy: 'id', selector: 'Cancel' } as const; + case 'ios': + return { strategy: 'accessibility id', selector: 'Cancel' } as const; + } + } +} + +/** + * The destructive action on the **first** stage - the one that raises the confirmation. + * + * The two stages are one dialog on Android, whose text swaps, but two stacked modals on iOS: the + * confirmation is presented OVER `NukeDataModal` rather than replacing it, so both are in the tree at + * once. That is why iOS needs a distinct id here and Android does not. + * + * Android's is the English display string, because `AlertDialog` falls back to a button's own text when + * the call site gives it no `qaTag`. A real id rather than a text match, but one that moves with locale. + * + * Pressing this on a **standard** account with "device only" selected deletes immediately on both + * platforms - there is no second confirmation on that branch. Only press it where one is expected. + */ +export class ClearDataConfirmButton extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { strategy: 'id', selector: 'Clear' } as const; + case 'ios': + return { strategy: 'accessibility id', selector: 'clear-data-confirm-button' } as const; + } + } +} + +/** + * The body of the **first** stage, carrying the generic copy every account sees. + * + * Separate from `ModalDescription` for the stacking reason on {@link ClearDataConfirmButton}: on iOS + * that id belongs to the confirmation presented on top, and asserting it before pressing Clear would + * read the wrong modal. + */ +export class ClearDataDialogDescription extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { strategy: 'id', selector: 'Modal description' } as const; + case 'ios': + return { strategy: 'accessibility id', selector: 'clear-data-description' } as const; + } + } +} + +/** + * The "Clear Data" row at the bottom of the user settings list. + * + * The id is a hand-written tag on both platforms, NOT derived from the display string, so a lookup + * says nothing about the copy - pair it with `expectControlCopy` and `sessionClearData`. + */ +export class ClearDataMenuItem extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { strategy: 'id', selector: 'Clear data' } as const; + case 'ios': + return { strategy: 'accessibility id', selector: 'Clear data' } as const; + } + } +} + +/** + * The "device only" / "device and network" radios on the first stage of the clear-data dialog. + * + * Device-only is preselected on both platforms, so only the network one is ever tapped. The other is + * still worth naming: which branches the dialog offers is part of what the screen promises, and + * nothing else reads the preselected one. + * + * Slug ids on both platforms, so neither carries its copy - `expectControlCopy` with `clearDeviceOnly` + * / `clearDeviceAndNetwork`. That check only bites on iOS; the Android label is a child node. + */ +export class ClearDeviceAndNetworkRadio extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { strategy: 'id', selector: 'clear-device-and-network-radio' } as const; + case 'ios': + return { + strategy: 'accessibility id', + selector: 'clear-device-and-network-radio', + } as const; + } + } +} + +/** The preselected branch — see {@link ClearDeviceAndNetworkRadio}. */ +export class ClearDeviceOnlyRadio extends LocatorsInterface { + public build(): StrategyExtractionObj { + switch (this.platform) { + case 'android': + return { strategy: 'id', selector: 'clear-device-only-radio' } as const; + case 'ios': + return { strategy: 'accessibility id', selector: 'clear-device-only-radio' } as const; + } + } +} + export class CloseAppButton extends LocatorsInterface { public build() { switch (this.platform) { @@ -154,7 +261,6 @@ export class HideRecoveryPasswordButton extends LocatorsInterface { } } } - export class LockAppOption extends LocatorsInterface { public build() { switch (this.platform) { @@ -200,6 +306,7 @@ export class NotificationsMenuItem extends LocatorsInterface { } } } + export class PathMenuItem extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { @@ -267,7 +374,6 @@ export class RecoveryPasswordMenuItem extends LocatorsInterface { } } } - export class RecoveryPhraseContainer extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { @@ -284,7 +390,6 @@ export class RecoveryPhraseContainer extends LocatorsInterface { } } } - export class RevealRecoveryPhraseButton extends LocatorsInterface { public build(): StrategyExtractionObj { switch (this.platform) { @@ -334,6 +439,7 @@ export class SaveProfilePictureButton extends LocatorsInterface { } } } + export class SelectAppIcon extends LocatorsInterface { public build() { switch (this.platform) { @@ -368,6 +474,7 @@ export class SettingsModalsEnableButton extends LocatorsInterface { } } } + export class UserAvatar extends LocatorsInterface { public build() { switch (this.platform) { diff --git a/run/test/specs/desktop/pro_clear_data_warning.spec.ts b/run/test/specs/desktop/pro_clear_data_warning.spec.ts new file mode 100644 index 000000000..9c6a1e24d --- /dev/null +++ b/run/test/specs/desktop/pro_clear_data_warning.spec.ts @@ -0,0 +1,120 @@ +import type { DesktopWrapper } from '../../../desktop/DesktopWrapper'; + +import { Global, LeftPane, Settings } from '../../../desktop/locators'; +import { test_Alice_1W_no_network } from '../../../desktop/sessionTest'; +import { tStripped } from '../../../localizer/lib'; + +/** + * The warning before wiping an account: Pro does not transfer, so save the recovery password. + * + * `DeleteAccountModal` is one modal in two stages. The first shows `clearDataAllDescription` and the + * two radios; pressing Clear flips `askingConfirmation` and the same slot re-renders with the + * confirmation copy. Only a second press deletes anything, so every case here reads the copy and + * cancels - the destructive action is never taken. + * + * Both branches, because the app picks the copy from `deleteMode` crossed with + * `useCurrentUserHasPro()` and each cell has its own token. + * + * All four cells of that grid, and each carries its OWN token - so the two standard cases are not just + * controls for the Pro ones, they are the only thing asserting their own copy. + * + * Display mocks throughout - the copy is chosen from `useCurrentUserHasPro()`, which a mocked status + * and proof satisfy, and nothing here needs a proof another party would verify. + * + * Whole tokens, not fragments. `checkModalStrings` reads `innerText`, where a `
` renders as a + * newline, and then collapses whitespace - landing on exactly what `tStripped` produces. The mobile + * spec normalises its own read for the same reason. + */ + +const PRO_ACCOUNT = { + pro: { proBackendStatus: 'active', proProof: 'valid', proLoadingState: 'success' }, +} as const; + +/** + * Open the modal and assert it is on its first stage. + * + * `checkModalStrings` rather than a bare `modal-description` wait: it scopes to + * `[data-modal-id="deleteAccountModal"]`, so a second modal carrying the generic description slot + * cannot satisfy it, and it pins the heading at the same time. + */ +async function openClearDataModal(alice: DesktopWrapper): Promise { + await alice.clickOn(LeftPane.settingsButton); + // Id AND copy, here and on every control below - see the rule in CLAUDE.md. `clickOnWithText` is the + // desktop primitive for it. + await alice.clickOnWithText(Settings.clearDataMenuItem, tStripped('sessionClearData')); + // The generic first-stage copy, so the assertion after Clear is a CHANGE of copy rather than + // whatever happened to render first. + await alice.checkModalStrings( + tStripped('clearDataAll'), + tStripped('clearDataAllDescription'), + 'deleteAccountModal' + ); +} + +/** + * Advance to the confirmation stage, assert its copy, then cancel. + * + * The cancel is not tidying up: it is the assertion that this test never took the destructive action. + */ +async function expectConfirmationCopy(alice: DesktopWrapper, expected: string): Promise { + await alice.clickOnWithText(Global.confirmButton, tStripped('clear')); + await alice.checkModalStrings(tStripped('clearDataAll'), expected, 'deleteAccountModal'); + await alice.clickOnWithText(Global.cancelButton, tStripped('cancel')); + await alice.hasElementPoppedUpThatShouldnt(Global.modalDescription); +} + +test_Alice_1W_no_network( + 'Clear data warns a Pro subscriber (device)', + async ({ alice }) => { + await openClearDataModal(alice); + // Device-only is the modal's initial `deleteMode`, so no radio is touched here. + await expectConfirmationCopy(alice, tStripped('proClearAllDataDevice')); + }, + PRO_ACCOUNT +); + +test_Alice_1W_no_network( + 'Clear data warns a Pro subscriber (network)', + async ({ alice }) => { + await openClearDataModal(alice); + await alice.clickOnWithText( + Settings.clearDeviceAndNetworkRadial, + tStripped('clearDeviceAndNetwork') + ); + await expectConfirmationCopy(alice, tStripped('proClearAllDataNetwork')); + }, + PRO_ACCOUNT +); + +/** + * The control, and the thing that keeps the two above honest: without it nothing separates "shows the + * Pro copy to Pro users" from "shows the Pro copy to everyone". + * + * No `pro` block at all, so `useCurrentUserHasPro()` is false. Desktop has always confirmed here for + * every account - it is the mobile clients that just changed to match, which is why the mobile spec + * carries the same case. + */ +test_Alice_1W_no_network( + 'Clear data confirmation for a standard account (device)', + async ({ alice }) => { + await openClearDataModal(alice); + await expectConfirmationCopy(alice, tStripped('clearDeviceDescription')); + } +); + +/** + * The fourth cell. Each of the four carries its OWN token, so this is the only thing asserting the + * copy that warns a standard account its messages cannot be restored - `delete_account.spec.ts` walks + * this branch but never reads the confirmation. + */ +test_Alice_1W_no_network( + 'Clear data confirmation for a standard account (network)', + async ({ alice }) => { + await openClearDataModal(alice); + await alice.clickOnWithText( + Settings.clearDeviceAndNetworkRadial, + tStripped('clearDeviceAndNetwork') + ); + await expectConfirmationCopy(alice, tStripped('clearDeviceAndNetworkConfirm')); + } +); diff --git a/run/test/specs/mobile/pro_clear_data_warning.spec.ts b/run/test/specs/mobile/pro_clear_data_warning.spec.ts new file mode 100644 index 000000000..d8d0f0bb8 --- /dev/null +++ b/run/test/specs/mobile/pro_clear_data_warning.spec.ts @@ -0,0 +1,305 @@ +import { test, type TestInfo } from '@playwright/test'; + +import type { DeviceWrapper } from '../../../types/DeviceWrapper'; + +import { tStripped } from '../../../localizer/lib'; +import { TestSteps } from '../../../types/allure'; +import { bothPlatformsIt } from '../../../types/sessionIt'; +import { USERNAME } from '../../../types/testing'; +import { ModalDescription, ModalHeading } from '../../locators/global'; +import { + ClearDataCancelButton, + ClearDataConfirmButton, + ClearDataDialogDescription, + ClearDataMenuItem, + ClearDeviceAndNetworkRadio, + ClearDeviceOnlyRadio, + UserSettings, +} from '../../locators/settings'; +import { newUser } from '../../utils/create_account'; +import { expectControlCopy, withCopy } from '../../utils/element_copy'; +import { + closeApp, + openAppOnPlatformSingleDevice, + SupportedPlatformsType, +} from '../../utils/open_app'; +import { activeProContext, PRO_BACKEND_CONTEXT } from '../../utils/pro_context'; + +const PRESENT_MAX_WAIT = 10_000; +const ABSENT_MAX_WAIT = 1_000; + +/** + * The warning before wiping an account: Pro does not transfer, so save the recovery password. + * + * A **two-stage** dialog on both platforms. Opening Clear Data shows the generic copy and the two + * radios; pressing Clear re-renders with the confirmation copy, and only a further press deletes + * anything. Every case here reads the copy and cancels - the destructive action is never taken. + * + * The two stages are built differently, and it matters for the locators. Android swaps the one + * dialog's `ClearDataState`, so both stages are the same element. iOS presents a `ConfirmationModal` + * OVER `NukeDataModal` rather than replacing it, so both are in the accessibility tree at once - which + * is why the first stage has its own ids there (`ClearDataDialogDescription`, + * `ClearDataConfirmButton`) and only the confirmation answers to `ModalDescription`. + * + * **Pro accounts only.** The standard-account copy is a different claim on a screen whose behaviour is + * changing: both mobile clients used to delete straight from the first Clear press on the device + * branch, and now confirm for every account as Desktop always has. A control written against the old + * behaviour would have been a test of something being removed. Desktop's standard device case is + * already covered incidentally by `clearDataOnWindow` in `linked_device_group.spec.ts`. + * + * The cost of leaving it out, so it is a decision rather than an oversight: nothing here separates + * "shows the Pro copy to Pro users" from "shows the Pro copy to everyone". Worth a control once the + * mobile behaviour has settled. + */ + +bothPlatformsIt({ + title: 'Clear data warns a Pro subscriber (device)', + risk: 'medium', + countOfDevicesNeeded: 1, + testCb: proClearDataDevice, + isPro: true, + allureSuites: { parent: 'Session Pro' }, + allureDescription: + 'A Pro subscriber clearing their device is told Pro cannot be transferred and to save their ' + + 'recovery password first.', +}); + +bothPlatformsIt({ + title: 'Clear data warns a Pro subscriber (network)', + risk: 'medium', + countOfDevicesNeeded: 1, + testCb: proClearDataNetwork, + isPro: true, + allureSuites: { parent: 'Session Pro' }, + allureDescription: + 'The same warning on the network branch, which additionally says the data cannot be restored.', +}); + +/** + * Assert the confirmation body carries exactly `copy`. + * + * The locator's own filter does the work - see `withCopy`. It is EXACT after a normalisation that + * collapses whitespace, which is what lets a token spanning a `

` be asserted whole, and what + * makes asserting the standard copy here enough to say the Pro copy is absent. + */ +async function expectConfirmationCopy(device: DeviceWrapper, copy: string): Promise { + await device.waitForTextElementToBePresent({ + ...new ModalDescription(device).build(), + ...withCopy(device, copy), + maxWait: PRESENT_MAX_WAIT, + }); +} + +/** + * Bring the "Clear Data" row into view. It is the last row of the settings list, so on arrival it is + * off screen and therefore absent from the accessibility tree - which reads as "element not found" + * rather than "not scrolled to". + */ +async function scrollToClearDataRow(device: DeviceWrapper): Promise { + const maxScrolls = 6; + for (let i = 0; i < maxScrolls; i++) { + const found = await device.doesElementExist({ + ...new ClearDataMenuItem(device).build(), + maxWait: ABSENT_MAX_WAIT, + }); + if (found) { + return; + } + await device.scrollDown(); + } + await device.waitForTextElementToBePresent({ + ...new ClearDataMenuItem(device).build(), + maxWait: PRESENT_MAX_WAIT, + }); +} + +async function openClearDataDialog( + platform: SupportedPlatformsType, + testInfo: TestInfo, + isPro = true +): Promise { + const { device } = await test.step(TestSteps.SETUP.NEW_USER, async () => { + const { device } = await openAppOnPlatformSingleDevice( + platform, + testInfo, + isPro ? activeProContext() : PRO_BACKEND_CONTEXT + ); + await newUser(device, USERNAME.ALICE, { saveUserData: false }); + return { device }; + }); + + await test.step('Open the clear-data dialog', async () => { + await device.clickOnElementAll(new UserSettings(device)); + await scrollToClearDataRow(device); + // The row's id is a hand-written tag rather than its display string, so unlike the dialog buttons + // the lookup says nothing about the copy - which is the case the rule exists for. + await expectControlCopy( + device, + new ClearDataMenuItem(device).build(), + tStripped('sessionClearData') + ); + await device.clickOnElementAll(new ClearDataMenuItem(device)); + // The generic first-stage copy, so the assertion after Clear is a CHANGE of copy rather than + // whatever happened to render first. This token has no break in it, so it matches whole. + await device.waitForTextElementToBePresent({ + ...new ClearDataDialogDescription(device).build(), + text: tStripped('clearDataAllDescription'), + maxWait: PRESENT_MAX_WAIT, + }); + // Both radios, not just the one a case goes on to tap: which branch the dialog offers is part of + // what this screen promises, and the preselected one is never pressed so nothing else reads it. + await expectControlCopy( + device, + new ClearDeviceOnlyRadio(device).build(), + tStripped('clearDeviceOnly') + ); + await expectControlCopy( + device, + new ClearDeviceAndNetworkRadio(device).build(), + tStripped('clearDeviceAndNetwork') + ); + }); + + return device; +} + +/** + * Cancel out of the confirmation and assert the dialog is gone, having deleted nothing. + * + * The button is asserted by id AND copy before being pressed - see `expectControlCopy`. On a destructive flow + * that matters more than usual: an id-only lookup would keep passing if the two actions ever swapped + * their labels, and this spec would then be pressing Clear while believing it pressed Cancel. + */ +async function cancelClearData(device: DeviceWrapper): Promise { + await test.step('Cancel without deleting anything', async () => { + await expectControlCopy(device, new ClearDataCancelButton(device).build(), tStripped('cancel')); + await device.clickOnElementAll(new ClearDataCancelButton(device)); + // ABSENT_MAX_WAIT, and the difference is not cosmetic: `verifyElementNotPresent` sleeps its + // `maxWait` UNCONDITIONALLY before looking, so this is a flat cost paid by every case rather than + // a bound that a fast dismiss escapes. At PRESENT_MAX_WAIT it was ten seconds per test. + await device.verifyElementNotPresent({ + ...new ModalHeading(device).build(), + text: tStripped('clearDataAll'), + maxWait: ABSENT_MAX_WAIT, + }); + }); +} + +/** + * Assert the destructive action carries the copy it should, then press it. + * + * Same reasoning as `cancelClearData`: the id alone would not notice this button being relabelled. + */ +async function pressClear(device: DeviceWrapper): Promise { + await expectControlCopy(device, new ClearDataConfirmButton(device).build(), tStripped('clear')); + await device.clickOnElementAll(new ClearDataConfirmButton(device)); +} + +async function proClearDataDevice(platform: SupportedPlatformsType, testInfo: TestInfo) { + const device = await openClearDataDialog(platform, testInfo); + + await test.step('Verify the Pro transfer warning', async () => { + // Device-only is preselected on both platforms, so no radio is touched here. + await pressClear(device); + // Both runs, because neither alone identifies this case: the warning is word-for-word identical in + // the network token, and the opening question says nothing about Pro. + await expectConfirmationCopy(device, tStripped('proClearAllDataDevice')); + await device.waitForTextElementToBePresent({ + ...new ModalHeading(device).build(), + text: tStripped('clearDataAll'), + maxWait: PRESENT_MAX_WAIT, + }); + }); + + await cancelClearData(device); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} + +async function proClearDataNetwork(platform: SupportedPlatformsType, testInfo: TestInfo) { + const device = await openClearDataDialog(platform, testInfo); + + await test.step('Verify the Pro transfer warning on the network branch', async () => { + // Copy already asserted for both radios in `openClearDataDialog`. + await device.clickOnElementAll(new ClearDeviceAndNetworkRadio(device)); + await pressClear(device); + // The opening run here is word-for-word `clearDeviceAndNetworkConfirm` - the standard copy - so it + // says which branch and nothing about Pro. The warning is what says Pro. + await expectConfirmationCopy(device, tStripped('proClearAllDataNetwork')); + }); + + await cancelClearData(device); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} + +/** + * The control, and the thing that keeps the two above honest: without it nothing separates "shows the + * Pro copy to Pro users" from "shows the Pro copy to everyone". + * + * Device branch specifically, because that is the one the clients just changed. A standard account + * pressing Clear here used to have its data deleted on that press - Android's + * `SettingsViewModel.clearData` fell through to `clearDataDeviceOnly()`, iOS's `clearDeviceOnly()` to + * `clearLocalAccount()` - so this case could not exist. It confirms for every account now, and this is + * what stops that regressing back to a one-tap wipe. + */ +bothPlatformsIt({ + title: 'Clear data confirmation for a standard account (device)', + risk: 'high', + countOfDevicesNeeded: 1, + testCb: standardClearDataDevice, + isPro: true, + allureSuites: { parent: 'Session Pro' }, + allureDescription: + 'A standard account clearing its device is asked to confirm, and told nothing about Pro.', +}); + +async function standardClearDataDevice(platform: SupportedPlatformsType, testInfo: TestInfo) { + const device = await openClearDataDialog(platform, testInfo, false); + + await test.step('Verify the standard confirmation says nothing about Pro', async () => { + await pressClear(device); + // Reaching this at all is the enforcement: before the client change the press above deleted the + // account, and this dialog would not have been on screen. The match is exact, so asserting the + // standard copy is also what says the Pro warning is not here. + await expectConfirmationCopy(device, tStripped('clearDeviceDescription')); + }); + + await cancelClearData(device); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} + +/** + * The fourth cell. Each of the four carries its OWN token, so this is the only thing asserting the + * copy that warns a standard account its messages cannot be restored. + */ +bothPlatformsIt({ + title: 'Clear data confirmation for a standard account (network)', + risk: 'medium', + countOfDevicesNeeded: 1, + testCb: standardClearDataNetwork, + isPro: true, + allureSuites: { parent: 'Session Pro' }, + allureDescription: + 'A standard account clearing the network is warned its data cannot be restored, and told nothing ' + + 'about Pro.', +}); + +async function standardClearDataNetwork(platform: SupportedPlatformsType, testInfo: TestInfo) { + const device = await openClearDataDialog(platform, testInfo, false); + + await test.step('Verify the standard network confirmation says nothing about Pro', async () => { + await device.clickOnElementAll(new ClearDeviceAndNetworkRadio(device)); + await pressClear(device); + await expectConfirmationCopy(device, tStripped('clearDeviceAndNetworkConfirm')); + }); + + await cancelClearData(device); + await test.step(TestSteps.SETUP.CLOSE_APP, async () => { + await closeApp(device); + }); +} diff --git a/run/test/utils/element_copy.ts b/run/test/utils/element_copy.ts new file mode 100644 index 000000000..59c79068e --- /dev/null +++ b/run/test/utils/element_copy.ts @@ -0,0 +1,64 @@ +import { test } from '@playwright/test'; + +import type { DeviceWrapper } from '../../types/DeviceWrapper'; +import type { StrategyExtractionObj } from '../../types/testing'; + +const COPY_MAX_WAIT = 10_000; + +/** + * Assert a control addressed by id also carries the copy it should. + * + * **Address by id, then check the words.** The id says the client rendered the right control; only the + * copy says it rendered the right words in it, and the two fail independently - a control keeps its + * identifier through a copy change, so an id-only lookup stays green against a wrong, empty or swapped + * string. On a destructive flow that is the difference between pressing Cancel and pressing Clear. + * + * Where the copy lives differs by platform, and it is not a preference: + * + * - **iOS** puts it on the node's `label`. An accessibility identifier becomes the element's `name` and + * displaces the display text, so `label` is the only place left - see + * `findMatchingLabelInElementArray`. + * - **Android** Compose controls report no text of their own: the label is a child node, so the node + * addressed by id has nothing to compare. Only text-bearing nodes (a dialog body, a heading) can be + * checked in place, and those take a plain `text` filter on the locator rather than this. + * + * So this asserts on iOS and skips on Android, and says so in the step name rather than quietly passing. + * Where an Android id is itself derived from the display string - `AlertDialog` falls back to a button's + * own text when the call site gives it no `qaTag` - the id lookup already covers the copy, and the + * caller should say which case it is. + */ +export async function expectControlCopy( + device: DeviceWrapper, + locator: StrategyExtractionObj, + copy: string +): Promise { + if (!device.isIOS()) { + return; + } + await test.step(`Verify the control reads "${copy}"`, async () => { + await device.waitForTextElementToBePresent({ + ...locator, + label: copy, + maxWait: COPY_MAX_WAIT, + }); + }); +} + +/** + * The copy filter for a **text-bearing** element - a dialog body, a heading, a row title. + * + * Spread alongside the locator so the lookup asserts the id and the copy in one wait. Both filters are + * EXACT after a normalisation that collapses whitespace (`findMatchingTextInElementArray`, + * `findMatchingLabelInElementArray`), which is what makes copy spanning a `
` comparable to the + * single space `tStripped` puts in its place. Do not hand-roll that read: the matchers already do it. + * + * The platform split is the same one {@link expectControlCopy} explains - an iOS identifier displaces + * the display text onto `label`. The difference is that this works on **both** platforms, because a + * text-bearing node carries its own copy on Android where a Compose control does not. + */ +export function withCopy( + device: DeviceWrapper, + copy: string +): { label: string } | { text: string } { + return device.isIOS() ? { label: copy } : { text: copy }; +} diff --git a/run/types/testing.ts b/run/types/testing.ts index 6c03eef60..31ba164d2 100644 --- a/run/types/testing.ts +++ b/run/types/testing.ts @@ -230,8 +230,13 @@ export type AccessibilityId = | 'Cancel' | 'character-limit-text' | 'Classic Light' + | 'clear-data-confirm-button' + | 'clear-data-description' + | 'clear-device-and-network-radio' + | 'clear-device-only-radio' | 'Clear' | 'Clear all' + | 'Clear data' | 'Close' | 'Close button' | 'Collections' @@ -524,9 +529,16 @@ export type Id = | 'block-user-menu-option' | 'Block' | 'Call' + | 'Cancel' + | 'clear-data-confirm-button' + | 'clear-data-description' + | 'clear-device-and-network-radio' + | 'clear-device-only-radio' | 'clear-input-button-description' | 'clear-input-button-name' | 'clear-input-button' + | 'Clear' + | 'Clear data' | 'Close button' | 'com.android.chrome:id/negative_button' | 'com.android.chrome:id/signin_fre_dismiss_button'