diff --git a/AGENTS.md b/AGENTS.md index c1676f1..0094909 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,16 @@ Notes: - `BACKEND=local` uses local Electrum (default). - `BACKEND=regtest` sets network Electrum against regtest. +### Test fixtures (images for profile avatar, etc.) + +Images in `test/fixtures/` can be pushed to a running Android emulator (`/sdcard/Pictures/`) and iOS Simulator (Photos) via: + +```bash +./scripts/push-fixture-media-to-devices.sh +``` + +See `docs/pubky-profile-manual-e2e.md` (preconditions). + ## Running Tests **Important:** The `BACKEND` env var controls which infrastructure the tests use for deposits/mining: diff --git a/docs/pubky-profile-manual-e2e.md b/docs/pubky-profile-manual-e2e.md new file mode 100644 index 0000000..a417f2d --- /dev/null +++ b/docs/pubky-profile-manual-e2e.md @@ -0,0 +1,224 @@ +# Pubky profile & contacts — manual E2E charter + +Charter for QA around profile creation, Pubky Ring import, and contacts. Use while the feature is landing; automate later via WebdriverIO + `ciIt()` when flows and infra are stable. + +PR context: `bitkit-android#824`, `bitkit-ios#476`. + +## Preconditions + +- **Build/network**: Match the PR under test. The current build talks to staging Pubky (`homegate.staging.pubky.app`, `staging.pubky.app`) regardless of flavor; switching to production `pubky.app` is a known TODO for mainnet. +- **Homegate dependency**: "Create profile from scratch" calls `POST https://homegate.staging.pubky.app/ip_verification`. If it returns 4xx (e.g. 404), the create-from-scratch path is blocked until the service or app config is fixed — verify the endpoint is healthy before starting this charter. +- **Pubky Ring**: Several steps need a physical device or simulator where Ring is (or is not) installed. Simulator may not fully mirror App Store install behavior for the "not installed" path. +- **Deterministic identity**: Pubky keys are derived from the wallet seed. Wiping and restoring the same seed yields the same pubky; creating a fresh wallet yields a different pubky. +- **Avatar fixtures**: Put test images under `test/fixtures/` (e.g. `bob.jpg`, `alice.png`), then push them into the running **Android emulator** and **iOS Simulator** Photos library: + + ```bash + ./scripts/push-fixture-media-to-devices.sh + ``` + + With no arguments the script imports every `*.jpg`, `*.jpeg`, `*.png`, `*.heic`, `*.webp` in `test/fixtures/`. You can also pass explicit paths. Requires a booted Android device/emulator (`adb`) and a booted iOS Simulator (`xcrun simctl addmedia`). Use `ANDROID_SERIAL` if multiple Android devices are connected; set `SKIP_ANDROID=1` or `SKIP_IOS=1` to run one side only. + +Run the same checklist on **Android** and **iOS** where the feature exists (labels may differ slightly). + +### Terminology + +- **Delete Profile** (`profile__delete_profile` / `profile__delete_label`): wipes profile data on the homeserver for this pubky. The pubky itself is seed-derived and unchanged; a new profile can be created for it later. **This is the only profile-removal action reachable from the normal Profile UI — it lives inside the Edit Profile screen.** +- **Disconnect** (`profile__sign_out`): local sign-out recovery action. **Not shown in the normal Profile view.** It surfaces only when the Profile screen lands in the empty / failed-to-load state (e.g. session expired, homeserver unreachable), next to a Retry button. Confirming clears the local session so the user can reconnect. + +--- + +## A. No profile — navigation & gating + +With no profile created yet, every entry point should funnel into the choice screen (Create / Import with Ring). + +1. Fresh wallet, **no profile** — tap the header **profile button** (top-right) → `ProfileIntro` → Continue → `PubkyChoice` (Create + Import options). +2. Drawer → **Contacts** → `ContactsIntro` → Continue → `PubkyChoice`. + - If you have already dismissed `ProfileIntro` in this session, `ContactsIntro` should still be shown the first time. +3. Drawer → **Profile** → goes straight to `PubkyChoice` (no intro once either intro has been seen). +4. Close drawer / back out from any of the above → returns to wallet home without leaving stale state. + +> **Spec** (`test/specs/pubky-profile.e2e.ts`): `@pubky_profile_1` covers this. + +--- + +## B. Create profile from scratch — full loop + +### B.1 Create profile + +1. From `PubkyChoice` → **Create profile** → name form. +2. With an **empty / whitespace-only** name, **Continue is disabled** on both platforms. +3. Enter a name → Continue becomes enabled → tap Continue → success toast, navigates to the **Pay Contacts** onboarding screen. +4. On Pay Contacts screen, toggle "Share payment data and enable payments with contacts" (if visible) → continue / close → land on profile/wallet. +5. Open Profile → name, avatar placeholder initial, and truncated pubky are shown. + +> **`@pubky_profile_2`** drives `createProfile`: (1)–(3) and the transition to **Pay Contacts**; Save stays disabled with an empty name (asserted before typing). (4) default path leaves the Pay Contacts toggle on and taps Continue. (5) checks name and pubky on the profile screen; it does not assert **avatar** placeholder/initials. + +### B.2 Pay Contacts onboarding + +1. First time after profile creation → **Pay Contacts** screen is shown (headline: "Let your contacts pay you", toggle + Continue / Skip). +2. Accepting / skipping both land safely on the wallet or profile screen. +3. Re-entering Profile after completing onboarding should **not** re-show the onboarding screen. + +> **`@pubky_profile_2`**: (1)–(2) are covered by the same `createProfile` hand-off (waits for `PayContactsContinue`, then profile). (3) is **implicit** in the rest of the spec (Profile opened many times, Pay Contacts is not expected again); a dedicated “second session / cold path” check is not in this file. + +### B.3 Edit own profile + +1. Profile → Edit → update **name**, **Notes** (label is `NOTES`), and **links** → Save. +2. Changes persist after leaving the screen and after app restart. + +> **`@pubky_profile_2`** overlaps part of this (edit + persist + remove link/tag); avatar and limit edge cases are not in that spec. +3. Avatar: + - Pick an image from the Photos library → shown as avatar → persists after restart. + - Remove avatar → falls back to the initial placeholder. +4. Character limits (if enforced by the UI) prevent oversize inputs without crashing. +5. **Delete Profile** lives at the bottom of this Edit screen (the only entry point in the normal UI). Covered by B.8. + +### B.4 Add contact (manual) + +1. Contacts → **Add contact** → paste a valid pubky → Continue → contact is added; opening detail shows the remote profile snapshot (name, image, links). +2. Scan-QR path (if exposed): scan a pubky QR → same flow. +3. **Invalid / malformed pubky** → clear inline error, no crash, Continue stays disabled or blocks submit. +4. **Paste from clipboard** button on iOS works without corrupting the value; whitespace is trimmed. +5. **Self-add guard**: pasting your own pubky shows an explicit error (Android string: `contacts__add_error_self`); contact is **not** created. +6. **Duplicate add** (same pubky already in contacts) → de-duplicated or clear message. + +### B.5 Edit contact (local snapshot) + +1. Contacts → open a contact → Edit. +2. Edits to **Notes** and any local fields **persist locally** and **do not** update the remote Pubky profile of that contact — this is a local snapshot. +3. Name field behavior: verify whether the app allows renaming or is display-only; whichever is shipped should match across Android and iOS. +4. Manual refresh / pull-to-refresh (if present) re-fetches the remote snapshot; local Notes are preserved. + +### B.6 Contacts list + +1. Header: single flat **CONTACTS** section header (no A/B/C alphabetical section headers — this was the Figma-compliant design). +2. "My profile" row is shown at the top when a profile exists. +3. Tapping a contact opens detail; tapping "My profile" opens own profile. +4. Empty state (profile exists, no contacts) shows the empty copy and an Add-contact CTA. + +### B.7 Delete profile (homeserver wipe) + +1. Profile → Edit → scroll to the bottom → **Delete Profile** → confirmation ("Delete Profile?" / "This will delete your current Pubky profile data. You can create a new profile for this pubky later.") → confirm. +2. After delete: gated back to `PubkyChoice` (same as **section A**). +3. Creating a new profile from the same wallet uses the **same pubky** (seed-derived), with **fresh** remote data. +4. Error path: if homeserver is unreachable, a clear error toast appears (Android: `profile__delete_error`); app does not lock up. + +> **`@pubky_profile_2`** also ends with delete → recreate and asserts the same seed-derived pubky. Homeserver error path in (4) is manual. + +### B.8 Wipe wallet + +1. Settings → reset/wipe wallet → onboard a **new seed** → no profile, different pubky → repeat **section A**. +2. Onboard the **same seed** on a fresh install → profile and contacts should be recovered from the homeserver (if not Deleted first). + +--- + +## C. Import with Pubky Ring + +### C.1 Ring not installed + +1. From `PubkyChoice` → **Import with Pubky Ring** → expect the download / App Store prompt. +2. Cancel / back returns to a safe screen (choice or previous), no dangling state. + +### C.2 Ring installed — happy path + +1. Complete auth in Ring → return to Bitkit. +2. Profile imported; Pay Contacts onboarding may be shown on first success (same as B.2). +3. After import, contacts list reflects the Ring state (see C.3). + +### C.3 Import contacts selection + +1. After auth, the **Import overview / select** screens appear (Android: `ContactImportOverviewScreen`, `ContactImportSelectScreen`). +2. **Import all** → all remote contacts added locally. +3. **Import subset** (if partial selection is supported) → only selected contacts appear locally; unselected ones are not added. +4. **Skip** (if allowed) → profile imported, zero contacts; you can add manually later. + +### C.4 PubkyAuth approval / cancel + +1. Approve in Ring → return with success; capabilities requested match what the app announced (`/pub/bitkit.to/:rw`, `/pub/staging.pubky.app/:r` for staging builds). +2. **Cancel** in Ring → return to Bitkit choice screen; no crash, no partial profile, retry works. +3. Kill Ring mid-auth / background Bitkit → on resume, state is consistent (either success or clean cancel). + +### C.5 Deep link / handoff + +1. Trigger `pubkyauth://` flow from a system share-sheet or deep link if exposed; verify it lands in Bitkit and continues the flow. + +--- + +## D. Profile empty / failed-to-load recovery + +Reproduce by simulating an unreachable homeserver, expired session, or forced load failure (e.g. toggle airplane mode before opening Profile). + +1. Open **Profile** → when loading finishes without a profile, the empty state is shown with: the `profile__empty_state` / `profile__session_expired` message, a **Retry** button, and a **Disconnect** button (`profile__sign_out`). +2. **Retry** with connectivity restored → profile loads into the normal view; Disconnect is no longer visible. +3. **Disconnect** (empty state only) → confirmation ("Disconnect Profile" / "...You can reconnect at any time.") → confirm → profile cleared locally, entry points behave like **section A** again. +4. Reconnect from `PubkyChoice` with the same wallet → same pubky, same remote profile restored (name, Notes, avatar, links); verify contacts are also re-synced. +5. Confirm Disconnect is **not** visible from the normal Profile view when a profile is successfully loaded — the only profile-removal action there is **Delete Profile** inside Edit. + +--- + +## E. Data lifecycle — matrix + +Use this table to verify persistence expectations. Fill in observed behavior if it differs from the stated expectation. + +| Action | Seed | Pubky | Remote profile | Local contacts | Local notes on contacts | +| --------------------------------------- | ---- | ----- | -------------- | -------------- | ----------------------- | +| Disconnect from empty state + reconnect | same | same | preserved | re-synced | preserved | +| Delete profile + recreate (same wallet) | same | same | wiped then new | tbd — verify | tbd — verify | +| Wipe wallet, restore same seed | same | same | preserved | re-synced | tbd — verify | +| Wipe wallet, new seed | new | new | none | none | none | +| App reinstall, same seed | same | same | preserved | re-synced | tbd — verify | + +--- + +## F. UI / copy compliance + +- Profile edit sheet: **NOTES** (not "Bio") on both platforms. +- Normal Profile view actions: **Edit / Copy / Share** only (no Disconnect, no Delete). +- **Delete Profile** is reached only from inside the Edit Profile screen. +- **Disconnect** is shown only in the Profile empty / failed-to-load state next to Retry. +- Contacts list: single **CONTACTS** section header (no alphabetical A/B/C splits). +- Create profile: placeholder and hint copy match Figma; Continue button disabled while name is empty/whitespace on both platforms. +- Pay Contacts onboarding: headline "Let your\ncontacts\npay you" (with accent on "pay you"). +- All surfaces avoid hard-coded strings — verify localization by switching app language if supported. + +--- + +## G. Extra cases / regression hooks + +- Rotate device / change theme (dark/light) on every surface → no layout breakage. +- Offline → open Profile and Contacts → cached data, no crash; reconnect triggers refresh. +- Large avatar image (>5 MB) → either rejected cleanly or downscaled; no OOM / crash. +- Long name / very long Notes / many links → truncation or scroll, no overflow. +- High-DPI and small-screen devices (e.g. iPhone SE, small Android) → no overlap on choice / profile / contact detail screens. + +--- + +## H. Automation strategy + +- **Spec**: `test/specs/pubky-profile.e2e.ts` — profile-only for now; contacts and Ring stay manual here until more specs exist. How/whether it runs in automation is a pipeline concern (`AGENTS.md`); this section only describes the spec’s intent. +- **Isolation**: `beforeEach` uses `reinstallApp()` and `completeOnboarding()` so a single `ciIt` can be run alone via `--mochaOpts.grep "@pubky_profile_N"`. +- **What the two tests do**: `@pubky_profile_1` — section A gating (no profile). `@pubky_profile_2` — one chained flow: create → edit and verify → app restart and verify → remove link and tag, save, verify → delete profile → new profile, same pubky. No backup/restore, Ring, contacts list, or avatar. +- **Tags**: suite `@pubky_profile`; tests `@pubky_profile_1`, `@pubky_profile_2`, …; reserve e.g. `@pubky_ring_required` if you add Ring-specific specs later. +- **Use `ciIt()`** instead of `it()` in this suite so it matches the repo’s `ci_run_*` / lockfile retry pattern when you wire runs up. +- Shared test IDs: **H.1** below. Implementation lives under `test/helpers/` next to other E2E specs. + +### H.1 Cross-platform test IDs (`testTag` / `accessibilityIdentifier`) + +Use the **same string** on Android and iOS so specs stay platform-agnostic (`elementById` in helpers). + +| Area | IDs | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Header / drawer | `ProfileButton`, `DrawerContacts`, `DrawerProfile`, `DrawerWallet`, … (existing app IDs) | +| Intros | `ProfileIntro`, `ProfileIntro-button`, `ContactsIntro`, `ContactsIntro-button` | +| Pubky choice | `PubkyChoiceCreate`, `PubkyChoiceImport` | +| Create profile | `CreateProfileAvatar`, `CreateProfileUsername`, `CreateProfileSave` | +| Pay contacts | `PayContactsToggle`, `PayContactsContinue` | +| Profile (view) | `ProfileEdit`, `ProfileCopy`, `ProfileShare`; empty/error: `ProfileRetry`, `ProfileEmptySignOut` (iOS) | +| Profile (presentation) | **`ProfileViewName`**, **`ProfileViewNotes`** (own profile only; `CenteredProfileHeader` passes tags on **Profile** screen). **`QRCode`** — same test id as Receive; pubky is read in E2E via **`getUriFromQRCode()`** in `test/helpers/actions.ts` (shared with receive flows). Links: **`ProfileLinkLabel_0`**, **`ProfileLinkValue_0`**, … (index matches link order). Tags section header: **`ProfileViewTagsHeader`**. Each tag chip text: **`Tag-`** (e.g. `Tag-ere`) on the label `Text` / `BodySSB`. | +| Edit profile | `EditProfileAvatar`, `ProfileEditName`, `ProfileEditBio`, `ProfileEditAddLink`, `ProfileEditLink_0` (URL field), `ProfileEditLinkRemove_0` (trash on that row), …, `ProfileEditAddTag`, remove chip `Tag--delete`, `ProfileEditDelete`, **`ProfileEditCancel`**, **`ProfileEditSave`** | +| Add link sheet | `AddLinkLabel`, `AddLinkUrl`, `AddLinkSave`, `AddLinkSuggestions` | +| Add tag sheet | `AddTagInput`, `AddTagSave`, `AddTagSuggestions` | + +Ring-only / iOS-only extras (when automating C.x): `PubkyChoiceCancelRing`, `PubkyRingAuthorize`, `PubkyRingCancelAuth`, `PubkyRingDownload`. + +Contacts list rows: `ContactsMyProfile`, `Contact_` (existing) — verify in app if still current when adding contact specs. diff --git a/scripts/adb-reverse.sh b/scripts/adb-reverse.sh new file mode 100755 index 0000000..0019881 --- /dev/null +++ b/scripts/adb-reverse.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +for d in $(adb devices | awk 'NR>1 && $2=="device" {print $1}'); do + echo "Setting reverse ports for $d" + + adb -s "$d" reverse tcp:60001 tcp:60001 + adb -s "$d" reverse tcp:9735 tcp:9735 + adb -s "$d" reverse tcp:30001 tcp:30001 + adb -s "$d" reverse tcp:6288 tcp:6288 +done + +echo "Done." \ No newline at end of file diff --git a/scripts/push-fixture-media-to-devices.sh b/scripts/push-fixture-media-to-devices.sh new file mode 100755 index 0000000..0483875 --- /dev/null +++ b/scripts/push-fixture-media-to-devices.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Push image fixtures into Android emulator storage and iOS Simulator Photos so tests +# can pick them (e.g. profile avatar during Pubky profile creation). +# +# Android: copies to /sdcard/Pictures/ and triggers MEDIA_SCANNER_SCAN_FILE. +# iOS: xcrun simctl addmedia (Photos library on the target simulator). +# +# Usage (from bitkit-e2e-tests repo root): +# ./scripts/push-fixture-media-to-devices.sh +# ./scripts/push-fixture-media-to-devices.sh ./test/fixtures/bob.jpg ./test/fixtures/alice.png +# +# Environment: +# ANDROID_SERIAL — if set, passed to adb -s (when multiple devices/emulators). +# SIMCTL_DEVICE — iOS device name or UDID for simctl (default: booted). +# SKIP_ANDROID=1 / SKIP_IOS=1 — skip one platform. +# +# Requirements: adb (Android), booted emulator; Xcode simctl (iOS), booted simulator. +set -euo pipefail + +E2E_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FIXTURES_DIR="${FIXTURES_DIR:-$E2E_ROOT/test/fixtures}" + +adb_cmd() { + if [[ -n "${ANDROID_SERIAL:-}" ]]; then + adb -s "$ANDROID_SERIAL" "$@" + else + adb "$@" + fi +} + +push_android() { + local files=("$@") + if ((${#files[@]} == 0)); then + echo "push-fixture-media: no image files for Android (skipped)." + return 0 + fi + if ! command -v adb >/dev/null 2>&1; then + echo "push-fixture-media: adb not found; skip Android." >&2 + return 0 + fi + if ! adb_cmd shell echo ok >/dev/null 2>&1; then + echo "push-fixture-media: no Android device/emulator; skip Android." >&2 + return 0 + fi + + local f base dest + for f in "${files[@]}"; do + base="$(basename "$f")" + dest="/sdcard/Pictures/$base" + echo "push-fixture-media: Android adb push '$f' -> $dest" + adb_cmd push "$f" "$dest" + adb_cmd shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE -d "file://$dest" >/dev/null \ + || true + done + echo "push-fixture-media: Android done (${#files[@]} file(s))." +} + +push_ios() { + local files=("$@") + if ((${#files[@]} == 0)); then + echo "push-fixture-media: no image files for iOS (skipped)." + return 0 + fi + if ! command -v xcrun >/dev/null 2>&1; then + echo "push-fixture-media: xcrun not found; skip iOS." >&2 + return 0 + fi + + local device="${SIMCTL_DEVICE:-booted}" + # addmedia requires a booted simulator when using "booted" + if ! xcrun simctl list devices | grep -q Booted; then + echo "push-fixture-media: no booted iOS Simulator; skip iOS." >&2 + return 0 + fi + + echo "push-fixture-media: iOS simctl addmedia $device ${files[*]}" + xcrun simctl addmedia "$device" "${files[@]}" + echo "push-fixture-media: iOS done (${#files[@]} file(s))." +} + +default_fixture_files() { + local dir="$1" + local -a out=() + local f + shopt -s nullglob + for f in "$dir"/*.{jpg,jpeg,png,heic,webp}; do + [[ -f "$f" ]] || continue + out+=("$f") + done + shopt -u nullglob + printf '%s\n' "${out[@]}" +} + +main() { + local -a files=() + if (($# > 0)); then + files=("$@") + else + while IFS= read -r line; do + [[ -n "$line" ]] && files+=("$line") + done < <(default_fixture_files "$FIXTURES_DIR") + fi + + if ((${#files[@]} == 0)); then + echo "push-fixture-media: no images under $FIXTURES_DIR (add .jpg/.png or pass paths)." >&2 + exit 1 + fi + + for f in "${files[@]}"; do + if [[ ! -f "$f" ]]; then + echo "push-fixture-media: not a file: $f" >&2 + exit 1 + fi + done + + local abs=() + local p + for p in "${files[@]}"; do + abs+=("$(cd "$(dirname "$p")" && pwd)/$(basename "$p")") + done + + if [[ "${SKIP_ANDROID:-}" != "1" ]]; then + push_android "${abs[@]}" + else + echo "push-fixture-media: SKIP_ANDROID=1" + fi + + if [[ "${SKIP_IOS:-}" != "1" ]]; then + push_ios "${abs[@]}" + else + echo "push-fixture-media: SKIP_IOS=1" + fi +} + +main "$@" diff --git a/test/fixtures/alice.png b/test/fixtures/alice.png new file mode 100644 index 0000000..e241f78 Binary files /dev/null and b/test/fixtures/alice.png differ diff --git a/test/fixtures/bob.jpg b/test/fixtures/bob.jpg new file mode 100644 index 0000000..15dc97b Binary files /dev/null and b/test/fixtures/bob.jpg differ diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index a8c8d41..f16139a 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -1,3 +1,5 @@ +import { Buffer } from 'node:buffer'; + import type { ChainablePromiseElement } from 'webdriverio'; import { reinstallApp } from './setup'; import { deposit, mineBlocks } from './regtest'; @@ -393,6 +395,17 @@ export async function multiTap(testId: string, count: number) { } } +/** + * Reads the device clipboard as UTF-8 text (Appium returns base64-encoded content). + */ +export async function getClipboardPlaintext(): Promise { + const b64 = await driver.getClipboard('plaintext'); + if (!b64 || b64.length === 0) { + return ''; + } + return Buffer.from(b64, 'base64').toString('utf8'); +} + export async function pasteIOSText(testId: string, text: string) { if (!driver.isIOS) { throw new Error('pasteIOSText can only be used on iOS devices'); @@ -496,6 +509,7 @@ export async function swipeFullScreen( ], }, ]); + await driver.releaseActions(); await sleep(500); // Allow time for the swipe to complete } @@ -1010,6 +1024,7 @@ export async function getReceiveAddress(which: addressType = 'bitcoin'): Promise return getAddressFromQRCode(which); } +/** Reads the encoded string for the `QRCode` element (receive invoice/address URI, profile pubky, etc.). */ export async function getUriFromQRCode(): Promise { const qrCode = await elementById('QRCode'); await qrCode.waitForDisplayed(); @@ -1030,7 +1045,7 @@ export async function getUriFromQRCode(): Promise { timeoutMsg: `Timed out after ${waitTimeoutMs}ms waiting for QR code URI`, } ); - console.info({ uri }); + console.info('→ QR code URI:', uri); return uri; } @@ -1159,8 +1174,12 @@ export type ToastId = | 'DevModeEnabledToast' | 'DevModeDisabledToast' | 'InsufficientSpendingToast' - | 'InsufficientSavingsToast'; + | 'InsufficientSavingsToast' + | 'ProfilePubkyCopiedToast' + | 'ProfileUpdatedToast'; +/** Wait for a toast by test id. Prefer `waitToDisappear` for iOS: success toasts live in a separate + * window, so swipe-dismiss (`dismiss: true`) often uses wrong coordinates and blocks later UI. */ export async function waitForToast( toastId: ToastId, { @@ -1171,7 +1190,7 @@ export async function waitForToast( ) { await elementById(toastId).waitForDisplayed({ timeout }); if (waitToDisappear) { - await elementById(toastId).waitForDisplayed({ reverse: true }); + await elementById(toastId).waitForDisplayed({ reverse: true, timeout }); return; } if (dismiss) { diff --git a/test/helpers/fixtures.ts b/test/helpers/fixtures.ts new file mode 100644 index 0000000..5908ca4 --- /dev/null +++ b/test/helpers/fixtures.ts @@ -0,0 +1,54 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +export const FIXTURES_DIR = path.join(REPO_ROOT, 'test', 'fixtures'); +const SCRIPT_PATH = path.join(REPO_ROOT, 'scripts', 'push-fixture-media-to-devices.sh'); + +export type PushFixtureMediaOptions = { + /** Specific files to push (absolute or relative to repo root). Defaults to all images in `test/fixtures/`. */ + files?: string[]; + /** Force-skip Android even when current driver is Android. */ + skipAndroid?: boolean; + /** Force-skip iOS even when current driver is iOS. */ + skipIos?: boolean; +}; + +/** + * Push image fixtures into the currently active platform (Android emulator or iOS Simulator) + * so the OS image picker can use them — e.g. for Pubky profile avatar selection. + * + * Wraps `scripts/push-fixture-media-to-devices.sh` so the OS-specific logic stays in one place. + * + * Notes: + * - Best called once per spec from `before(...)`, after `reinstallApp()` / `completeOnboarding()`. + * - Photos library is global per simulator/emulator; re-running is safe but may add duplicates. + * - On iOS, the Bitkit picker may need Photos permission granted (handled by setup helpers). + */ +export function pushFixtureMedia(options: PushFixtureMediaOptions = {}) { + if (!fs.existsSync(SCRIPT_PATH)) { + throw new Error(`pushFixtureMedia: script not found at ${SCRIPT_PATH}`); + } + + const env: NodeJS.ProcessEnv = { ...process.env }; + // Restrict to the platform of the active driver unless caller explicitly overrides. + const isAndroid = typeof driver !== 'undefined' && driver.isAndroid; + const isIOS = typeof driver !== 'undefined' && driver.isIOS; + if (options.skipAndroid || (typeof driver !== 'undefined' && !isAndroid)) { + env.SKIP_ANDROID = '1'; + } + if (options.skipIos || (typeof driver !== 'undefined' && !isIOS)) { + env.SKIP_IOS = '1'; + } + + const args = (options.files ?? []).map((f) => (path.isAbsolute(f) ? f : path.join(REPO_ROOT, f))); + + try { + execFileSync(SCRIPT_PATH, args, { stdio: 'inherit', env }); + console.info('→ pushFixtureMedia: done'); + } catch (error) { + console.warn('⚠ pushFixtureMedia failed', error); + throw error; + } +} diff --git a/test/helpers/navigation.ts b/test/helpers/navigation.ts index 9e232bf..9c6558b 100644 --- a/test/helpers/navigation.ts +++ b/test/helpers/navigation.ts @@ -9,6 +9,7 @@ export type SettingsTab = 'general' | 'security' | 'advanced'; export async function openSettings(tab: SettingsTab = 'general') { await tap('HeaderMenu'); await tap('DrawerSettings'); + await sleep(500); if (tab !== 'general') { await tap(`Tab-${tab}`); await sleep(300); @@ -21,6 +22,25 @@ export async function openSettings(tab: SettingsTab = 'general') { export async function openSupport() { await tap('HeaderMenu'); await tap('DrawerSupport'); + await sleep(500); +} + +/** + * Opens the Contacts entry from the drawer menu. + */ +export async function openContacts() { + await tap('HeaderMenu'); + await tap('DrawerContacts'); + await sleep(500); +} + +/** + * Opens the Profile entry from the drawer menu. + */ +export async function openProfile() { + await tap('HeaderMenu'); + await tap('DrawerProfile'); + await sleep(500); } /** diff --git a/test/helpers/profile.ts b/test/helpers/profile.ts new file mode 100644 index 0000000..d841612 --- /dev/null +++ b/test/helpers/profile.ts @@ -0,0 +1,250 @@ +import { + confirmInputOnKeyboard, + elementById, + elementByText, + getAccessibleText, + getClipboardPlaintext, + getUriFromQRCode, + sleep, + swipeFullScreen, + tap, + typeText, + waitForToast, +} from './actions'; +import { openProfile } from './navigation'; + +/** One link row on the profile (label + URL). */ +export type PubkyProfileLink = { label: string; url: string }; + +/** Expected content on the Profile screen (presentation). */ +export type ProfileDetails = { + name: string; + notes: string; + links: PubkyProfileLink[]; + tags: string[]; +}; + +function normalizeProfileDisplayName(s: string) { + return s.trim().toUpperCase(); +} + +export async function verifyPubkyString(pubky: string) { + await expect(pubky.length).toBeGreaterThan(0); + await expect(pubky.startsWith('pubky')).toBe(true); +} + +/** + * Taps Profile Copy and returns the pubky string from the system clipboard + * (same value as {@link getUriFromQRCode} on the profile QR when copy works). + * Waits for the copy confirmation toast; on iOS waits until it disappears so the overlay + * does not interfere with later gestures. + */ +export async function readPubkyFromProfileCopy(): Promise { + await openProfile(); + await tap('ProfileCopy'); + await waitForToast('ProfilePubkyCopiedToast', { waitToDisappear: driver.isIOS }); + return (await getClipboardPlaintext()).trim(); +} + +/** + * Profile → Edit → scroll to delete → confirm. Ends on {@link PubkyChoice} (create / import). + */ +export async function deleteProfile() { + await openEditProfile(); + await swipeFullScreen('up', { upStartYPercent: 0.3 }); + await sleep(500); + await tap('ProfileEditDelete'); + const confirm = await elementByText('Yes, Delete', 'exact'); + await confirm.waitForDisplayed(); + await confirm.click(); + await elementById('PubkyChoiceCreate').waitForDisplayed(); +} + +/** + * Navigate from the Wallet home to the PubkyChoice screen, covering the + * "first visit" case where ProfileIntro is shown before the choice screen. + * + * Idempotent enough to be called whether the ProfileIntro has already been + * dismissed on a prior run in the same app session. + */ +export async function openPubkyChoice() { + try { + await elementById('PubkyChoiceCreate').waitForDisplayed({ timeout: 2000 }); + return; + } catch { + /* not already on choice */ + } + + await tap('ProfileButton'); + + // On first visit, the ProfileIntro screen is shown as a gate to PubkyChoice. + const intro = await elementById('ProfileIntro-button'); + const introShown = await intro.isDisplayed().catch(() => false); + if (introShown) { + await tap('ProfileIntro-button'); + } + + await elementById('PubkyChoiceCreate').waitForDisplayed(); +} + +export async function openEditProfile() { + await openProfile(); + await tap('ProfileEdit'); + await elementById('ProfileEditName').waitForDisplayed(); +} + +/** Scroll, Save, wait for success toast; lands back on the read-only profile screen. */ +export async function saveEditProfile() { + await swipeFullScreen('up'); + await tap('ProfileEditSave'); + // iOS shows toasts in a separate window; drag-dismiss via waitForToast hits wrong coords. + // Auto-hide clears the overlay so the next openProfile() can reach the drawer. + await waitForToast('ProfileUpdatedToast', { waitToDisappear: driver.isIOS }); + await elementById('ProfileEdit').waitForDisplayed({ timeout: 60_000 }); +} + +/** + * Removes a link row by index (0-based) on the edit form. When removing multiple rows, + * remove the highest index first so indices stay stable, or call once per edit-save cycle. + */ +export async function removeEditProfileLinkAt(index: number) { + const id = `ProfileEditLinkRemove_${index}`; + await swipeFullScreen('up', { upStartYPercent: 0.3 }); + await tap(id); + await sleep(300); +} + +/** + * Removes a tag on the edit form via the chip’s delete control (`Tag--delete` on both platforms). + */ +export async function removeEditProfileTag(tag: string) { + const id = `Tag-${tag}-delete`; + await swipeFullScreen('up', { upStartYPercent: 0.3 }); + await tap(id); + await sleep(300); +} + +/** + * Opens Profile → Edit, sets name, notes (bio), adds links and tags in order, then saves. + * + * Best used when links/tags are empty (e.g. right after {@link createProfile}) or when only + * adding; use {@link removeEditProfileLinkAt} / {@link removeEditProfileTag} and {@link saveEditProfile} for removals. + */ +export async function updateProfile(details: ProfileDetails) { + await openEditProfile(); + + await typeText('ProfileEditName', details.name); + await confirmInputOnKeyboard(); + await typeText('ProfileEditBio', details.notes); + await elementByText('YOUR PUBKY').click(); + + for (const link of details.links) { + await tap('ProfileEditAddLink'); + await elementById('AddLinkLabel').waitForDisplayed(); + await typeText('AddLinkLabel', link.label); + await typeText('AddLinkUrl', link.url); + await tap('AddLinkSave'); + await sleep(400); + } + + for (const tag of details.tags) { + await tap('ProfileEditAddTag'); + await elementById('AddTagInput').waitForDisplayed(); + await typeText('AddTagInput', tag); + await tap('AddTagSave'); + await sleep(400); + } + + await saveEditProfile(); +} + +/** + * Asserts Profile screen shows the given name (display is uppercase), notes, links by index, + * and tag chips (`Tag-`). Link row labels use uppercase on Android (`Text13Up`); both sides + * are compared uppercase so expectations can use plain casing (e.g. `Website`). + */ +export async function verifyProfileDetails(expected: ProfileDetails) { + await openProfile(); + await elementById('ProfileCopy').waitForDisplayed(); + await swipeFullScreen('up'); + + const nameEl = await elementById('ProfileViewName'); + await nameEl.waitForDisplayed(); + const nameText = await getAccessibleText(nameEl); + await expect(normalizeProfileDisplayName(nameText)).toBe( + normalizeProfileDisplayName(expected.name) + ); + + const notesTrimmed = expected.notes.trim(); + if (notesTrimmed.length > 0) { + const notesEl = await elementById('ProfileViewNotes'); + await notesEl.waitForDisplayed(); + await expect((await getAccessibleText(notesEl)).trim()).toBe(notesTrimmed); + } + + for (let i = 0; i < expected.links.length; i++) { + const link = expected.links[i]; + const labelEl = await elementById(`ProfileLinkLabel_${i}`); + const valueEl = await elementById(`ProfileLinkValue_${i}`); + await labelEl.waitForDisplayed(); + await valueEl.waitForDisplayed(); + await expect(normalizeProfileDisplayName(await getAccessibleText(labelEl))).toBe( + normalizeProfileDisplayName(link.label) + ); + await expect((await getAccessibleText(valueEl)).trim()).toBe(link.url.trim()); + } + + for (const tag of expected.tags) { + const tagEl = await elementById(`Tag-${tag}`); + await tagEl.waitForDisplayed(); + } +} + +/** + * Full happy-path profile creation: PubkyChoice → Create → Pay Contacts → Profile. + * + * Assumes the Wallet home screen is visible. Requires a healthy + * `homegate.staging.pubky.app/ip_verification` endpoint; the Save call + * may take several seconds while the identity is derived and signed up. + * + * @param payContactsOption - When `true` (default), leaves the Pay Contacts “share payment data” + * toggle as shipped (on by default). When `false`, taps `PayContactsToggle` once to turn it off before Continue. + * + * Returns the wallet-derived pubky string (same as encoded in the profile QR). + */ +export async function createProfile({ + name, + payContactsOption = true, +}: { + name: string; + payContactsOption?: boolean; +}): Promise<{ pubky: string }> { + await openPubkyChoice(); + await tap('PubkyChoiceCreate'); + + // Save is disabled with an empty name; enabled once a name is entered. + await expect(elementById('CreateProfileSave')).toBeDisabled(); + await elementById('CreateProfileUsername').waitForDisplayed(); + await typeText('CreateProfileUsername', name); + await tap('CreateProfileSave'); + + // Pay Contacts onboarding is shown once after successful signup. + await elementById('PayContactsContinue').waitForDisplayed({ timeout: 60_000 }); + await sleep(300); + if (!payContactsOption) { + await tap('PayContactsToggle'); + await sleep(300); + } + await tap('PayContactsContinue'); + + // Landed on the user's Profile screen. Both platforms uppercase the name + // in CenteredProfileHeader (iOS: Text(name.uppercased()); Android: Display→uppercase()). + await elementById('ProfileEdit').waitForDisplayed(); + await elementById('ProfileCopy').waitForDisplayed(); + await elementById('ProfileShare').waitForDisplayed(); + await elementByText(name.toUpperCase()).waitForDisplayed(); + + const pubky = await getUriFromQRCode(); + console.info('→ Created profile with name:', name, 'and pubky:', pubky); + return { pubky }; +} diff --git a/test/specs/lnurl.e2e.ts b/test/specs/lnurl.e2e.ts index 0fbb7fc..5bae73b 100644 --- a/test/specs/lnurl.e2e.ts +++ b/test/specs/lnurl.e2e.ts @@ -59,7 +59,11 @@ function spendingBalanceLabelSats(satsInteger: number): string { } /** Balance in msats after pay (subtract) or withdraw (add) from a prior msat total. */ -function applyLnurlMsatDelta(balanceMsats: bigint, deltaMsats: number, direction: 'pay' | 'withdraw'): bigint { +function applyLnurlMsatDelta( + balanceMsats: bigint, + deltaMsats: number, + direction: 'pay' | 'withdraw' +): bigint { const d = BigInt(deltaMsats); return direction === 'pay' ? balanceMsats - d : balanceMsats + d; } @@ -358,7 +362,7 @@ describe('@lnurl - LNURL', () => { balanceMsats = afterPay; await expectTextWithin( 'ActivitySpending', - spendingBalanceLabelSats(Number(balanceMsats / 1000n)), + spendingBalanceLabelSats(Number(balanceMsats / 1000n)) ); await elementById('ActivityShort-0').waitForDisplayed(); await expectTextWithin('ActivityShort-0', '-'); @@ -385,7 +389,7 @@ describe('@lnurl - LNURL', () => { balanceMsats = afterWithdraw; await expectTextWithin( 'ActivitySpending', - spendingBalanceLabelSats(Number(balanceMsats / 1000n)), + spendingBalanceLabelSats(Number(balanceMsats / 1000n)) ); await elementById('ActivityShort-0').waitForDisplayed(); await expectTextWithin('ActivityShort-0', '+'); diff --git a/test/specs/pubky-profile.e2e.ts b/test/specs/pubky-profile.e2e.ts new file mode 100644 index 0000000..ce67943 --- /dev/null +++ b/test/specs/pubky-profile.e2e.ts @@ -0,0 +1,100 @@ +import { completeOnboarding, doNavigationClose, elementById, tap } from '../helpers/actions'; +import { openContacts, openProfile } from '../helpers/navigation'; +import { + createProfile, + deleteProfile, + openEditProfile, + readPubkyFromProfileCopy, + removeEditProfileLinkAt, + removeEditProfileTag, + saveEditProfile, + updateProfile, + type ProfileDetails, + verifyProfileDetails, + verifyPubkyString, +} from '../helpers/profile'; +import { launchFreshApp, reinstallApp } from '../helpers/setup'; +import { ciIt } from '../helpers/suite'; + +// Covers scenarios from docs/pubky-profile-manual-e2e.md. +// Each test reinstalls + onboards so any single test can be run in isolation +// (e.g. `--mochaOpts.grep "@pubky_profile_2"`). +describe('@pubky_profile - Pubky profile', () => { + beforeEach(async () => { + await reinstallApp(); + await completeOnboarding(); + }); + + // Section A: with no profile, every entry point must funnel into the choice screen. + describe('Gating (no profile)', () => { + ciIt('@pubky_profile_1 - Contacts/profile entry points lead to choice screen', async () => { + // Header profile button → ProfileIntro → PubkyChoice + await tap('ProfileButton'); + await elementById('ProfileIntro').waitForDisplayed(); + await tap('ProfileIntro-button'); + await elementById('PubkyChoiceCreate').waitForDisplayed(); + await elementById('PubkyChoiceImport').waitForDisplayed(); + + await doNavigationClose(); + + // Drawer → Contacts → ContactsIntro → PubkyChoice + // (profile intro already dismissed above, so contacts intro skips it) + await openContacts(); + await elementById('ContactsIntro').waitForDisplayed(); + await tap('ContactsIntro-button'); + await elementById('PubkyChoiceCreate').waitForDisplayed(); + + await doNavigationClose(); + + // Drawer → Profile → straight into PubkyChoice + await openProfile(); + await elementById('PubkyChoiceCreate').waitForDisplayed(); + await elementById('PubkyChoiceImport').waitForDisplayed(); + }); + }); + + // Section B (charter): create profile, copy pubky, edit fields, persistence, delete, recreate. + // @pubky_profile_2 — end-to-end: create → copy pubky → edit (name, notes, link, tag) → verify on + // profile → terminate/relaunch → data + pubky unchanged → open edit → remove link & tag → save → + // verify empty links/tags → delete profile → create new profile → same pubky (seed-derived). + describe('Create / Edit / Delete profile', () => { + ciIt( + '@pubky_profile_2 - Create, edit, relaunch keeps pubky; delete and recreate same pubky', + async () => { + const { pubky } = await createProfile({ name: 'Alice' }); + await verifyPubkyString(pubky); + const copiedPubky = await readPubkyFromProfileCopy(); + await expect(copiedPubky).toBe(pubky.trim()); + const details = { + name: 'Bob', + notes: 'Notes for E2E', + links: [{ label: 'Website', url: 'https://example.org' }], + tags: ['cypherpunk'], + }; + await updateProfile(details); + await verifyProfileDetails(details); + + await launchFreshApp(); + await verifyProfileDetails(details); + const pubkyAfterRelaunch = await readPubkyFromProfileCopy(); + await expect(pubkyAfterRelaunch).toBe(pubky.trim()); + + await openEditProfile(); + await removeEditProfileLinkAt(0); + await removeEditProfileTag('cypherpunk'); + await saveEditProfile(); + const detailsAfterRemovals: ProfileDetails = { + name: 'Bob', + notes: 'Notes for E2E', + links: [], + tags: [], + }; + await verifyProfileDetails(detailsAfterRemovals); + + await deleteProfile(); + const { pubky: pubkyAfterRecreate } = await createProfile({ name: 'Alice2' }); + await expect(pubkyAfterRecreate.trim()).toBe(pubky.trim()); + } + ); + }); +}); diff --git a/test/specs/send.e2e.ts b/test/specs/send.e2e.ts index 8b99866..b9cf96c 100644 --- a/test/specs/send.e2e.ts +++ b/test/specs/send.e2e.ts @@ -593,7 +593,11 @@ describe('@send - Send', () => { } await expectTextWithin('ActivitySpending', '10 000'); - async function payMsatInvoice(valueMsat: string, valueSats: string, acceptCameraPermission: boolean) { + async function payMsatInvoice( + valueMsat: string, + valueSats: string, + acceptCameraPermission: boolean + ) { const { paymentRequest } = await lnd.addInvoice({ valueMsat }); console.info({ valueMsat, paymentRequest }); await sleep(1000);