Compare commits

...
39 Commits
Author SHA1 Message Date
Linus Rath 3424bcf2e9 perf: show the cached inbox at start before the server's session arrives 2026-10-22 22:04:33 +02:00
Linus Rath d13769a9f2 perf: load screens that aren't on the way to the inbox on first visit 2026-10-22 22:04:33 +02:00
Linus Rath 0c8b8179b2 perf: load JS modules on first use instead of at startup 2026-10-22 22:04:33 +02:00
Linus Rath 533f176e88 test: time each module's evaluation at start-up in builds made for the performance harness 2026-10-22 22:04:29 +02:00
Linus Rath da72415ef3 test: mark the steps of the start-up restore for the performance harness 2026-10-22 22:04:29 +02:00
Linus Rath 0109273037 fix: start typing in the message body when the empty space under it is tapped 2026-10-22 22:04:29 +02:00
Linus Rath 33eb8f99e5 fix: show the undo button on the undo bar in dark mode 2026-10-22 22:04:29 +02:00
Linus Rath 721c2e34a1 fix: sign out of the last account instead of staying signed in 2026-10-22 22:04:28 +02:00
Linus Rath e06d92d88f fix: open the photo, camera and file pickers from the compose attach sheet on ios 2026-10-22 22:04:28 +02:00
Linus Rath 42e89122bd test: mark where the time goes when a message opens 2026-10-22 22:04:28 +02:00
Linus Rath 33fc33910a test: add test ids to the contact form's name and email fields 2026-10-22 22:04:28 +02:00
Linus Rath dde504f825 fix: let screen readers reach each option in sheets and menus 2026-10-22 22:04:28 +02:00
Linus Rath f5f3637d01 fix: keep full-screen modal headers below the status bar on iPhones with a notch 2026-10-22 22:04:28 +02:00
Linus Rath bdade8503d test: mark how long each persisted cache takes to load 2026-10-22 22:04:28 +02:00
Linus Rath 57ca1c8083 test: log timing marks for the performance harness when launched with -perfMarks 2026-10-22 22:04:28 +02:00
Linus Rath 32a41f42d3 ci: report the slowest modules at ios start-up in the perf leg 2026-10-22 22:04:24 +02:00
Linus Rath ad9db5cbff ci: tap undo at once, search for every settings page and scroll message rows clear of the ios tab bar 2026-10-22 21:57:31 +02:00
Linus Rath 99e4bd9614 ci: let ios runs with different legs on one branch run side by side 2026-10-22 20:19:48 +02:00
Linus Rath 239bf618b5 ci: search for ipad settings pages, tap the attachment chip by name and move to the body with return in the ios flows 2026-10-22 20:12:59 +02:00
Linus Rath 58e52421e9 ci: report the ios start-up timeline and cache load times in the perf leg 2026-10-22 18:16:33 +02:00
Linus Rath 96ffeaa0e2 ci: pick a photo in the ios compose flow, settle settings rows before tapping, answer prompts in portrait and tap undo sooner 2026-10-22 17:11:21 +02:00
Linus Rath 81a0723abd ci: report the first ios launch after install apart from the cold starts after it 2026-10-22 16:19:03 +02:00
Linus Rath 6103df86a5 ci: wait longer for the sign-in link prompt after a sign-out on ios 2026-10-22 14:18:30 +02:00
Linus Rath b97a877fce ci: let the first ios push banner go before tapping the next 2026-10-22 14:04:11 +02:00
Linus Rath 663f6f0007 ci: push and tap the ios banner again when the first tap misses 2026-10-22 13:56:52 +02:00
Linus Rath 5477cc039a ci: wait for the message toolbar instead of reply all when opening a message on ios 2026-10-22 13:51:07 +02:00
Linus Rath ab8e8d7e1e ci: answer the notification prompt, retry server continue and file delete, and accept a starred row in the ios flows 2026-10-22 13:50:35 +02:00
Linus Rath 49e57482cc ci: scroll ios settings pages to their check text and steady the calendar reminder and compose body steps 2026-10-22 13:49:11 +02:00
Linus Rath 5f06c3918c ci: tap the ios push banner right after the push 2026-10-22 13:38:54 +02:00
Linus Rath 96471cf13b ci: scroll to the seeded inbox row when checking the ios sign-in 2026-10-22 13:20:23 +02:00
Linus Rath 3575ed9450 ci: tap the ios notification again until the app opens 2026-10-22 13:04:59 +02:00
Linus Rath f181c73a56 ci: tap the ios push notification in notification center, not the fading banner 2026-10-22 12:56:58 +02:00
Linus Rath c85e32c9f4 ci: test the ios push banner and tap without the service extension under simctl 2026-10-22 11:58:19 +02:00
Linus Rath f2b98c6dba ci: erase and retype web sign-in fields instead of selecting their text 2026-10-22 11:52:00 +02:00
Linus Rath 653d5965ed ci: scroll the ipad settings column to sign out and retry simctl pbcopy 2026-10-22 11:43:34 +02:00
Linus Rath 01d15fd24f ci: retry the ios maestro warm-up once when the driver dies 2026-10-22 11:28:15 +02:00
Linus Rath 35e44318b9 ci: check the ipad message pane and scroll to rows before long-press in the ios flows 2026-10-22 11:27:29 +02:00
Linus Rath 3bdafefb65 ci: close the ios photo picker without losing the composer and focus the body first 2026-10-22 11:26:36 +02:00
Linus Rath 982a23cafd ci: fix the ios settings, calendar and swipe flow steps 2026-10-22 11:22:51 +02:00
64 changed files with 1735 additions and 238 deletions
+15 -3
View File
@@ -44,7 +44,8 @@ on:
types: [published]
concurrency:
group: e2e-ios-${{ github.ref }}
# Per branch and set of legs: a perf run doesn't cancel a feature run.
group: e2e-ios-${{ github.ref }}-${{ inputs.legs || 'all' }}
cancel-in-progress: true
env:
@@ -121,6 +122,11 @@ jobs:
- name: Build for the simulator
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# The bundle times each module's evaluation for the perf leg, and
# Metro writes which id is which module (metro.config.js; a branch
# without that support ignores both).
BULWARK_MODULE_TIMINGS: '1'
BULWARK_MODULE_MAP: ${{ runner.temp }}/module-map.json
run: |
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
@@ -155,7 +161,13 @@ jobs:
codesign -d --entitlements - "$APP_PATH" 2>&1 | head -20 || true
# Packed as a tar: artifacts drop the executable bit, which would
# leave a reused app unable to launch.
tar -C build/Build/Products/Release-iphonesimulator -czf "$RUNNER_TEMP/simulator-app.tar.gz" BulwarkMobile.app
tar -C build/Build/Products/Release-iphonesimulator -cf "$RUNNER_TEMP/simulator-app.tar" BulwarkMobile.app
if [ -f "$RUNNER_TEMP/module-map.json" ]; then
tar -C "$RUNNER_TEMP" -rf "$RUNNER_TEMP/simulator-app.tar" module-map.json
else
echo "no module map (branch without module timings)"
fi
gzip -f "$RUNNER_TEMP/simulator-app.tar"
ls -la "$RUNNER_TEMP/simulator-app.tar.gz"
- name: Upload the simulator app
@@ -325,7 +337,7 @@ jobs:
if [ -d "$RUNNER_TEMP/base/app/BulwarkMobile.app" ]; then
APPS=(--app "base=$RUNNER_TEMP/base/app/BulwarkMobile.app" "${APPS[@]}")
fi
node scripts/e2e/perf.mjs "${APPS[@]}" --rounds "${{ inputs.perf_rounds || 5 }}" --cold 3 \
node scripts/e2e/perf.mjs "${APPS[@]}" --rounds "${{ inputs.perf_rounds || 5 }}" --cold 5 \
--flows "$(ls "$GITHUB_WORKSPACE"/.maestro/perf/[0-9]-*.yaml | paste -sd, -)" --out "$OUT"
- name: Upload results
+6 -2
View File
@@ -44,7 +44,9 @@ tags:
visible: ".*q3-report.pdf.*"
timeout: 20000
- takeScreenshot: read-04-attachment
- tapOn: ".*q3-report.pdf.*"
# The message's attachment chip, labelled with the file name alone (on the
# iPad the list row next to it mentions the name in its longer label).
- tapOn: "q3-report.pdf"
- extendedWaitUntil:
visible: "Share"
timeout: 30000
@@ -53,9 +55,11 @@ tags:
- takeScreenshot: read-05-attachment-preview
- assertVisible: "q3-report.pdf"
- tapOn: "Close"
# Back on the message (the iPad pane has a quick reply instead of Reply All).
- extendedWaitUntil:
visible: "Reply All"
notVisible: "Share"
timeout: 10000
- assertVisible: ".*q3-report.pdf.*"
- runFlow: ../subflows/close-message.yaml
# A thread (Projects/Alpha)
+52 -20
View File
@@ -21,13 +21,12 @@ tags:
- runFlow:
file: ../subflows/compose-body.yaml
env:
FROM_SUBJECT: "yes"
BODY: "Hello from the iOS simulator test."
- takeScreenshot: compose-01-filled
# The attach sheet offers its options one by one (it used to be a single
# accessibility element). The system photo and file pickers run in another
# process that XCTest can't drive, so a picked attachment is covered by the
# forward in 04-incoming-reply-forward instead.
# accessibility element, and picking one opened no picker on iOS).
- tapOn: "Attach"
- extendedWaitUntil:
visible: "Photos & Videos"
@@ -35,28 +34,54 @@ tags:
- assertVisible: "Camera"
- assertVisible: "Files"
- takeScreenshot: compose-02-attach-sheet
- tapOn: "Photos & Videos"
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: compose-03-photo-picker
# Dismiss the picker sheet.
- swipe:
start: 50%,12%
end: 50%,95%
duration: 400
- waitForAnimationToEnd:
timeout: 3000
- evalScript: ${output.pickPhoto = ORIENTATION != 'landscape'}
# The system photo picker runs in another process: XCTest sees none of its
# elements, but taps by position reach it. Pick the first photo (the app
# icon, added by boot-sim.sh) and confirm with the check mark. The iPad in
# landscape lays the picker out differently, so it only closes the sheet.
- runFlow:
when:
notVisible: "New Message"
true: ${!output.pickPhoto}
commands:
- swipe:
start: 50%,12%
end: 50%,95%
duration: 400
- tapOn: "Cancel"
- runFlow:
when:
true: ${output.pickPhoto}
commands:
- tapOn: "Photos & Videos"
- extendedWaitUntil:
notVisible: "Draft saved|New Message"
timeout: 10000
optional: true
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: compose-03-photo-picker
- runFlow:
when:
true: ${DEVICE == 'tablet'}
commands:
- tapOn:
point: "44%,33%"
- waitForAnimationToEnd:
timeout: 2000
- tapOn:
point: "90%,26%"
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- tapOn:
point: "16%,25%"
- waitForAnimationToEnd:
timeout: 2000
- tapOn:
point: "90%,13%"
- extendedWaitUntil:
visible: "New Message"
timeout: 10000
timeout: 20000
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: compose-04-with-photo
- tapOn: "Send"
- extendedWaitUntil:
@@ -67,3 +92,10 @@ tags:
file: ../subflows/assert-delivered.yaml
env:
SUBJECT: ${output.subject}
- runFlow:
when:
true: ${output.pickPhoto}
commands:
- assertTrue:
condition: ${output.helper.attachments.length >= 1}
label: "${output.subject} carries the picked photo"
+11 -6
View File
@@ -6,16 +6,21 @@ tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- scrollUntilVisible:
element: ".*Grüße aus München.*"
direction: DOWN
centerElement: true
timeout: 20000
- swipe:
from: ".*Grüße aus München.*"
direction: LEFT
duration: 400
- extendedWaitUntil:
visible: "Email archived"
timeout: 10000
- takeScreenshot: swipe-01-archived
- assertNotVisible: ".*Grüße aus München.*"
- tapOn: "UNDO"
# The undo bar shows for 5 s; when it goes, the Compose button slides into
# its place, and a late tap on UNDO opens the composer. So tap at once and
# check the rest afterwards.
- tapOn:
text: "UNDO"
waitToSettleTimeoutMs: 500
- extendedWaitUntil:
visible: ".*Grüße aus München.*"
timeout: 15000
+26 -15
View File
@@ -21,12 +21,16 @@ tags:
- extendedWaitUntil:
visible: "Move to..."
timeout: 10000
- tapOn: "Projects"
- extendedWaitUntil:
visible: "Moved to Projects"
timeout: 15000
- tapOn:
text: "Projects"
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
waitToSettleTimeoutMs: 500
- takeScreenshot: actions-02-moved
- tapOn: "UNDO"
# Tap UNDO at once (see 06-swipe-undo: a late tap opens the composer).
- tapOn:
text: "UNDO"
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
waitToSettleTimeoutMs: 500
- runFlow: ../subflows/close-message.yaml
- extendedWaitUntil:
visible: ".*Weekly digest.*"
@@ -38,27 +42,34 @@ tags:
env:
SUBJECT: Weekly digest
- takeScreenshot: actions-03a-before-delete
- tapOn: "Delete"
- waitForAnimationToEnd:
timeout: 3000
- takeScreenshot: actions-03b-after-delete
- extendedWaitUntil:
visible: "Email moved to Trash"
timeout: 15000
- tapOn:
text: "Delete"
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
waitToSettleTimeoutMs: 500
- takeScreenshot: actions-03-deleted
- tapOn: "UNDO"
- tapOn:
text: "UNDO"
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
waitToSettleTimeoutMs: 500
- runFlow: ../subflows/close-message.yaml
- extendedWaitUntil:
visible: ".*Weekly digest.*"
timeout: 15000
# Select from the list and star, then unstar
# Select from the list and star, then unstar. On a phone the row can be
# below the fold (earlier runs left mail on top).
- scrollUntilVisible:
element: ".*Grüße aus München.*"
direction: DOWN
centerElement: true
timeout: 20000
- longPressOn: ".*Grüße aus München.*"
- extendedWaitUntil:
visible: "Clear selection"
timeout: 10000
- takeScreenshot: actions-04-selected
- tapOn: "Star"
# Star (or Unstar, when an earlier run left it starred), then toggle back.
- tapOn: "Star|Unstar"
# A bulk action ends selection mode by itself.
- runFlow:
when:
+1
View File
@@ -21,6 +21,7 @@ tags:
- runFlow:
file: ../subflows/compose-body.yaml
env:
FROM_SUBJECT: "yes"
BODY: "Scheduled from the iOS simulator test."
- tapOn: "Schedule send"
- extendedWaitUntil:
+20 -9
View File
@@ -12,13 +12,7 @@ tags:
- extendedWaitUntil:
visible: ".*Would Like to Send You Notifications.*|Go to today"
timeout: 30000
- runFlow:
when:
visible: ".*Would Like to Send You Notifications.*"
commands:
- tapOn:
text: "Allow"
optional: true
- runFlow: ../subflows/allow-notifications.yaml
- extendedWaitUntil:
visible: "Go to today"
timeout: 30000
@@ -47,16 +41,33 @@ tags:
timeout: 10000
- tapOn: "Title"
- inputText: ${output.event}
# Close the keyboard first: a tap while it slides away lands on a form that
# is still moving.
- pressKey: Enter
- waitForAnimationToEnd:
timeout: 3000
- scrollUntilVisible:
element: "Add reminder"
direction: DOWN
timeout: 10000
- tapOn: "Add reminder"
- repeat:
while:
notVisible: "15 minutes before"
times: 2
commands:
- tapOn: "Add reminder"
- waitForAnimationToEnd:
timeout: 3000
- scrollUntilVisible:
element: "15 minutes before"
direction: DOWN
timeout: 10000
- tapOn: "15 minutes before"
- takeScreenshot: calendar-05-editor
- tapOn: "Save"
# The editor closes (its Save goes; the FAB is labelled "New event" too).
- extendedWaitUntil:
notVisible: "New event"
notVisible: "Save"
timeout: 20000
# Find it in the schedule and open it
+8 -1
View File
@@ -44,7 +44,14 @@ tags:
timeout: 20000
- takeScreenshot: files-04-folder-created
- longPressOn: ".*${output.folder}.*"
- tapOn: "Delete"
- repeat:
while:
notVisible: "Delete this item.*"
times: 3
commands:
- tapOn: "Delete"
- waitForAnimationToEnd:
timeout: 3000
- extendedWaitUntil:
visible: "Delete this item.*"
timeout: 10000
+22 -22
View File
@@ -12,27 +12,27 @@ tags:
timeout: 20000
- takeScreenshot: settings-00-list
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: account, ROW: ".*@example.org, .*", PROOF: "Logged-in accounts|Display Name"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: language, ROW: "Language, Region & Time, .*", PROOF: "Date Format"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: notifications, ROW: "Notifications, .*", PROOF: "Email Notifications"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: appearance, ROW: "Appearance, .*", PROOF: "Choose your preferred color scheme"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: layout, ROW: "Layout, .*", PROOF: "Tune the email list interactions for mobile."}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: reading, ROW: "Reading, .*", PROOF: "Mark as Read"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: composing, ROW: "Composing, .*", PROOF: "Composer"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: identities, ROW: "Identities, .*", PROOF: "Sending Identities"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: vacation, ROW: "Out of Office, .*", PROOF: "Reply automatically"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: filters, ROW: "Filters, .*", PROOF: "Email Filters"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: templates, ROW: "Templates, .*", PROOF: "Email Templates"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: folders, ROW: "Folders, .*", PROOF: ".*Inbox.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: tags, ROW: "Tags, .*", PROOF: "Email Tags"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: downloads, ROW: "Downloads, .*", PROOF: "Email .*eml.* filename"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: security, ROW: "Security, .*", PROOF: "Account Security|Detecting server capabilities.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: content, ROW: "Content & Senders, .*", PROOF: "External Content"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: calendar, ROW: "Calendar, .*", PROOF: "Default view"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: contacts, ROW: "Contacts, .*", PROOF: "Group by first letter"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: files, ROW: "Files, .*", PROOF: "Default View"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: sidebar-apps, ROW: "Sidebar Apps, .*", PROOF: "Keep Apps Loaded"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: themes, ROW: "Themes, .*", PROOF: "The default light and dark palettes."}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", PROOF: "Offline mail.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: about, ROW: "About, .*", PROOF: "This device"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: language, ROW: "Language, Region & Time, .*", Q: "Language", PROOF: "Date Format"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: notifications, ROW: "Notifications, .*", Q: "Notifications", PROOF: "Email Notifications"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: appearance, ROW: "Appearance, .*", Q: "Appearance", PROOF: "Choose your preferred color scheme"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: layout, ROW: "Layout, .*", Q: "Layout", PROOF: "Tune the email list interactions for mobile."}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: reading, ROW: "Reading, .*", Q: "Reading", PROOF: "Mark as Read"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: composing, ROW: "Composing, .*", Q: "Composing", PROOF: "Composer"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: identities, ROW: "Identities, .*", Q: "Identities", PROOF: "Sending Identities"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: vacation, ROW: "Out of Office, .*", Q: "Office", PROOF: "Reply automatically"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: filters, ROW: "Filters, .*", Q: "Filters", PROOF: "Email Filters"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: templates, ROW: "Templates, .*", Q: "Templates", PROOF: "Email Templates"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: folders, ROW: "Folders, .*", Q: "Folders", PROOF: ".*Inbox.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: tags, ROW: "Tags, .*", Q: "Tags", PROOF: "Email Tags"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: downloads, ROW: "Downloads, .*", Q: "Downloads", PROOF: "Email .*eml.* filename"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: security, ROW: "Security, .*", Q: "Security", PROOF: "Account Security|Detecting server capabilities.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: content, ROW: "Content & Senders, .*", Q: "Content", PROOF: "External Content"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: calendar, ROW: "Calendar, .*", Q: "Calendar", PROOF: "Default view"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: contacts, ROW: "Contacts, .*", Q: "Contacts", PROOF: "Group by first letter"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: files, ROW: "Files, .*", Q: "Files", PROOF: "Default View"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: sidebar-apps, ROW: "Sidebar Apps, .*", Q: "Sidebar", PROOF: "Keep Apps Loaded"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: themes, ROW: "Themes, .*", Q: "Themes", PROOF: "The default light and dark palettes."}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", Q: "Storage", PROOF: "Offline mail.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: about, ROW: "About, .*", Q: "About", PROOF: "This device"}}
- tapOn:
id: tab-mail
+32 -22
View File
@@ -1,8 +1,13 @@
# Push on iOS: the relay's payload carries only ids; the notification service
# extension (plugins/ios-push) fetches sender and subject before the banner
# shows. A tap opens the message, with the app running and from a cold start.
# The helper sends the message as userb and pushes with simctl, so this needs
# no APNs key.
# Push on iOS: the relay's payload carries only ids and a placeholder text.
# A tap opens the message, with the app running and from a cold start. The
# helper sends the message as userb and pushes the relay's payload with
# simctl, so this needs no APNs key.
#
# simctl push hands the payload to the simulator as a local notification
# request: the notification service extension (plugins/ios-push), which
# puts sender and subject in, never runs that way (the log shows no
# NotificationService process). So the banner shows the placeholder here;
# the extension needs a real APNs push to be tested.
appId: org.bulwarkmail.mobile
name: Push notification on iOS
tags:
@@ -30,38 +35,43 @@ tags:
timeout: 3000
- pressKey: Home
- evalScript: ${output.subject = 'E2E push ' + Date.now()}
# 1. The banner shows (and what it says).
- evalScript: ${output.subject0 = 'E2E push banner ' + Date.now()}
- runScript:
file: ../scripts/helper.js
env:
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject)}
# The banner shows for a few seconds: catch it with either text, then check.
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject0)}
- extendedWaitUntil:
visible: ".*${output.subject}.*|.*New message.*"
visible: ".*${output.subject0}.*|.*New message.*"
timeout: 30000
- takeScreenshot: push-01-banner
# Sender and subject come from the extension, not the relay.
- assertVisible: ".*User B.*"
- assertNotVisible: ".*New message.*"
- tapOn: ".*${output.subject}.*"
# Let it go, so the next tap can't hit this one.
- extendedWaitUntil:
notVisible: ".*${output.subject0}.*|.*New message.*"
timeout: 20000
# 2. A tap opens the message, app in the background. The banner shows for a
# few seconds and finding it takes Maestro about as long; taps on the lock
# screen list do nothing on these simulators. So tap where the banner
# appears, right after the push (push-and-tap.yaml).
- evalScript: ${output.subject = 'E2E push ' + Date.now()}
- runFlow:
file: ../subflows/push-and-tap.yaml
env:
SUBJECT: ${output.subject}
- extendedWaitUntil:
visible: "Reply All"
timeout: 60000
- assertVisible: ".*${output.subject}.*"
- takeScreenshot: push-02-opened-warm
# Cold start: the app isn't running when the notification is tapped.
# 3. The same from a cold start: the app isn't running.
- stopApp
- evalScript: ${output.subject2 = 'E2E push cold ' + Date.now()}
- runScript:
file: ../scripts/helper.js
- runFlow:
file: ../subflows/push-and-tap.yaml
env:
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject2)}
- extendedWaitUntil:
visible: ".*${output.subject2}.*"
timeout: 30000
- takeScreenshot: push-03-banner-cold
- tapOn: ".*${output.subject2}.*"
SUBJECT: ${output.subject2}
- extendedWaitUntil:
visible: "Reply All"
timeout: 90000
@@ -0,0 +1,21 @@
# The app asks for notification permission now and then (calendar
# reminders, push). The system alert covers the whole app, so XCTest sees
# nothing else until it is answered: allow it when it is up. Taps on system
# alerts miss in landscape, so answer it in portrait.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
visible: ".*Would Like to Send You Notifications.*"
commands:
- setOrientation: PORTRAIT
- waitForAnimationToEnd:
timeout: 3000
- tapOn:
text: "Allow"
optional: true
- runFlow:
when:
true: ${ORIENTATION == 'landscape'}
commands:
- setOrientation: LANDSCAPE_LEFT
+30 -8
View File
@@ -1,19 +1,41 @@
# In the composer: type BODY into the editor. The rich editor is a WebView
# whose placeholder is CSS, so it may not be exposed; then tap the middle of
# the screen, which is the body area below Subject (above the keyboard).
# FROM_SUBJECT=yes: the Subject field has the focus; Return there moves it
# to the body (an iPad in landscape shows hardly any body with the keyboard
# up, so there is nothing to tap).
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
visible: "Write your message.*"
true: ${FROM_SUBJECT == 'yes'}
commands:
- tapOn: "Write your message.*"
- pressKey: Enter
- waitForAnimationToEnd:
timeout: 2000
- inputText: ${BODY}
- runFlow:
when:
notVisible: "Write your message.*"
true: ${FROM_SUBJECT != 'yes'}
commands:
- tapOn:
point: "50%,50%"
- waitForAnimationToEnd:
timeout: 3000
- inputText: ${BODY}
- runFlow:
when:
visible: "Write your message.*"
commands:
- tapOn: "Write your message.*"
# A tap on the WebView doesn't always take the focus from the Subject field
# (on the iPad the body once went into Subject); a second tap does.
# (hideKeyboard fails here: the composer's keyboard has no dismiss key.)
- runFlow:
when:
notVisible: "Write your message.*"
commands:
- tapOn:
point: "50%,50%"
- waitForAnimationToEnd:
timeout: 2000
- tapOn:
point: "50%,50%"
- waitForAnimationToEnd:
timeout: 3000
- inputText: ${BODY}
+2 -1
View File
@@ -10,9 +10,10 @@ appId: org.bulwarkmail.mobile
visible: "Open in .*Bulwark Mobile.*"
commands:
- tapOn: "Open"
# Right after a sign-out the app can take a while to get to the link.
- extendedWaitUntil:
visible: "Sign in with a link.|Open the sign-in page of.*"
timeout: 20000
timeout: 60000
- takeScreenshot: link-confirm-prompt
- tapOn:
text: "Continue"
+6 -2
View File
@@ -18,5 +18,9 @@ appId: org.bulwarkmail.mobile
visible:
id: email-row
timeout: 60000
# Rows are one element: sender, subject and preview together.
- assertVisible: ${INBOX_TEXT || '.*Your verification code.*'}
# Rows are one element: sender, subject and preview together. Mail the
# tests sent earlier can push the seeded row below the fold.
- scrollUntilVisible:
element: ${INBOX_TEXT || '.*Your verification code.*'}
direction: DOWN
timeout: 30000
+12
View File
@@ -14,6 +14,18 @@ appId: org.bulwarkmail.mobile
TAP: "no"
TEXT: ${HOST}
- tapOn: "Continue"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
optional: true
# Once in a while the tap doesn't reach the button (it moves as the keyboard
# goes away): tap again if the form is still there.
- runFlow:
when:
visible:
id: login-server-input
commands:
- tapOn: "Continue"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
+28 -5
View File
@@ -1,13 +1,36 @@
# From a message list: open the message whose row mentions SUBJECT.
# A row near the bottom can count as visible while the phone's tab bar
# covers it (the tap then opens another tab): on the phone, scroll it to the
# middle. The iPad's list is a column Maestro's scroll (in the middle of the
# screen) doesn't reach; its rows fit on screen.
appId: org.bulwarkmail.mobile
---
- scrollUntilVisible:
element: ".*${SUBJECT}.*"
direction: DOWN
timeout: 30000
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- scrollUntilVisible:
element: ".*${SUBJECT}.*"
direction: DOWN
centerElement: true
visibilityPercentage: 100
timeout: 30000
- runFlow:
when:
true: ${DEVICE == 'tablet'}
commands:
- scrollUntilVisible:
element: ".*${SUBJECT}.*"
direction: DOWN
timeout: 30000
# A tap while the list still moves lands on another row.
- waitForAnimationToEnd:
timeout: 5000
- tapOn: ".*${SUBJECT}.*"
# The message toolbar's "more" button: "Reply All" shows on the phone, but
# the iPad pane has a quick reply box instead for mail to one recipient.
- extendedWaitUntil:
visible: "Reply All"
visible: "more"
timeout: 20000
- waitForAnimationToEnd:
timeout: 5000
+23
View File
@@ -0,0 +1,23 @@
# Push a message with SUBJECT to ACCOUNT (helper /simpush) and tap its banner
# where it appears, at once (it shows for a few seconds, about as long as
# Maestro takes to find it). If the tap came too early or too late, push
# again: up to three tries until the message is open.
appId: org.bulwarkmail.mobile
---
- repeat:
while:
notVisible: "Reply All"
times: 3
commands:
- runScript:
file: ../scripts/helper.js
env:
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(SUBJECT)}
- tapOn:
point: "50%,9%"
waitToSettleTimeoutMs: 500
retryTapIfNoChange: false
- extendedWaitUntil:
visible: "Reply All"
timeout: 30000
optional: true
+74 -11
View File
@@ -1,18 +1,81 @@
# Settings list: scroll to the row ROW (regex on "Title, summary"), open it,
# check PROOF shows, screenshot, and go back on a phone. On the iPad the list
# is a left column, so scroll there instead of the middle of the screen.
# Settings list: find the row ROW (regex on "Title, summary"), open it,
# check PROOF shows, screenshot, and go back on a phone.
#
# Rows count as visible while the phone's tab bar or header covers them or
# while they hang half off the iPad's screen, and a tap then misses; the
# iPad's list is a left column Maestro's scroll (in the middle of the screen)
# doesn't reach. So search for the page (Q, a word of its title): the row
# then sits right under the search box. The account card (no Q) is always
# at the top.
appId: org.bulwarkmail.mobile
---
- repeat:
while:
notVisible: ${ROW}
times: 10
- runFlow:
when:
true: ${!!Q}
commands:
- swipe:
start: "${DEVICE == 'tablet' ? '18%,80%' : '50%,80%'}"
end: "${DEVICE == 'tablet' ? '18%,40%' : '50%,40%'}"
duration: 600
- tapOn: "Search settings"
- eraseText: 40
- inputText: ${Q}
- waitForAnimationToEnd:
timeout: 3000
# Now and then a typed character goes missing: type it again.
- runFlow:
when:
notVisible: ${ROW}
commands:
- tapOn: "Search settings"
- eraseText: 40
- inputText: ${Q}
- waitForAnimationToEnd:
timeout: 3000
# A tap while the list still coasts from the swipe only stops it.
- waitForAnimationToEnd:
timeout: 5000
- tapOn: ${ROW}
- waitForAnimationToEnd:
timeout: 5000
# On the phone the page replaces the list: make sure it opened before
# scrolling (else the swipes below scroll the list away).
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- runFlow:
when:
notVisible: "Back"
commands:
- tapOn: ${ROW}
- extendedWaitUntil:
visible: "Back"
timeout: 10000
# PROOF can be further down the page: scroll the page (the right-hand pane
# on the iPad) until it shows.
- runFlow:
when:
true: ${DEVICE == 'tablet'}
commands:
- repeat:
while:
notVisible: ${PROOF}
times: 6
commands:
- swipe:
start: 75%,80%
end: 75%,40%
duration: 600
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- repeat:
while:
notVisible: ${PROOF}
times: 6
commands:
- swipe:
start: 50%,80%
end: 50%,40%
duration: 600
- extendedWaitUntil:
visible: ${PROOF}
timeout: 20000
+22 -4
View File
@@ -1,12 +1,30 @@
# Signed in: sign out of the active account from the end of Settings.
# On the iPad the Settings list is the left column; scroll that one.
appId: org.bulwarkmail.mobile
---
- tapOn:
id: tab-settings
- scrollUntilVisible:
element: "Sign out"
direction: DOWN
timeout: 30000
- runFlow:
when:
true: ${DEVICE == 'tablet'}
commands:
- repeat:
while:
notVisible: "Sign out"
times: 12
commands:
- swipe:
start: 30%,85%
end: 30%,30%
duration: 500
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- scrollUntilVisible:
element: "Sign out"
direction: DOWN
timeout: 30000
- tapOn: "Sign out"
- extendedWaitUntil:
visible: "The account is removed from this device.*"
+9 -1
View File
@@ -7,6 +7,13 @@ appId: org.bulwarkmail.mobile
notifications: allow
photos: allow
camera: allow
# Answer the notification prompt (if the app asks) before turning to
# landscape: rotated, taps on system alerts miss.
- setOrientation: PORTRAIT
- extendedWaitUntil:
visible: "Enter server details manually|Inbox|.*Would Like to Send You Notifications.*"
timeout: 120000
- runFlow: allow-notifications.yaml
- runFlow:
when:
true: ${ORIENTATION == 'landscape'}
@@ -14,7 +21,7 @@ appId: org.bulwarkmail.mobile
- setOrientation: LANDSCAPE_LEFT
- extendedWaitUntil:
visible: "Enter server details manually|Inbox"
timeout: 120000
timeout: 30000
- runFlow:
when:
visible: "Enter server details manually"
@@ -23,6 +30,7 @@ appId: org.bulwarkmail.mobile
file: password-sign-in.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow: allow-notifications.yaml
- tapOn:
id: tab-mail
- runFlow: go-to-inbox.yaml
+12 -10
View File
@@ -1,8 +1,10 @@
# Type TEXT into a web page field found by its placeholder HINT (the sign-in
# pages in the auth session), and check it arrived as typed. Now and then a
# stray character lands in front of it on the CI simulator; then select the
# field's text and type over it. SECRET=yes skips the check (a password
# field shows dots).
# stray character lands in front of it on the CI simulator; then erase the
# field and type again. The caret is still at the end of what was just
# typed, so eraseText (which deletes before the caret) empties it; selecting
# the text with a long press didn't work and appended instead. SECRET=yes
# skips the check (a password field shows dots).
appId: org.bulwarkmail.mobile
---
- tapOn: ${HINT}
@@ -13,13 +15,13 @@ appId: org.bulwarkmail.mobile
when:
true: ${SECRET != 'yes'}
commands:
- runFlow:
when:
- repeat:
while:
notVisible: ${TEXT}
times: 2
commands:
- longPressOn: ".*${TEXT}.*"
- tapOn:
text: "Select All"
optional: true
- eraseText: 150
- waitForAnimationToEnd:
timeout: 2000
- inputText: ${TEXT}
- assertVisible: ${TEXT}
- assertVisible: ${TEXT}
+15 -16
View File
@@ -43,20 +43,14 @@ import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
// Pause between a mute change and the push-filter update it sends (#78).
const MUTES_RESYNC_DELAY_MS = 1_000;
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
import EmailSourceScreen from './src/screens/EmailSourceScreen';
import LoginScreen from './src/screens/LoginScreen';
import MailTab from './src/screens/MailTab';
import FilesScreen from './src/screens/FilesScreen';
import CalendarScreen from './src/screens/CalendarScreen';
import ContactsTab from './src/screens/ContactsTab';
import ContactDetailScreen from './src/screens/ContactDetailScreen';
import ContactFormScreen from './src/screens/ContactFormScreen';
import GroupDetailScreen from './src/screens/GroupDetailScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import ScheduledScreen from './src/screens/ScheduledScreen';
import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen';
// Screens that aren't on the way to the inbox load on first visit
// (getComponent below), so their modules stay out of the startup work.
import { useAccountStore } from './src/stores/account-store';
import { useAuthStore } from './src/stores/auth-store';
import { useCalendarStore } from './src/stores/calendar-store';
@@ -94,6 +88,7 @@ import { useOfflineCacheStore } from './src/stores/offline-cache-store';
import { useOutboxStore } from './src/stores/outbox-store';
import { runOfflineSync } from './src/lib/offline-sync';
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
import { perfEndAfterPaint, perfMarksEnabled, perfStart } from './src/lib/perf-marks';
import { useColors } from './src/theme/colors';
const Stack = createNativeStackNavigator<RootStackParamList>();
@@ -261,6 +256,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
<PushOnboardingPrompt />
<AppIconBadge />
<Tab.Navigator
screenListeners={perfMarksEnabled() ? ({ route }) => ({
tabPress: () => perfStart('tab-switch'),
focus: () => perfEndAfterPaint('tab-switch', { to: route.name }),
}) : undefined}
screenOptions={{
headerShown: false,
// Tabs out of view stop re-rendering until they are shown again.
@@ -318,7 +317,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
</Tab.Screen>
<Tab.Screen
name="Calendar"
component={CalendarScreen}
getComponent={() => require('./src/screens/CalendarScreen').default}
options={{
title: tabLabels.calendar,
tabBarButtonTestID: 'tab-calendar',
@@ -334,7 +333,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
/>
<Tab.Screen
name="Contacts"
component={ContactsTab}
getComponent={() => require('./src/screens/ContactsTab').default}
options={{
title: tabLabels.contacts,
tabBarButtonTestID: 'tab-contacts',
@@ -350,7 +349,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
/>
<Tab.Screen
name="Files"
component={FilesScreen}
getComponent={() => require('./src/screens/FilesScreen').default}
options={{
title: tabLabels.files,
tabBarButtonTestID: 'tab-files',
@@ -868,10 +867,10 @@ export default function App() {
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withHosts}>
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
<Stack.Screen name="EmailSource" getComponent={() => require('./src/screens/EmailSourceScreen').default} />
<Stack.Screen
name="Compose"
component={ComposeScreen}
getComponent={() => require('./src/screens/ComposeScreen').default}
// A tablet shows the composer as a panel over the screen it came
// from, so that screen stays drawn behind the dimmed backdrop.
options={isSplit
@@ -881,15 +880,15 @@ export default function App() {
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
<Stack.Screen
name="ContactForm"
component={ContactFormScreen}
getComponent={() => require('./src/screens/ContactFormScreen').default}
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
}}
/>
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
<Stack.Screen name="Scheduled" getComponent={() => require('./src/screens/ScheduledScreen').default} />
<Stack.Screen name="UnifiedInbox" getComponent={() => require('./src/screens/UnifiedInboxScreen').default} />
<Stack.Screen
name="AddAccount"
options={{
+4
View File
@@ -6,6 +6,10 @@ import { registerWidgetTaskHandler } from 'react-native-android-widget';
import { pushBackgroundTask } from './src/lib/push-background-task';
import { widgetTaskHandler } from './src/widgets/task-handler';
import { refreshWidgetsInBackground } from './src/widgets/sync';
import { perfMark } from './src/lib/perf-marks';
// Every import above has run: the bundle is loaded (performance harness).
perfMark('bundle-loaded');
import { markUiStarted } from './src/widgets/ui-presence';
// Runs in a fresh headless JS runtime when BulwarkPushTaskService is started
+41
View File
@@ -1,5 +1,46 @@
const path = require('path');
const fs = require('fs');
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
// Performance harness (the iOS simulator tests, .github/workflows/e2e-ios.yml):
// with BULWARK_MODULE_TIMINGS set, the bundle times each module's
// evaluation (scripts/perf/module-timings.js), and BULWARK_MODULE_MAP names
// a file that receives the module ids with their source paths, so the
// harness can tell which module is which. Release builds set neither.
if (process.env.BULWARK_MODULE_TIMINGS) {
const basePolyfills = config.serializer.getPolyfills;
config.serializer.getPolyfills = (ctx) => [
...basePolyfills(ctx),
path.join(__dirname, 'scripts/perf/module-timings.js'),
];
const mapFile = process.env.BULWARK_MODULE_MAP;
if (mapFile) {
const baseFactory = config.serializer.createModuleIdFactory;
config.serializer.createModuleIdFactory = () => {
const idOf = baseFactory();
const names = {};
process.on('exit', () => {
try { fs.writeFileSync(mapFile, JSON.stringify(names)); } catch { /* best effort */ }
});
return (modulePath) => {
const id = idOf(modulePath);
names[id] = path.relative(__dirname, modulePath).split(path.sep).join('/');
return id;
};
};
}
}
// Evaluate a module when it is first used instead of all of them before the
// first render. Expo's default leaves this off; React Native's own template
// turns it on. Side-effect-only imports (`import './x'`) still run in place.
config.transformer.getTransformOptions = async () => ({
transform: {
experimentalImportSupport: true,
inlineRequires: true,
},
});
module.exports = config;
+17 -2
View File
@@ -105,9 +105,24 @@ function simctl(args, input) {
});
}
// simctl pbcopy times out now and then (seen on the iPad simulator); a
// second or third try goes through.
async function pbcopy(text) {
for (let attempt = 1; ; attempt++) {
try {
await simctl(['pbcopy', UDID], text);
return;
} catch (e) {
if (attempt >= 3) throw e;
log(`pbcopy attempt ${attempt} failed, trying again`);
await new Promise((r) => setTimeout(r, 2000));
}
}
}
async function deliver(link, how) {
if (how === 'openurl') await simctl(['openurl', UDID, link]);
else if (how === 'pbcopy') await simctl(['pbcopy', UDID], link);
else if (how === 'pbcopy') await pbcopy(link);
else if (how !== 'none') throw new Error(`unknown deliver=${how}`);
}
@@ -398,7 +413,7 @@ const routes = {
return { ok: true };
},
async '/clipboard'(q) {
await simctl(['pbcopy', UDID], q.get('text') || '');
await pbcopy(q.get('text') || '');
return { ok: true };
},
};
+57 -6
View File
@@ -11,7 +11,10 @@
// stays signed in),
// 2. cold-starts it `--cold` times: terminate, launch, wait for
// `inbox-rendered`; cold start = that mark's time minus the launch time
// taken on this machine (the simulator shares the host clock),
// taken on this machine (the simulator shares the host clock). The
// first launch after the install is much slower and swings a lot (iOS
// does its first-launch work for the new binary), so it is reported on
// its own as `first-start-*`; `cold-start-*` are the launches after it,
// 3. runs the Maestro interaction flows once each (open messages, switch
// tabs, open compose, scroll), which produce the other marks. Each flow
// is its own run, so one that fails costs only its own metric.
@@ -101,12 +104,14 @@ function waitForMark(name, timeoutMs) {
// ── steps ───────────────────────────────────────────────────────────────────
const samples = []; // { label, metric, ms, round }
const moduleRuns = {}; // label -> [{ total, top: { id: ms } }] (module-times marks)
async function coldStart(label, round) {
async function coldStart(label, round, first) {
try { simctl('terminate', UDID, BUNDLE); } catch { /* not running */ }
await sleep(1500);
const pending = waitForMark('inbox-rendered', 120_000);
const bundlePending = waitForMark('bundle-loaded', 120_000);
const modulesPending = waitForMark('module-times', 125_000);
const t0 = Date.now();
simctl('launch', UDID, BUNDLE, '-perfMarks', 'YES');
const [mark, bundle] = await Promise.all([pending, bundlePending]);
@@ -114,8 +119,33 @@ async function coldStart(label, round) {
console.log(` ${label} cold start: no inbox-rendered mark within 120 s`);
return;
}
samples.push({ label, round, metric: 'cold-start-to-inbox', ms: mark.t - t0 });
if (bundle) samples.push({ label, round, metric: 'cold-start-to-bundle', ms: bundle.t - t0 });
const kind = first ? 'first-start' : 'cold-start';
// Builds made with BULWARK_MODULE_TIMINGS log how long each module took
// to evaluate, right after the first inbox (src/lib/perf-marks.ts).
const modules = await Promise.race([modulesPending, sleep(3000).then(() => null)]);
if (modules && !first) {
const top = {};
for (const pair of String(modules.top || '').split(',')) {
const [id, ms] = pair.split(':');
if (id) top[id] = Number(ms);
}
(moduleRuns[label] ||= []).push({ total: modules.total, top });
}
samples.push({ label, round, metric: `${kind}-to-inbox`, ms: mark.t - t0 });
if (bundle) samples.push({ label, round, metric: `${kind}-to-bundle`, ms: bundle.t - t0 });
// The start-up timeline: every mark between launch and the inbox, as time
// since launch, plus how long each persisted cache took to read and parse.
if (!first) {
for (const m of marks) {
if (m.t < t0 || m.t > mark.t || m.n === 'inbox-rendered' || m.n === 'bundle-loaded') continue;
const key = m.store ? `${m.n}:${m.store}` : m.n;
samples.push({ label, round, metric: `start:${key}`, ms: m.t - t0 });
if (m.n === 'hydrate') {
samples.push({ label, round, metric: `hydrate-read:${m.store}`, ms: m.readMs });
samples.push({ label, round, metric: `hydrate-parse:${m.store}`, ms: m.parseMs, kb: m.kb });
}
}
}
console.log(` ${label} cold start ${mark.t - t0} ms (bundle ${bundle ? bundle.t - t0 : '?'} ms, rows ${mark.rows})`);
await sleep(3000);
}
@@ -168,7 +198,28 @@ const scroll = labels.map((l) => {
if (!xs.length) return null;
return `${l}: median ${median(xs.map((s) => s.long))} long frames of ${median(xs.map((s) => s.frames))}, worst ${median(xs.map((s) => s.worst))} ms`;
}).filter(Boolean);
const md = [...lines, '', ...scroll.map((s) => `- list-scroll ${s}`), ''].join('\n');
// The slowest modules at start-up per app, by median over the launches.
const moduleSections = [];
for (const app of apps) {
const runs = moduleRuns[app.label];
if (!runs?.length) continue;
let names = {};
try { names = JSON.parse(fs.readFileSync(path.join(path.dirname(app.path), 'module-map.json'), 'utf8')); } catch { /* no map */ }
const ids = new Set(runs.flatMap((r) => Object.keys(r.top)));
const rows = [...ids]
.map((id) => ({ id, ms: median(runs.map((r) => r.top[id] ?? 0)) }))
.sort((a, b) => b.ms - a.ms)
.slice(0, 30);
moduleSections.push(
`### Slowest modules at start-up: ${app.label} (own evaluation time, median of ${runs.length} launches; all modules ${median(runs.map((r) => r.total))} ms)`,
'',
'| module | ms |',
'|---|---|',
...rows.map((r) => `| ${names[r.id] ?? `#${r.id}`} | ${r.ms} |`),
'',
);
}
const md = [...lines, '', ...scroll.map((s) => `- list-scroll ${s}`), '', ...moduleSections].join('\n');
fs.writeFileSync(path.join(out, 'perf.json'), JSON.stringify({ apps, rounds, coldRuns, samples }, null, 1));
fs.writeFileSync(path.join(out, 'perf.md'), md);
console.log(md);
@@ -185,7 +236,7 @@ if (final && process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITH
currentLabel = app.label;
try { simctl('terminate', UDID, BUNDLE); } catch { /* not running */ }
simctl('install', UDID, app.path);
for (let i = 0; i < coldRuns; i++) await coldStart(app.label, round);
for (let i = 0; i < coldRuns; i++) await coldStart(app.label, round, i === 0);
const got = await runFlows(app.label);
for (const m of got) {
const metric = m.n === 'tab-switch' && m.to ? `tab-switch:${m.to}` : m.n;
+13 -10
View File
@@ -34,17 +34,20 @@ maestro --version
xcrun simctl io "$UDID" recordVideo --codec=h264 --force "$OUT/run.mp4" &
RECORDER=$!
# Warm-up: the XCTest driver takes minutes to start on a cold runner.
WARMUP_START=$(date +%s)
(cd "$OUT/screenshots" && capped maestro --device "$UDID" test \
--debug-output "$RUNNER_TEMP/maestro-warmup" "$ROOT/.maestro/launch.yaml")
STATUS=$?
echo "warm-up took $(( $(date +%s) - WARMUP_START )) s (status $STATUS)"
if [ $STATUS -ne 0 ]; then
echo "::group::Maestro warm-up log"
find "$RUNNER_TEMP/maestro-warmup" -name 'maestro.log' -exec tail -n 300 {} \;
# Warm-up: the XCTest driver takes minutes to start on a cold runner. When
# it dies on the way (it does, now and then), a second try starts a new one
# instead of losing the whole leg.
for attempt in 1 2; do
WARMUP_START=$(date +%s)
(cd "$OUT/screenshots" && capped maestro --device "$UDID" test \
--debug-output "$RUNNER_TEMP/maestro-warmup-$attempt" "$ROOT/.maestro/launch.yaml")
STATUS=$?
echo "warm-up $attempt took $(( $(date +%s) - WARMUP_START )) s (status $STATUS)"
[ $STATUS -eq 0 ] && break
echo "::group::Maestro warm-up log $attempt"
find "$RUNNER_TEMP/maestro-warmup-$attempt" -name 'maestro.log' -exec tail -n 300 {} \;
echo "::endgroup::"
fi
done
if [ $STATUS -eq 0 ]; then
ARGS=(
+30
View File
@@ -0,0 +1,30 @@
// Metro polyfill for the performance harness (metro.config.js adds it only
// when BULWARK_MODULE_TIMINGS is set at bundle time; release builds don't
// have it). It wraps every module factory and adds up how long each module's
// own code ran when it was first evaluated, without the modules it required
// meanwhile. src/lib/perf-marks.ts logs the slowest ones at first inbox.
(function (g) {
var define = g.__d;
if (typeof define !== 'function') return;
var now = typeof g.nativePerformanceNow === 'function'
? g.nativePerformanceNow
: function () { return Date.now(); };
var times = {};
var stack = [];
g.__bulwarkModuleTimes = times;
g.__d = function (factory, moduleId, dependencyMap, verboseName) {
var timed = function () {
var start = now();
stack.push(0);
try {
return factory.apply(this, arguments);
} finally {
var total = now() - start;
var nested = stack.pop();
times[moduleId] = (times[moduleId] || 0) + (total - nested);
if (stack.length > 0) stack[stack.length - 1] += total;
}
};
return define(timed, moduleId, dependencyMap, verboseName);
};
})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const secure = new Map<string, string>();
vi.mock('expo-secure-store', () => ({
setItemAsync: vi.fn(async (k: string, v: string) => { secure.set(k, v); }),
getItemAsync: vi.fn(async (k: string) => secure.get(k) ?? null),
deleteItemAsync: vi.fn(async (k: string) => { secure.delete(k); }),
}));
import AsyncStorage from '@react-native-async-storage/async-storage';
import { JMAPClient, AuthenticationError } from '../jmap-client';
import type { JMAPSession } from '../types';
// Start-up used to wait for the server's session document before the
// account could show. The client now keeps the last one per account and
// starts from it (loadAccountFromCache), then fetches a fresh one.
const ACCOUNT = 'user@mail.example.com';
const CREDS = { serverUrl: 'https://mail.example.com', username: 'user', password: 'pass' };
const SESSION: JMAPSession = {
apiUrl: 'https://mail.example.com/jmap/',
downloadUrl: 'https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}',
uploadUrl: 'https://mail.example.com/upload/{accountId}/',
eventSourceUrl: 'https://mail.example.com/eventsource/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc-1', 'urn:ietf:params:jmap:core': 'acc-1' },
accounts: { 'acc-1': { name: 'user@example.com', isPersonal: true, isReadOnly: false } },
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {} },
state: 'state-1',
};
function mockFetch(responses: Array<{ status: number; json?: unknown }>) {
let i = 0;
const fn = vi.fn(async (url: string) => {
const r = responses[i++] ?? responses[responses.length - 1];
return {
ok: r.status >= 200 && r.status < 300,
status: r.status,
statusText: r.status === 200 ? 'OK' : 'Error',
url,
redirected: false,
headers: { get: () => null },
json: async () => r.json,
text: async () => (r.json === undefined ? '' : JSON.stringify(r.json)),
};
});
vi.stubGlobal('fetch', fn);
return fn;
}
async function storeCredentials() {
const SecureStore = await import('expo-secure-store');
await SecureStore.setItemAsync(`jmap_credentials__${ACCOUNT.replace(/[^a-zA-Z0-9._-]/g, '_')}`, JSON.stringify(CREDS));
}
beforeEach(async () => {
secure.clear();
await AsyncStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('saved session', () => {
it('starts from the session of the last connect, without a request', async () => {
await storeCredentials();
mockFetch([{ status: 200, json: SESSION }]);
expect(await new JMAPClient().loadAccount(ACCOUNT)).toBe(true);
await vi.waitFor(async () => expect(await AsyncStorage.getAllKeys()).toHaveLength(1));
const fetch = mockFetch([{ status: 500 }]);
const client = new JMAPClient();
expect(await client.loadAccountFromCache(ACCOUNT)).toBe(true);
expect(fetch).not.toHaveBeenCalled();
expect(client.accountId).toBe('acc-1');
expect(client.currentSession?.apiUrl).toBe(SESSION.apiUrl);
});
it('has nothing to start from before the first connect', async () => {
await storeCredentials();
expect(await new JMAPClient().loadAccountFromCache(ACCOUNT)).toBe(false);
});
it('ignores a session saved for another server', async () => {
await storeCredentials();
await AsyncStorage.setItem(
`jmap_session_cache__${ACCOUNT}`,
JSON.stringify({ serverUrl: 'https://old.example.com', session: SESSION }),
);
expect(await new JMAPClient().loadAccountFromCache(ACCOUNT)).toBe(false);
});
it('is dropped with the credentials', async () => {
await storeCredentials();
await AsyncStorage.setItem(
`jmap_session_cache__${ACCOUNT}`,
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
);
await new JMAPClient().clearAccountCredentials(ACCOUNT);
expect(await AsyncStorage.getItem(`jmap_session_cache__${ACCOUNT}`)).toBeNull();
});
it('revalidates against the server and reports refused credentials', async () => {
await storeCredentials();
await AsyncStorage.setItem(
`jmap_session_cache__${ACCOUNT}`,
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
);
const client = new JMAPClient();
await client.loadAccountFromCache(ACCOUNT);
mockFetch([{ status: 401 }]);
await expect(client.revalidateSession()).rejects.toBeInstanceOf(AuthenticationError);
});
it('drops a revalidation that finishes after a switch to another account', async () => {
await storeCredentials();
await AsyncStorage.setItem(
`jmap_session_cache__${ACCOUNT}`,
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
);
const client = new JMAPClient();
await client.loadAccountFromCache(ACCOUNT);
mockFetch([{ status: 200, json: { ...SESSION, apiUrl: 'https://mail.example.com/jmap2/' } }]);
const pending = client.revalidateSession();
client.reset();
expect(await pending).toBeNull();
expect(client.currentSession).toBeNull();
});
});
+84
View File
@@ -1,4 +1,5 @@
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type {
JMAPSession,
JMAPMethodCall,
@@ -44,6 +45,20 @@ function credentialsKey(accountId: string): string {
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
}
// The session document from the account's last connect (URLs, account ids,
// capabilities; no secrets). Start-up uses it to show the account without
// waiting for the network, then fetches a fresh one (revalidateSession).
const SESSION_CACHE_PREFIX = 'jmap_session_cache__';
function sessionCacheKey(accountId: string): string {
return SESSION_CACHE_PREFIX + accountId;
}
interface CachedSession {
serverUrl: string;
session: JMAPSession;
}
export interface StoredCredentials {
serverUrl: string;
username: string;
@@ -524,9 +539,74 @@ export class JMAPClient {
);
}
this._accountId = this.resolveAccountId(this.session);
this.rememberSession(accountId);
return true;
}
/**
* Start-up without a network round trip: the account's credentials and the
* session document saved at its last connect. Returns false when either is
* missing (or the saved one is for another server); the caller then uses
* loadAccount. A wrong password or revoked token surfaces on the first
* request (401, onAuthFailure) or in revalidateSession.
*/
async loadAccountFromCache(accountId: string): Promise<boolean> {
const [stored, cachedRaw] = await Promise.all([
SecureStore.getItemAsync(credentialsKey(accountId)),
AsyncStorage.getItem(sessionCacheKey(accountId)).catch(() => null),
]);
if (!stored || !cachedRaw) return false;
let creds: StoredCredentials;
let cached: CachedSession;
try {
creds = JSON.parse(stored);
cached = JSON.parse(cachedRaw);
} catch {
return false;
}
if (!cached?.session?.apiUrl || cached.serverUrl !== creds.serverUrl) return false;
let accountIdInSession: string;
try {
accountIdInSession = this.resolveAccountId(cached.session);
} catch {
return false;
}
this.credentials = creds;
this.firstTouchGate.reset();
this.session = cached.session;
this._accountId = accountIdInSession;
return true;
}
/**
* Fetch a fresh session document for the account the client holds now,
* after a start from the saved one. Resolves null when the client moved to
* another account meanwhile (the result is dropped). Throws
* AuthenticationError / NetworkError like loadAccount.
*/
async revalidateSession(): Promise<JMAPSession | null> {
const creds = this.credentials;
if (!creds) return null;
let fresh: JMAPSession;
try {
fresh = this.rewriteSessionUrls(await this.fetchSession(creds.serverUrl), creds.serverUrl);
} catch (err) {
if (this.credentials !== creds) return null;
throw err;
}
if (this.credentials !== creds) return null;
this.session = fresh;
this._accountId = this.resolveAccountId(fresh);
this.rememberSession(generateAccountId(creds.username, creds.serverUrl));
return fresh;
}
private rememberSession(accountId: string): void {
if (!this.session || !this.credentials) return;
const entry: CachedSession = { serverUrl: this.credentials.serverUrl, session: this.session };
void AsyncStorage.setItem(sessionCacheKey(accountId), JSON.stringify(entry)).catch(() => undefined);
}
/**
* Snapshot of the live connection so a failed "add account" / switch can put
* the previous account back without a network round-trip.
@@ -548,12 +628,14 @@ export class JMAPClient {
async clearAccountCredentials(accountId: string): Promise<void> {
await SecureStore.deleteItemAsync(credentialsKey(accountId));
await AsyncStorage.removeItem(sessionCacheKey(accountId)).catch(() => undefined);
}
async clearAllCredentials(accountIds: string[]): Promise<void> {
await Promise.all([
SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY),
...accountIds.map((id) => SecureStore.deleteItemAsync(credentialsKey(id))),
...accountIds.map((id) => AsyncStorage.removeItem(sessionCacheKey(id)).catch(() => undefined)),
]);
}
@@ -616,6 +698,7 @@ export class JMAPClient {
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
if (currentAccountId) {
await SecureStore.deleteItemAsync(credentialsKey(currentAccountId));
await AsyncStorage.removeItem(sessionCacheKey(currentAccountId)).catch(() => undefined);
}
}
@@ -835,6 +918,7 @@ export class JMAPClient {
const baseUrl = this.credentials.serverUrl;
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
this._accountId = this.resolveAccountId(this.session);
this.rememberSession(generateAccountId(this.credentials.username, baseUrl));
return this.session;
}
+7
View File
@@ -19,6 +19,7 @@ import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
import { perfEndAfterPaint, perfLap } from '../lib/perf-marks';
import type { RootStackParamList } from '../navigation/types';
type Nav = NativeStackNavigationProp<RootStackParamList>;
@@ -683,8 +684,13 @@ export default function EmailBodyView({
[prepared.isHtml, prepared.applyInversion, quoteLabels],
);
// Performance harness: how much of opening a message is the WebView.
React.useEffect(() => { perfLap('open-message', 'body-mounted'); }, []);
const onMessage = (e: WebViewMessageEvent) => {
const data = e.nativeEvent.data;
// The body reported its size: it is laid out (performance harness).
perfEndAfterPaint('open-message');
// Swipe/zoom messages arrive as JSON; the height reporter posts a bare number.
if (data.charCodeAt(0) === 123 /* '{' */) {
try {
@@ -788,6 +794,7 @@ export default function EmailBodyView({
injectedJavaScript={injectedJs}
onMessage={onMessage}
onLoadEnd={() => {
perfLap('open-message', 'webview-loaded');
// A (re)load resets the page's zoom state to 1 — drop any host-side
// scroll locks so they can't go stale (e.g. cid images arriving
// reload the source mid-zoom).
+3 -3
View File
@@ -5,7 +5,6 @@ import {
TextInput,
Pressable,
StyleSheet,
Modal,
Platform,
KeyboardAvoidingView,
ScrollView,
@@ -19,6 +18,7 @@ import Button from './Button';
import { useLocaleStore } from '../stores/locale-store';
import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
import { describeLoginError } from '../lib/login-errors';
import { SafeAreaModal } from './SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface PasteSignInLinkModalProps {
@@ -81,7 +81,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn
const title = t('login.mobile.paste_title', 'Paste sign-in link');
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
<View style={styles.header}>
@@ -145,7 +145,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</Modal>
</SafeAreaModal>
);
}
+4 -3
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native';
import { View, Text, Pressable, StyleSheet, Platform, Linking } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { X } from './icons';
@@ -7,6 +7,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
import { useColors, useResolvedTheme } from '../theme/colors';
import Button from './Button';
import { useLocaleStore } from '../stores/locale-store';
import { SafeAreaModal } from './SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface QrScanModalProps {
@@ -43,7 +44,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
);
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<View style={styles.container}>
{permission?.granted ? (
<CameraView
@@ -105,7 +106,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
) : null}
</SafeAreaView>
</View>
</Modal>
</SafeAreaModal>
);
}
+7
View File
@@ -3,6 +3,7 @@ import { StyleSheet, View, Platform } from 'react-native';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useColors } from '../theme/colors';
import type { ThemePalette } from '../theme/tokens';
import { perfEndAfterPaint } from '../lib/perf-marks';
import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
export type RichTextCommand =
@@ -65,6 +66,8 @@ export interface RichTextEditorHandle {
*/
getHtml(timeoutMs?: number): Promise<string>;
focus(): void;
/** Focus with the caret where new text goes (before signature / quote). */
focusEnd(): void;
/** Drop the caret, which closes the keyboard. */
blur(): void;
}
@@ -185,6 +188,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
pendingSnapshots.current.set(id, { resolve, reject, timer });
call(`window.__rne && window.__rne.getHtml(${id})`);
}),
focusEnd: () => {
call(`window.__rne && window.__rne.focusEnd()`);
},
focus: () => {
call(`window.__rne && window.__rne.focus()`);
},
@@ -202,6 +208,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
}
if (!readyRef.current) {
readyRef.current = true;
perfEndAfterPaint('compose-open');
const queued = queuedRef.current;
queuedRef.current = [];
for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`);
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import { Modal, type ModalProps } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
/**
* A Modal with its own safe-area provider. A Modal is a separate native
* window, and a SafeAreaView inside a slide-up Modal measured no insets: the
* view starts off screen when the slide begins, so on iPhones with a notch or
* Dynamic Island the header (and its close button) ended up under the status
* bar, where taps don't reach it. Use this for full-screen modals with a
* SafeAreaView header.
*/
export function SafeAreaModal({ children, ...props }: ModalProps) {
return (
<Modal {...props}>
<SafeAreaProvider>{children}</SafeAreaProvider>
</Modal>
);
}
+5 -3
View File
@@ -6,6 +6,7 @@ import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { undoSnackbarColors } from './undo-snackbar-colors';
import { useEmailStore } from '../stores/email-store';
import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../stores/send-undo-store';
import { useLocaleStore } from '../stores/locale-store';
@@ -158,6 +159,7 @@ export function UndoSnackbar() {
}
function makeStyles(c: ThemePalette) {
const colors = undoSnackbarColors(c);
return StyleSheet.create({
wrap: {
position: 'absolute',
@@ -170,7 +172,7 @@ function makeStyles(c: ThemePalette) {
paddingLeft: spacing.lg,
paddingRight: spacing.sm,
borderRadius: radius.md,
backgroundColor: c.text,
backgroundColor: colors.bar,
shadowColor: '#000',
shadowOpacity: 0.3,
shadowRadius: 12,
@@ -179,7 +181,7 @@ function makeStyles(c: ThemePalette) {
},
label: {
...typography.body,
color: c.background,
color: colors.label,
flex: 1,
},
undoBtn: {
@@ -192,7 +194,7 @@ function makeStyles(c: ThemePalette) {
},
undoText: {
...typography.bodySemibold,
color: c.primary,
color: colors.action,
letterSpacing: 0.5,
},
sendNowText: {
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// A SafeAreaView inside a slide-up react-native Modal measured no insets on
// iOS: the Modal is its own window and its content starts off screen, so on
// iPhones with a notch or Dynamic Island the header and its close button sat
// under the status bar, out of reach (attachment preview, QR scanner, paste
// sign-in link, filter and event editors). Such modals use SafeAreaModal,
// which gives the window its own SafeAreaProvider.
const SRC = path.resolve(__dirname, '../..');
function sourceFiles(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name);
if (e.isDirectory()) return e.name === '__tests__' ? [] : sourceFiles(p);
return e.name.endsWith('.tsx') ? [p] : [];
});
}
/** Opening tags of plain <Modal> elements, attributes included. */
function modalTags(source: string): string[] {
const tags: string[] = [];
let at = source.indexOf('<Modal');
while (at >= 0) {
if (/[\s>]/.test(source[at + 6] ?? '')) {
let depth = 0;
let i = at;
for (; i < source.length; i++) {
const ch = source[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
else if (ch === '>' && depth === 0 && source[i - 1] !== '=') break;
}
tags.push(source.slice(at, i + 1));
}
at = source.indexOf('<Modal', at + 6);
}
return tags;
}
describe('slide-up modals with a SafeAreaView', () => {
it('use SafeAreaModal so their header clears the status bar', () => {
const offenders = sourceFiles(SRC)
.filter((file) => {
const source = fs.readFileSync(file, 'utf8');
if (!source.includes('SafeAreaView')) return false;
return modalTags(source).some((tag) => /animationType="slide"/.test(tag));
})
.map((file) => path.relative(SRC, file));
expect(offenders).toEqual([]);
});
it('finds the tags it is looking for', () => {
const tags = modalTags('<View><Modal visible={x} onRequestClose={() => close()} animationType="slide">\n</Modal></View>');
expect(tags).toHaveLength(1);
expect(tags[0]).toContain('animationType="slide"');
});
});
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// Sheets and menus were built as a tappable backdrop (Pressable or
// TouchableWithoutFeedback) wrapping a card that swallows taps. Both are
// accessible elements by default, and an accessible element hides its
// children from VoiceOver and TalkBack: the compose "Attach" and "Schedule
// send" sheets read as one element ("Attach, Photos & Videos, Camera, Files,
// Cancel") whose options couldn't be picked one by one. The wrappers that
// only catch taps are marked accessible={false}, so the options inside stay
// reachable.
const SRC = path.resolve(__dirname, '../..');
function sourceFiles(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name);
if (e.isDirectory()) return e.name === '__tests__' ? [] : sourceFiles(p);
return e.name.endsWith('.tsx') ? [p] : [];
});
}
function openingTags(source: string, name: string): { tag: string; line: number }[] {
const out: { tag: string; line: number }[] = [];
const re = new RegExp(`<${name}\\b`, 'g');
let m: RegExpExecArray | null;
while ((m = re.exec(source))) {
let depth = 0;
let i = m.index;
for (; i < source.length; i++) {
const ch = source[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
else if (ch === '>' && depth === 0 && source[i - 1] !== '=') break;
}
out.push({ tag: source.slice(m.index, i + 1), line: source.slice(0, m.index).split('\n').length });
}
return out;
}
/** A wrapper that only catches taps and still hides what it wraps. */
export function isHidingWrapper(tag: string): boolean {
if (/accessible=\{false\}/.test(tag)) return false;
if (/\/\s*>$/.test(tag)) return false; // self-closing: wraps nothing
if (tag.startsWith('<TouchableWithoutFeedback')) return true;
if (/onPress=\{\(\) => \{\}\}/.test(tag)) return true;
return /style=\{styles\.\w*(Backdrop|backdrop|Overlay|overlay)\}/.test(tag);
}
describe('sheet backdrops', () => {
it('do not hide the options they wrap from screen readers', () => {
const offenders: string[] = [];
for (const file of sourceFiles(SRC)) {
const source = fs.readFileSync(file, 'utf8');
for (const name of ['Pressable', 'TouchableWithoutFeedback']) {
for (const { tag, line } of openingTags(source, name)) {
if (isHidingWrapper(tag)) offenders.push(`${path.relative(SRC, file)}:${line}`);
}
}
}
expect(offenders).toEqual([]);
});
it('recognises the wrappers it looks for', () => {
expect(isHidingWrapper('<Pressable style={styles.modalBackdrop} onPress={onClose}>')).toBe(true);
expect(isHidingWrapper('<Pressable style={styles.card} onPress={() => {}}>')).toBe(true);
expect(isHidingWrapper('<TouchableWithoutFeedback onPress={onClose}>')).toBe(true);
expect(isHidingWrapper('<Pressable accessible={false} style={styles.modalBackdrop} onPress={onClose}>')).toBe(false);
expect(isHidingWrapper('<Pressable style={styles.pickerOverlay} onPress={close} />')).toBe(false);
expect(isHidingWrapper('<Pressable style={styles.row} onPress={open}>')).toBe(false);
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { DARK_COLORS, LIGHT_COLORS, type ThemePalette } from '../../theme/tokens';
import { BUILTIN_THEMES } from '../../theme/builtin-themes';
import { undoSnackbarColors } from '../undo-snackbar-colors';
// The undo bar (UndoSnackbar) is drawn in the text colour. Its UNDO button
// used the primary colour, which is white in the default dark theme: white
// on white, so in dark mode the bar showed "Email archived" and no visible
// way to undo. The bound is 3:1, not 4.5:1, because Solarized's own text
// and background are a little under 4.5:1.
function luminance(hex: string): number {
const v = hex.replace('#', '');
const full = v.length === 3 ? v.split('').map((ch) => ch + ch).join('') : v.slice(0, 6);
const [r, g, b] = [0, 2, 4].map((i) => {
const c = parseInt(full.slice(i, i + 2), 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a: string, b: string): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
const palettes: Array<[string, ThemePalette]> = [
['default light', LIGHT_COLORS],
['default dark', DARK_COLORS],
...BUILTIN_THEMES.flatMap((theme): Array<[string, ThemePalette]> => [
[`${theme.name} light`, { ...LIGHT_COLORS, ...theme.light } as ThemePalette],
[`${theme.name} dark`, { ...DARK_COLORS, ...theme.dark } as ThemePalette],
]),
];
describe('undo bar', () => {
it.each(palettes)('UNDO is readable on the bar (%s)', (_name, palette) => {
const { bar, action } = undoSnackbarColors(palette);
expect(contrast(action, bar)).toBeGreaterThanOrEqual(3);
});
});
@@ -74,9 +74,9 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<Text style={styles.title}>
{calendar
@@ -160,9 +160,9 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<View style={styles.titleRow}>
<Users size={18} color={c.textMuted} />
+3 -3
View File
@@ -1,6 +1,5 @@
import React from 'react';
import {
Modal,
View,
Text,
ScrollView,
@@ -69,6 +68,7 @@ import { ParticipantInput } from './ParticipantInput';
import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { RecurrenceEditor } from './RecurrenceEditor';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { SafeAreaModal } from '../SafeAreaModal';
import ToggleSwitch from '../ToggleSwitch';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
@@ -419,7 +419,7 @@ export function EventModal({
);
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS}
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
animationType="slide"
presentationStyle="fullScreen"
@@ -838,7 +838,7 @@ export function EventModal({
/>
)}
</SafeAreaView>
</Modal>
</SafeAreaModal>
);
}
@@ -1,6 +1,6 @@
import React from 'react';
import {
View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, Alert,
View, Text, StyleSheet, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, Alert,
useWindowDimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -21,6 +21,7 @@ import { formatFullDateTime } from '../../lib/email-date';
import type { EmlPreview } from './use-body-override';
import { useFilePreview } from './use-file-preview';
import { ArchiveSummary, PreviewPartList, type PreviewPart } from './PreviewPartList';
import { SafeAreaModal } from '../SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
export interface PreviewItem {
@@ -432,9 +433,9 @@ export function AttachmentPreviewModal({
);
if (docked) return item || loading ? content : null;
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
{content}
</Modal>
</SafeAreaModal>
);
}
+2 -2
View File
@@ -160,9 +160,9 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<View style={styles.titleRow}>
<Icon size={18} color={c.textMuted} />
+4 -3
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Alert,
View, Text, StyleSheet, ScrollView, Pressable, Alert,
KeyboardAvoidingView, Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -37,6 +37,7 @@ import type {
FilterComparator,
FilterActionType,
} from '../../lib/sieve/types';
import { SafeAreaModal } from '../SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment'];
@@ -227,7 +228,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
}, [name, conditions, actions, matchType, stopProcessing, includeSpam, rule, onSave, t, mailboxTargets]);
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable
@@ -490,7 +491,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
</Button>
</View>
</SafeAreaView>
</Modal>
</SafeAreaModal>
);
}
+4 -3
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, TextInput,
View, Text, StyleSheet, ScrollView, Pressable, TextInput,
KeyboardAvoidingView, Platform, ActivityIndicator,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -9,6 +9,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
import { useColors } from '../../theme/colors';
import Button from '../Button';
import { useLocaleStore } from '../../stores/locale-store';
import { SafeAreaModal } from '../SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
interface SieveEditorSheetProps {
@@ -63,7 +64,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
}, [script, showSaveWarning, onSave]);
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable
@@ -174,7 +175,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
</View>
</View>
</SafeAreaView>
</Modal>
</SafeAreaModal>
);
}
@@ -196,8 +196,8 @@ export function ContentSendersSettings() {
animationType="fade"
onRequestClose={() => setModalOpen(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
<Pressable style={styles.modalCard} onPress={() => {}}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
<Pressable accessible={false} style={styles.modalCard} onPress={() => {}}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{t('settings.email_behavior.trusted_senders.modal_title', 'Trusted Senders')}</Text>
<Pressable
+1 -1
View File
@@ -298,7 +298,7 @@ export function Select({ value, onChange, options, style }: SelectProps) {
animationType="fade"
onRequestClose={() => setOpen(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setOpen(false)}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setOpen(false)}>
<View style={styles.modalContent}>
<ScrollView>
{options.map((opt) => {
+10
View File
@@ -0,0 +1,10 @@
import type { ThemePalette } from '../theme/tokens';
/**
* Colours of the undo bar. The bar is drawn in the text colour, so what is
* on it takes the background colour. The primary colour can't be used for
* UNDO: the default dark theme's primary is white, like the bar.
*/
export function undoSnackbarColors(c: ThemePalette): { bar: string; label: string; action: string } {
return { bar: c.text, label: c.background, action: c.background };
}
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { createAfterDismissQueue } from '../after-modal-dismiss';
afterEach(() => {
vi.useRealTimers();
});
describe('createAfterDismissQueue', () => {
it('holds the action on iOS until the modal is dismissed', () => {
const queue = createAfterDismissQueue('ios');
const action = vi.fn();
queue.run(action);
expect(action).not.toHaveBeenCalled();
queue.flush();
expect(action).toHaveBeenCalledTimes(1);
queue.flush();
expect(action).toHaveBeenCalledTimes(1);
});
it('runs the action after the fallback delay when onDismiss never comes', () => {
vi.useFakeTimers();
const queue = createAfterDismissQueue('ios', 600);
const action = vi.fn();
queue.run(action);
vi.advanceTimersByTime(599);
expect(action).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(action).toHaveBeenCalledTimes(1);
});
it('does not run the action twice when onDismiss and the timer both fire', () => {
vi.useFakeTimers();
const queue = createAfterDismissQueue('ios', 600);
const action = vi.fn();
queue.run(action);
queue.flush();
vi.advanceTimersByTime(1000);
expect(action).toHaveBeenCalledTimes(1);
});
it('runs the action right away on Android', () => {
const queue = createAfterDismissQueue('android');
const action = vi.fn();
queue.run(action);
expect(action).toHaveBeenCalledTimes(1);
});
});
// The compose "Attach" sheet closed its modal and opened the photo picker,
// camera or file picker in the same tick. On iOS the picker never showed.
describe('compose options sheet', () => {
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
it('opens the chosen option only after the sheet is dismissed', () => {
expect(source).not.toMatch(/onClose\(\);\s*opt\.onPress\(\)/);
expect(source).toMatch(/onDismiss=\{afterDismiss\.flush\}/);
expect(source).toMatch(/afterDismiss\.run\(opt\.onPress\)/);
});
});
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { buildEditorHtml, FOCUS_END_SOURCE } from '../editor-html';
import { LIGHT_COLORS } from '../../theme/tokens';
// On the iPad most of the composer's body area is empty space under the
// editor; a tap there did nothing and the keyboard kept typing into Subject.
// That space now calls the page's focusEnd(), which must put the caret where
// the user's own text goes: in front of the signature or the quoted message.
interface FakeNode { name: string; previousElementSibling?: FakeNode | null }
function run(stop: FakeNode | null) {
const calls: string[] = [];
const editor = {
name: 'editor',
focus: () => calls.push('focus'),
querySelector: () => stop,
};
const range = {
selectNodeContents: (n: FakeNode) => calls.push(`select ${n.name}`),
setStartBefore: (n: FakeNode) => calls.push(`before ${n.name}`),
collapse: (toStart: boolean) => calls.push(toStart ? 'collapse start' : 'collapse end'),
};
const selection = {
removeAllRanges: () => calls.push('clear'),
addRange: (r: unknown) => calls.push(r === range ? 'add range' : 'add other'),
};
const fakeDocument = { createRange: () => range };
const fakeWindow = { getSelection: () => selection };
const focusEnd = new Function('document', 'window', `${FOCUS_END_SOURCE}; return focusEnd;`)(fakeDocument, fakeWindow);
focusEnd(editor);
return calls;
}
describe('focusEnd', () => {
it('puts the caret at the end of the paragraph in front of the signature', () => {
const mine = { name: 'mine' };
expect(run({ name: 'signature', previousElementSibling: mine })).toEqual([
'focus', 'select mine', 'collapse end', 'clear', 'add range',
]);
});
it('puts the caret in front of a signature or quote that comes first', () => {
expect(run({ name: 'quote', previousElementSibling: null })).toEqual([
'focus', 'before quote', 'collapse start', 'clear', 'add range',
]);
});
it('puts the caret at the end without signature or quote', () => {
expect(run(null)).toEqual(['focus', 'select editor', 'collapse end', 'clear', 'add range']);
});
it('is part of the page script and of its bridge', () => {
const html = buildEditorHtml({ initialHtml: '<p><br></p>', placeholder: 'Write…', c: LIGHT_COLORS });
expect(html).toContain('function focusEnd(editor)');
expect(html).toContain('focusEnd: function () { focusEnd(editor); }');
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// scripts/perf/module-timings.js wraps Metro's module definitions and adds
// up each module's own evaluation time, without the time of the modules it
// required while it ran.
function install(clock: { t: number }) {
const g: Record<string, unknown> = { nativePerformanceNow: () => clock.t };
const factories = new Map<number, () => void>();
g.__d = (factory: () => void, id: number) => { factories.set(id, factory); };
const src = fs.readFileSync(path.resolve(__dirname, '../../../scripts/perf/module-timings.js'), 'utf8');
new Function('globalThis', src)(g);
(g.__d as (f: () => void, id: number) => void)(() => { clock.t += 5; }, 2);
(g.__d as (f: () => void, id: number) => void)(() => {
clock.t += 10;
factories.get(2)!();
clock.t += 3;
}, 1);
return { run: (id: number) => factories.get(id)!(), times: () => g.__bulwarkModuleTimes as Record<number, number> };
}
describe('module timings polyfill', () => {
it('counts a module without the modules it required', () => {
const clock = { t: 0 };
const m = install(clock);
m.run(1);
expect(m.times()).toEqual({ 1: 13, 2: 5 });
});
});
+78
View File
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The module reads its switch once at load, so each case loads it fresh
// with its own react-native mock.
async function load(os: 'ios' | 'android', setting: unknown) {
vi.resetModules();
vi.doMock('react-native', () => ({
Platform: { OS: os },
Settings: { get: vi.fn(() => setting) },
}));
return import('../perf-marks');
}
describe('perf marks', () => {
let log: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
});
afterEach(() => {
log.mockRestore();
vi.doUnmock('react-native');
});
it('logs nothing without the launch argument', async () => {
const m = await load('ios', undefined);
m.perfMark('bundle-loaded');
m.perfStart('open-message');
m.perfEnd('open-message');
m.perfOnce('inbox-rendered');
expect(m.perfMarksEnabled()).toBe(false);
expect(log).not.toHaveBeenCalled();
});
it('stays off on Android even when the setting is there', async () => {
const m = await load('android', 'YES');
m.perfMark('bundle-loaded');
expect(log).not.toHaveBeenCalled();
});
it('logs marks and span durations with -perfMarks YES', async () => {
const m = await load('ios', 'YES');
vi.useFakeTimers();
vi.setSystemTime(1_000);
m.perfStart('open-message');
vi.setSystemTime(1_250);
m.perfEnd('open-message', { id: 'x' });
vi.useRealTimers();
expect(log).toHaveBeenCalledTimes(1);
const line = String(log.mock.calls[0][0]);
expect(line.startsWith('[perf] ')).toBe(true);
expect(JSON.parse(line.slice(7))).toEqual({ n: 'open-message', t: 1_250, ms: 250, id: 'x' });
});
it('logs a once-mark only once and ignores an end without a start', async () => {
const m = await load('ios', true);
m.perfOnce('inbox-rendered', { rows: 3 });
m.perfOnce('inbox-rendered', { rows: 9 });
m.perfEnd('never-started');
expect(log).toHaveBeenCalledTimes(1);
expect(String(log.mock.calls[0][0])).toContain('"rows":3');
});
it('logs the slowest modules once, with the total', async () => {
const m = await load('ios', true);
(globalThis as { __bulwarkModuleTimes?: Record<string, number> }).__bulwarkModuleTimes = { 7: 12.34, 3: 50, 9: 1 };
try {
m.logModuleTimes();
m.logModuleTimes();
const lines = log.mock.calls.map((c: unknown[]) => String(c[0])).filter((l: string) => l.includes('module-times'));
expect(lines).toHaveLength(1);
const mark = JSON.parse(lines[0].slice('[perf] '.length));
expect(mark.total).toBe(63);
expect(mark.count).toBe(3);
expect(mark.top).toBe('3:50,7:12.3,9:1');
} finally {
delete (globalThis as { __bulwarkModuleTimes?: unknown }).__bulwarkModuleTimes;
}
});
});
+42
View File
@@ -0,0 +1,42 @@
// iOS can't present a view controller (the photo picker, the camera, the
// document picker, a share sheet) while a React Native Modal is still
// dismissing: UIKit drops the request without an error and the picker never
// shows. A sheet whose option opens a picker has to wait until it is gone.
//
// run() keeps the action until flush(), which the Modal's onDismiss calls.
// A timer flushes too, in case onDismiss doesn't fire. Android has no such
// limit, so the action runs right away there.
export interface AfterDismissQueue {
run(action: () => void): void;
flush(): void;
}
export function createAfterDismissQueue(
platform: string,
fallbackMs = 600,
): AfterDismissQueue {
let pending: (() => void) | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = () => {
if (timer) clearTimeout(timer);
timer = null;
const action = pending;
pending = null;
action?.();
};
return {
run(action) {
if (platform !== 'ios') {
action();
return;
}
if (timer) clearTimeout(timer);
pending = action;
timer = setTimeout(flush, fallbackMs);
},
flush,
};
}
+30
View File
@@ -64,6 +64,33 @@ export function shouldBlockEditorRemoteImages(opts: {
return !senderTrusted;
}
/**
* Page-script function for a tap on the empty space under the editor: focus
* it with the caret where the user's own text goes, in front of the
* signature or the quoted message, else at the end. A string, embedded in
* the page script and run by the unit test against stand-in DOM objects.
*/
export const FOCUS_END_SOURCE = `function focusEnd(editor) {
editor.focus();
var sel = window.getSelection();
if (!sel) return;
var stop = editor.querySelector('[data-signature-block="start"], [data-signature-block="separator"], [data-quoted-html]');
var range = document.createRange();
var prev = stop ? stop.previousElementSibling : null;
if (prev) {
range.selectNodeContents(prev);
range.collapse(false);
} else if (stop) {
range.setStartBefore(stop);
range.collapse(true);
} else {
range.selectNodeContents(editor);
range.collapse(false);
}
sel.removeAllRanges();
sel.addRange(range);
}`;
export function buildEditorHtml(opts: {
initialHtml: string;
placeholder: string;
@@ -261,6 +288,8 @@ export function buildEditorHtml(opts: {
});
// ── Bridge: receive commands from RN ───────────────────────────────
${FOCUS_END_SOURCE}
window.__rne = {
exec: function (command, value) {
editor.focus();
@@ -363,6 +392,7 @@ export function buildEditorHtml(opts: {
post('htmlSnapshot', { id: id, html: editor.innerHTML });
},
focus: function () { editor.focus(); },
focusEnd: function () { focusEnd(editor); },
};
reportHeight();
+140
View File
@@ -0,0 +1,140 @@
// Timing marks for the performance harness (scripts/e2e/perf.mjs).
//
// Off unless the app was launched with the `-perfMarks YES` launch argument
// (iOS puts launch arguments into NSUserDefaults, which React Native's
// Settings module reads). When on, every mark is one `[perf]` line on the
// console; on iOS that lands in the system log, where the harness reads it
// with `log show --info`. Nothing is stored or sent anywhere.
import { Platform, Settings } from 'react-native';
function readEnabled(): boolean {
try {
if (Platform.OS !== 'ios') return false;
const value = Settings.get('perfMarks') as unknown;
return value === true || value === 'YES' || value === 'true' || value === 1 || value === '1';
} catch {
return false;
}
}
const enabled = readEnabled();
export function perfMarksEnabled(): boolean {
return enabled;
}
/** Log a mark with the wall-clock time (the harness compares across processes). */
export function perfMark(name: string, detail?: Record<string, string | number | boolean>): void {
if (!enabled) return;
console.log(`[perf] ${JSON.stringify({ n: name, t: Date.now(), ...detail })}`);
}
const started = new Map<string, number>();
/** Start a timed span; `perfEnd` with the same name logs its duration. */
export function perfStart(name: string): void {
if (!enabled) return;
started.set(name, Date.now());
}
export function perfEnd(name: string, detail?: Record<string, string | number | boolean>): void {
if (!enabled) return;
const t0 = started.get(name);
if (t0 === undefined) return;
started.delete(name);
perfMark(name, { ms: Date.now() - t0, ...detail });
}
/** Log how far into a running span a step got, without ending it. */
export function perfLap(name: string, lap: string): void {
if (!enabled) return;
const t0 = started.get(name);
if (t0 === undefined) return;
perfMark(`${name}:${lap}`, { ms: Date.now() - t0 });
}
/**
* End a span once the next frames are drawn: two animation frames after the
* state change that rendered the result, so the time includes commit and
* paint, not just the JS render.
*/
export function perfEndAfterPaint(name: string, detail?: Record<string, string | number | boolean>): void {
if (!enabled || !started.has(name)) return;
requestAnimationFrame(() => requestAnimationFrame(() => perfEnd(name, detail)));
}
let onceDone = new Set<string>();
/** A mark that is logged once per process (first inbox render, ...). */
export function perfOnce(name: string, detail?: Record<string, string | number | boolean>): void {
if (!enabled || onceDone.has(name)) return;
onceDone.add(name);
perfMark(name, detail);
}
/**
* The modules whose own evaluation took longest so far, when the bundle was
* built with BULWARK_MODULE_TIMINGS (scripts/perf/module-timings.js): the
* 60 slowest as "id:ms" pairs, plus the total. Logged once, at first inbox.
*/
export function logModuleTimes(): void {
if (!enabled || onceDone.has('module-times')) return;
const times = (globalThis as { __bulwarkModuleTimes?: Record<string, number> }).__bulwarkModuleTimes;
if (!times) return;
onceDone.add('module-times');
const entries = Object.entries(times);
const total = entries.reduce((sum, [, ms]) => sum + ms, 0);
const top = entries
.sort((a, b) => b[1] - a[1])
.slice(0, 60)
.map(([id, ms]) => `${id}:${Math.round(ms * 10) / 10}`)
.join(',');
perfMark('module-times', { total: Math.round(total), count: entries.length, top });
}
/** Test hook. */
export function resetPerfMarks(): void {
started.clear();
onceDone = new Set();
}
/**
* Frame monitor for scrolling: counts animation frames between `start` and
* `stop` and how many of them took longer than two vsyncs (a visible hitch
* at 60 Hz). JS-thread frames, so it shows JS work blocking during a scroll.
*/
export function createFrameMonitor(name: string): { start: () => void; stop: () => void } {
let raf: number | null = null;
let last = 0;
let frames = 0;
let long = 0;
let worst = 0;
let t0 = 0;
const tick = (now: number) => {
if (last) {
const dt = now - last;
frames += 1;
if (dt > 34) long += 1;
if (dt > worst) worst = dt;
}
last = now;
raf = requestAnimationFrame(tick);
};
return {
start() {
if (!enabled || raf !== null) return;
frames = 0;
long = 0;
worst = 0;
last = 0;
t0 = Date.now();
raf = requestAnimationFrame(tick);
},
stop() {
if (raf === null) return;
cancelAnimationFrame(raf);
raf = null;
perfMark(name, { ms: Date.now() - t0, frames, long, worst: Math.round(worst) });
},
};
}
+32 -10
View File
@@ -32,6 +32,7 @@ import { getEmailInitials } from '../lib/avatar-utils';
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
import { useLayout } from '../lib/use-layout';
import { createAfterDismissQueue } from '../lib/after-modal-dismiss';
import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
import { useHasContacts } from '../lib/capabilities';
@@ -375,16 +376,26 @@ function OptionsSheet({
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
// An option may open a system picker, which iOS only shows once this
// modal is gone.
const afterDismiss = React.useRef(createAfterDismissQueue(Platform.OS)).current;
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Pressable style={styles.modalBackdrop} onPress={onClose}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<Modal
supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
onDismiss={afterDismiss.flush}
>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={onClose}>
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
{!!title && <Text style={styles.modalTitle} numberOfLines={2}>{title}</Text>}
{options.map((opt) => (
<Pressable
key={opt.label}
style={styles.scheduleRow}
onPress={() => { onClose(); opt.onPress(); }}
onPress={() => { onClose(); afterDismiss.run(opt.onPress); }}
>
<Text style={[styles.scheduleRowLabel, opt.destructive && { color: c.error }]}>{opt.label}</Text>
</Pressable>
@@ -2484,6 +2495,16 @@ export default function ComposeScreen({ route, navigation }: Props) {
onViewportShift={onEditorViewportShift}
/>
)}
{/* The empty space under the body looks like part of it (on a
tablet, most of the panel): a tap there starts typing in it. */}
<Pressable
style={styles.bodyFiller}
accessible={false}
onPress={() => {
if (plainTextMode) plainBodyRef.current?.focus();
else editorRef.current?.focusEnd();
}}
/>
</ScrollView>
{/* A phone in landscape with the keyboard up has room for a few lines
@@ -2647,8 +2668,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
{/* Text / background colour palette */}
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!colorTarget} transparent animationType="fade" onRequestClose={() => setColorTarget(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setColorTarget(null)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setColorTarget(null)}>
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
<Text style={styles.modalTitle}>
{colorTarget === 'background'
? t('email_composer.toolbar.background_color', 'Background color')
@@ -2716,8 +2737,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
animationType="fade"
onRequestClose={() => setScheduleSheetOpen(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setScheduleSheetOpen(false)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setScheduleSheetOpen(false)}>
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
<Text style={styles.modalTitle}>
{t('email_composer.schedule_send', 'Schedule send')}
</Text>
@@ -2750,8 +2771,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
{customStage !== null && Platform.OS === 'ios' && (
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
<DateTimePicker
value={customDraftRef.current}
mode="datetime"
@@ -2980,6 +3001,7 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.background,
},
scrollContent: { flexGrow: 1 },
bodyFiller: { flexGrow: 1, minHeight: 48 },
header: {
flexDirection: 'row',
+3
View File
@@ -1254,6 +1254,7 @@ export default function ContactFormScreen() {
style={styles.input}
placeholder={t('contacts.form.given_name', 'First name')}
ref={givenRef}
testID="contact-first-name"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => middleRef.current?.focus()}
@@ -1280,6 +1281,7 @@ export default function ContactFormScreen() {
style={styles.input}
placeholder={t('contacts.form.surname', 'Last name')}
ref={surnameRef}
testID="contact-last-name"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => fullRef.current?.focus()}
@@ -1347,6 +1349,7 @@ export default function ContactFormScreen() {
style={styles.input}
placeholder={t('contacts.form.email_placeholder', 'email@example.com')}
ref={i === 0 ? firstEmailRef : undefined}
testID={`contact-email-${i}`}
placeholderTextColor={c.textMuted}
keyboardType="email-address"
autoCapitalize="none"
+24 -4
View File
@@ -56,6 +56,7 @@ import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
import type { RootStackParamList } from '../navigation/types';
import { usePendingMailSearch } from '../navigation/pending-mail-search';
import type { Attachment, Email } from '../api/types';
import { createFrameMonitor, logModuleTimes, perfOnce, perfStart } from '../lib/perf-marks';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
function getSenderName(email: Email, unknownLabel: string): string {
@@ -473,6 +474,17 @@ export default function EmailListScreen({
() => collapseThreads(emails, disableThreading),
[emails, disableThreading],
);
// Performance harness (off unless launched with -perfMarks YES).
const scrollMonitor = React.useMemo(() => createFrameMonitor('list-scroll'), []);
React.useEffect(() => { perfOnce('list-mounted'); }, []);
React.useEffect(() => {
if (visibleEmails.length > 0) {
requestAnimationFrame(() => {
perfOnce('inbox-rendered', { rows: visibleEmails.length });
logModuleTimes();
});
}
}, [visibleEmails.length]);
const threadGroups = React.useMemo(
() => groupByThread(emails, disableThreading),
[emails, disableThreading],
@@ -645,7 +657,10 @@ export default function EmailListScreen({
const email = emailsRef.current.find((e) => rowKeyOf(e) === id);
if (!email) return;
if (isDraftEmail(email, currentRoleRef.current)) void openDraftRef.current(email);
else onEmailPressRef.current?.(email);
else {
perfStart('open-message');
onEmailPressRef.current?.(email);
}
}
}, [toggleSelect]);
@@ -1563,6 +1578,8 @@ export default function EmailListScreen({
contentContainerStyle={styles.listContent}
onEndReached={handleEndReached}
onEndReachedThreshold={0.3}
onScrollBeginDrag={scrollMonitor.start}
onMomentumScrollEnd={scrollMonitor.stop}
refreshing={loading}
onRefresh={handleRefresh}
/>
@@ -1570,7 +1587,10 @@ export default function EmailListScreen({
{/* Compose FAB - matches webmail mobile: PenSquare, h-14 w-14, rounded-full, shadow-lg */}
<Pressable
onPress={onComposePress}
onPress={() => {
perfStart('compose-open');
onComposePress?.();
}}
style={({ pressed }) => [
styles.fab,
// Lifted over the phone's undo bar, which used to cover it.
@@ -1593,9 +1613,9 @@ export default function EmailListScreen({
animationType="fade"
onRequestClose={() => setFilterMenuOpen(false)}
>
<TouchableWithoutFeedback onPress={() => setFilterMenuOpen(false)}>
<TouchableWithoutFeedback accessible={false} onPress={() => setFilterMenuOpen(false)}>
<View style={styles.filterBackdrop}>
<TouchableWithoutFeedback>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.filterMenu}>
<View style={styles.filterMenuHeader}>
<Text style={styles.filterMenuTitle}>{t('email_list.filter_title', 'Filter emails')}</Text>
+8 -8
View File
@@ -1179,9 +1179,9 @@ function PromptModal(props: PromptModalProps) {
animationType="fade"
onRequestClose={props.onCancel}
>
<TouchableWithoutFeedback onPress={props.onCancel}>
<TouchableWithoutFeedback accessible={false} onPress={props.onCancel}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.dialog}>
<Text style={styles.title}>{props.title}</Text>
<TextInput
@@ -1246,9 +1246,9 @@ function ActionsSheet({
const modified = modifiedOf(target);
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{target.displayName}
@@ -1370,9 +1370,9 @@ function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }:
const currentParent = target.parentId ?? null;
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{t('files.move_to', 'Move to…')} · {target.displayName}
@@ -1427,9 +1427,9 @@ function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChang
const keys: FilesSortKey[] = ['name', 'size', 'modified'];
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<Text style={styles.sheetTitle}>{t('files.settings_default_sort', 'Sort')}</Text>
{keys.map((k) => (
+6 -6
View File
@@ -326,8 +326,8 @@ export default function ScheduledScreen({ navigation }: Props) {
{/* Per-message actions */}
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!actionsFor} transparent animationType="fade" onRequestClose={() => setActionsFor(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setActionsFor(null)}>
<Pressable style={styles.card} onPress={() => {}}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setActionsFor(null)}>
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
<Text style={styles.cardTitle} numberOfLines={2}>
{actionsFor?.subject || t('email_composer.no_subject', '(No Subject)')}
</Text>
@@ -349,8 +349,8 @@ export default function ScheduledScreen({ navigation }: Props) {
{/* Reschedule presets */}
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!rescheduleFor && customStage === null} transparent animationType="fade" onRequestClose={() => setRescheduleFor(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setRescheduleFor(null)}>
<Pressable style={styles.card} onPress={() => {}}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setRescheduleFor(null)}>
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
<Text style={styles.cardTitle}>{t('email_list.reschedule_send', 'Reschedule')}</Text>
{reschedulePresets.map((preset) => (
<Pressable
@@ -379,8 +379,8 @@ export default function ScheduledScreen({ navigation }: Props) {
{customStage !== null && Platform.OS === 'ios' && (
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
<Pressable style={styles.card} onPress={() => {}}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
<DateTimePicker
value={customDraftRef.current}
mode="datetime"
+85 -1
View File
@@ -6,6 +6,8 @@ vi.mock('../../api/jmap-client', () => ({
logout: vi.fn(),
restoreSession: vi.fn(),
loadAccount: vi.fn(),
loadAccountFromCache: vi.fn(async () => false),
revalidateSession: vi.fn(async () => null),
consumeLegacyCredentials: vi.fn(async () => null),
clearAccountCredentials: vi.fn(async () => undefined),
clearAllCredentials: vi.fn(async () => undefined),
@@ -35,7 +37,7 @@ vi.mock('../../lib/push-notifications', () => ({
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
}));
import { jmapClient } from '../../api/jmap-client';
import { jmapClient, AuthenticationError } from '../../api/jmap-client';
import { useAuthStore, HYDRATION_TIMEOUT_MS } from '../auth-store';
import { useAccountStore } from '../account-store';
import { useCalendarStore } from '../calendar-store';
@@ -49,6 +51,8 @@ import { lastBodyHeight, rememberBodyHeight } from '../../lib/body-heights';
const mockConnect = jmapClient.connect as ReturnType<typeof vi.fn>;
const mockLogout = jmapClient.logout as ReturnType<typeof vi.fn>;
const mockLoadAccount = jmapClient.loadAccount as ReturnType<typeof vi.fn>;
const mockLoadFromCache = jmapClient.loadAccountFromCache as ReturnType<typeof vi.fn>;
const mockRevalidate = jmapClient.revalidateSession as ReturnType<typeof vi.fn>;
function resetAccountStore(): void {
useAccountStore.setState({
@@ -129,6 +133,31 @@ describe('auth-store', () => {
expect(state.serverUrl).toBeNull();
expect(state.username).toBeNull();
});
// logout used to read the account list from a snapshot taken before it
// removed the account: the removed account still looked like one to
// switch to, the "switch" to the active account returned at once, and
// signing out of the only account left the app signed in.
it('signs out of the only account', async () => {
const id = 'me@mail.example.com';
useAccountStore.setState({
accounts: [{
id, serverUrl: 'https://mail.example.com', username: 'me',
displayName: 'me', email: 'me', avatarColor: '#000', lastLoginAt: 0,
isConnected: true, hasError: false, isDefault: true,
}],
activeAccountId: id,
defaultAccountId: id,
});
useAuthStore.setState({ isAuthenticated: true, activeAccountId: id, serverUrl: 'x', username: 'y' });
await useAuthStore.getState().logout();
expect(useAccountStore.getState().accounts).toEqual([]);
const state = useAuthStore.getState();
expect(state.isAuthenticated).toBe(false);
expect(state.activeAccountId).toBeNull();
});
});
describe('mail the viewer held in memory', () => {
@@ -211,6 +240,61 @@ describe('auth-store', () => {
expect(useAuthStore.getState().isAuthenticated).toBe(true);
});
describe('from the session saved at the last connect', () => {
function registerAccount() {
useAccountStore.setState({
accounts: [{
id: 'acc-1', serverUrl: 'https://mail.example.com', username: 'user',
displayName: 'user', email: 'user', avatarColor: '#000', lastLoginAt: 0,
isConnected: true, hasError: false, isDefault: true,
}],
activeAccountId: 'acc-1',
defaultAccountId: 'acc-1',
});
}
// Start-up used to wait for the server's session document before the
// cached inbox could show.
it('shows the account without waiting for the server', async () => {
registerAccount();
mockLoadFromCache.mockResolvedValueOnce(true);
mockRevalidate.mockReturnValueOnce(new Promise(() => undefined));
const restored = await useAuthStore.getState().restoreSession();
expect(restored).toBe(true);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
expect(mockLoadAccount).not.toHaveBeenCalled();
expect(mockRevalidate).toHaveBeenCalledTimes(1);
});
it('signs out when the server refuses the stored credentials', async () => {
registerAccount();
mockLoadFromCache.mockResolvedValueOnce(true);
mockRevalidate.mockRejectedValueOnce(new AuthenticationError('Invalid credentials'));
mockLoadAccount.mockRejectedValueOnce(new AuthenticationError('Invalid credentials'));
await useAuthStore.getState().restoreSession();
await vi.waitFor(() => expect(useAuthStore.getState().isAuthenticated).toBe(false));
expect(useAuthStore.getState().error).toBe('Session expired');
expect(useAccountStore.getState().accounts).toEqual([]);
});
it('stays signed in and marks the account offline without network', async () => {
registerAccount();
mockLoadFromCache.mockResolvedValueOnce(true);
mockRevalidate.mockRejectedValueOnce(new Error('Network request failed'));
await useAuthStore.getState().restoreSession();
await vi.waitFor(() => expect(useAccountStore.getState().accounts[0].hasError).toBe(true));
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(useAccountStore.getState().accounts[0].isConnected).toBe(false);
});
});
it('stops waiting for a persisted store that never finishes hydrating', async () => {
vi.useFakeTimers();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+57 -3
View File
@@ -27,6 +27,7 @@ import {
teardownPushNotificationsForAccount,
} from '../lib/push-notifications';
import { deviceSyncSignedIn, releaseDeviceSyncBeforeSignOut } from '../device-sync/app/lifecycle';
import { perfMark } from '../lib/perf-marks';
// Persist middleware hydrates asynchronously on cold start. Without this
// guard, restoreSession() can read the account-store before AsyncStorage has
@@ -602,10 +603,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
jmapClient.reset();
clearAccountFeatureStores(currentId);
// Switch to next remaining account, if any
const remaining = accountStore.accounts;
// Switch to next remaining account, if any. Read the store again:
// `accountStore` is the state from before removeAccount.
const after = useAccountStore.getState();
const remaining = after.accounts.filter((a) => a.id !== currentId);
if (remaining.length > 0) {
const next = accountStore.getDefaultAccount() ?? remaining[0];
const next = after.getDefaultAccount() ?? remaining[0];
try {
await get().switchAccount(next.id);
return;
@@ -761,6 +764,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
restoreSession: async () => {
set({ isLoading: true });
perfMark('restore-start');
try {
// Wait for persisted caches to finish hydrating from AsyncStorage.
// Otherwise we read empty defaults and bounce the user back to the
@@ -773,6 +777,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
waitForHydration(useCalendarStore),
waitForHydration(useContactsStore),
]);
perfMark('restore-hydrated');
const accountStore = useAccountStore.getState();
// Legacy migration: if there are no registered accounts but the old
@@ -806,8 +811,27 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// data from a previous session.
useEmailStore.getState().setActiveAccount(target.id);
// Start from the session saved at the last connect: the cached mail
// shows without waiting for the server, and a fresh session follows.
let fromCache = false;
try {
fromCache = await jmapClient.loadAccountFromCache(target.id);
} catch {
fromCache = false;
}
if (fromCache) {
accountStore.setActiveAccount(target.id);
applyConnectedState(set, jmapClient.currentSession!, target.serverUrl, target.username, target.id);
perfMark('restore-done', { cached: true });
refetchFeatureStores();
void revalidateRestoredSession(target.id);
void syncAccountDisplayName(target.id);
return true;
}
try {
const ok = await jmapClient.loadAccount(target.id);
perfMark('restore-session');
if (!ok) {
// No stored credentials (or corrupt) — genuine logout.
accountStore.removeAccount(target.id);
@@ -861,6 +885,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const session = jmapClient.currentSession!;
applyConnectedState(set, session, target.serverUrl, target.username, target.id);
perfMark('restore-done');
// Refresh the cached mailbox list + current folder now that the session
// is live. Feature stores show persisted data immediately; this swaps
// in fresh data once the network round-trip completes.
@@ -922,6 +947,35 @@ export const useAuthStore = create<AuthState>((set, get) => ({
clearError: () => set({ error: null }),
}));
/**
* After a start from the saved session: fetch a fresh one. New capabilities
* or URLs replace the saved ones; bad credentials go the way of any 401 on a
* live session (retrySession, then the sign-in screen); no network marks the
* account offline, as a start without network always did.
*/
async function revalidateRestoredSession(accountId: string): Promise<void> {
const before = jmapClient.accountId;
try {
const fresh = await jmapClient.revalidateSession();
if (!fresh || useAuthStore.getState().activeAccountId !== accountId) return;
useAuthStore.setState({ session: fresh, accountId: jmapClient.accountId });
useAccountStore.getState().updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined });
if (jmapClient.accountId !== before) refetchFeatureStores();
} catch (err) {
if (useAuthStore.getState().activeAccountId !== accountId) return;
if (err instanceof AuthenticationError) {
useAuthStore.setState({ session: null });
await useAuthStore.getState().retrySession();
return;
}
useAccountStore.getState().updateAccount(accountId, {
isConnected: false,
hasError: true,
errorMessage: err instanceof Error ? err.message : String(err),
});
}
}
// A 401 on a live session (revoked password/token, expired refresh token)
// used to leave the user on a dead session until relaunch: nothing outside
// the login flow handled `AuthenticationError`, and `retrySession` bailed
+4
View File
@@ -16,6 +16,7 @@
import { AppState } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { perfMark } from '../lib/perf-marks';
import type { PersistStorage, StorageValue } from 'zustand/middleware';
/** How long a store's write waits to take in further changes. */
@@ -93,9 +94,12 @@ export function createPersistStorage<S>(
return {
getItem: async (name) => {
try {
const t0 = Date.now();
const raw = await AsyncStorage.getItem(name);
if (raw === null) return null;
const t1 = Date.now();
const value = JSON.parse(raw) as StorageValue<S>;
perfMark('hydrate', { store: name, readMs: t1 - t0, parseMs: Date.now() - t1, kb: Math.round(raw.length / 1024) });
slotFor(name).stored = raw;
return value;
} catch (err) {