Skip to content
33 changes: 33 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<br/>`. `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.
Expand Down
113 changes: 110 additions & 3 deletions run/test/locators/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -154,7 +261,6 @@ export class HideRecoveryPasswordButton extends LocatorsInterface {
}
}
}

export class LockAppOption extends LocatorsInterface {
public build() {
switch (this.platform) {
Expand Down Expand Up @@ -200,6 +306,7 @@ export class NotificationsMenuItem extends LocatorsInterface {
}
}
}

export class PathMenuItem extends LocatorsInterface {
public build(): StrategyExtractionObj {
switch (this.platform) {
Expand Down Expand Up @@ -267,7 +374,6 @@ export class RecoveryPasswordMenuItem extends LocatorsInterface {
}
}
}

export class RecoveryPhraseContainer extends LocatorsInterface {
public build(): StrategyExtractionObj {
switch (this.platform) {
Expand All @@ -284,7 +390,6 @@ export class RecoveryPhraseContainer extends LocatorsInterface {
}
}
}

export class RevealRecoveryPhraseButton extends LocatorsInterface {
public build(): StrategyExtractionObj {
switch (this.platform) {
Expand Down Expand Up @@ -334,6 +439,7 @@ export class SaveProfilePictureButton extends LocatorsInterface {
}
}
}

export class SelectAppIcon extends LocatorsInterface {
public build() {
switch (this.platform) {
Expand Down Expand Up @@ -368,6 +474,7 @@ export class SettingsModalsEnableButton extends LocatorsInterface {
}
}
}

export class UserAvatar extends LocatorsInterface {
public build() {
switch (this.platform) {
Expand Down
120 changes: 120 additions & 0 deletions run/test/specs/desktop/pro_clear_data_warning.spec.ts
Original file line number Diff line number Diff line change
@@ -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 `<br/>` 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<void> {
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<void> {
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'));
}
);
Loading