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'