mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9857fe1711 | ||
|
|
b4331a9bf9 | ||
|
|
c08e9d6fa8 | ||
|
|
470e7fc3ba | ||
|
|
b3bd19cc8c | ||
|
|
95cfa809ef | ||
|
|
3d3f4aec5a | ||
|
|
8303ff769f | ||
|
|
7078dfe806 | ||
|
|
f62849d1fe | ||
|
|
4bb846b726 | ||
|
|
2618680b21 | ||
|
|
533f176e88 | ||
|
|
da72415ef3 | ||
|
|
0109273037 | ||
|
|
33eb8f99e5 | ||
|
|
721c2e34a1 | ||
|
|
e06d92d88f | ||
|
|
42e89122bd | ||
|
|
33fc33910a | ||
|
|
dde504f825 | ||
|
|
f5f3637d01 | ||
|
|
bdade8503d | ||
|
|
57ca1c8083 | ||
|
|
32a41f42d3 | ||
|
|
ad9db5cbff | ||
|
|
99e4bd9614 | ||
|
|
239bf618b5 | ||
|
|
58e52421e9 | ||
|
|
96ffeaa0e2 | ||
|
|
81a0723abd | ||
|
|
6103df86a5 | ||
|
|
b97a877fce | ||
|
|
663f6f0007 | ||
|
|
5477cc039a | ||
|
|
ab8e8d7e1e | ||
|
|
49e57482cc | ||
|
|
5f06c3918c | ||
|
|
96471cf13b | ||
|
|
3575ed9450 | ||
|
|
f181c73a56 | ||
|
|
c85e32c9f4 | ||
|
|
f2b98c6dba | ||
|
|
653d5965ed | ||
|
|
01d15fd24f | ||
|
|
35e44318b9 | ||
|
|
3bdafefb65 | ||
|
|
982a23cafd | ||
|
|
c39e977de5 | ||
|
|
24948cb717 | ||
|
|
6b434346ff | ||
|
|
1827f8eb34 | ||
|
|
9c549c9d00 | ||
|
|
f00cd6a259 | ||
|
|
62931bcc53 | ||
|
|
5622d732e9 | ||
|
|
db3c6b5ba1 | ||
|
|
680d4745ed | ||
|
|
8533bd4f99 | ||
|
|
52975bd715 | ||
|
|
bf226c3794 | ||
|
|
ca09708fd1 | ||
|
|
96d9d54cc9 | ||
|
|
02bd714afd | ||
|
|
e57ee3d446 | ||
|
|
08e2f47187 | ||
|
|
de414f7ed6 | ||
|
|
273051e5f5 | ||
|
|
fa68b2eb53 | ||
|
|
3748a64b10 | ||
|
|
2078388e6f | ||
|
|
ea9bad7cff | ||
|
|
ae0b76442a | ||
|
|
341cdb0e61 |
@@ -0,0 +1,92 @@
|
||||
name: iOS simulator test leg
|
||||
description: Boot a simulator, install the app and run one suite of Maestro flows
|
||||
|
||||
inputs:
|
||||
suite:
|
||||
description: Folder under .maestro (signin or features)
|
||||
required: true
|
||||
name:
|
||||
description: Leg name, used for the artifact
|
||||
required: true
|
||||
app_run_id:
|
||||
description: Take the simulator app from this run instead of the current one
|
||||
required: false
|
||||
default: ""
|
||||
flows:
|
||||
description: Comma list of flow names to run instead of the whole suite
|
||||
required: false
|
||||
default: ""
|
||||
playwright:
|
||||
description: Install Playwright for the helper's webmail pairing
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Download the simulator app (this run)
|
||||
if: inputs.app_run_id == ''
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: simulator-app
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
- name: Download the simulator app (earlier run)
|
||||
if: inputs.app_run_id != ''
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: simulator-app
|
||||
run-id: ${{ inputs.app_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
- name: Unpack the simulator app
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/app"
|
||||
tar -C "$RUNNER_TEMP/app" -xzf "$RUNNER_TEMP/simulator-app.tar.gz"
|
||||
echo "APP=$RUNNER_TEMP/app/BulwarkMobile.app" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Boot a simulator
|
||||
shell: bash
|
||||
run: bash scripts/e2e/boot-sim.sh "$DEVICE" "$APPEARANCE" "$APP"
|
||||
|
||||
- name: Install Maestro
|
||||
shell: bash
|
||||
run: |
|
||||
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install Playwright for the helper
|
||||
if: inputs.playwright == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/pw"
|
||||
cd "$RUNNER_TEMP/pw"
|
||||
npm init -y >/dev/null
|
||||
npm install --no-audit --no-fund playwright@1.59.1 >/dev/null
|
||||
npx playwright install chromium >/dev/null
|
||||
echo "PLAYWRIGHT_DIR=$RUNNER_TEMP/pw/node_modules" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Start the test helper
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "$OUT/helper"
|
||||
HELPER_SHOTS="$OUT/helper" nohup node scripts/e2e/helper.mjs > "$OUT/helper/helper.log" 2>&1 &
|
||||
for i in $(seq 1 20); do
|
||||
curl -fs http://127.0.0.1:8787/health >/dev/null && break
|
||||
sleep 0.5
|
||||
done
|
||||
curl -fs http://127.0.0.1:8787/health
|
||||
|
||||
- name: Run the flows
|
||||
shell: bash
|
||||
run: bash scripts/e2e/run-flows.sh "${{ inputs.suite }}" "${{ inputs.flows }}"
|
||||
|
||||
- name: Upload screenshots, recording and logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-ios-${{ inputs.name }}
|
||||
path: e2e-output/
|
||||
if-no-files-found: ignore
|
||||
@@ -0,0 +1,349 @@
|
||||
name: iOS simulator tests
|
||||
|
||||
# Builds the app for the iOS Simulator (no signing needed) and runs the
|
||||
# Maestro flows in .maestro/ on iPhone and iPad simulators:
|
||||
#
|
||||
# - signin: every sign-in method (.maestro/signin), on an iPhone and an iPad
|
||||
# - features: the app feature by feature (.maestro/features), on iPhone and
|
||||
# iPad in light and dark, plus the iPad in landscape (split layout)
|
||||
#
|
||||
# Each leg signs in with its own account on the test server (e2e-* accounts
|
||||
# on VM 106, password E2E_PASSWORD), so parallel legs never share a mailbox.
|
||||
# scripts/e2e/helper.mjs runs next to the simulator for what a flow can't do
|
||||
# itself: TOTP codes, fresh webmail pairing links, mail sent by userb.
|
||||
#
|
||||
# Artifacts per leg: screenshots, a screen recording, the app log and a JUnit
|
||||
# report. They are public; see scripts/e2e/run-flows.sh for how secrets are
|
||||
# kept out of them.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
app_run_id:
|
||||
description: "Reuse the simulator app from this earlier run instead of building (run id)"
|
||||
type: string
|
||||
required: false
|
||||
legs:
|
||||
description: "Legs to run: all, signin, features, perf, or a comma list (signin-phone, features-tablet-dark, ...)"
|
||||
type: string
|
||||
default: all
|
||||
flows:
|
||||
description: "Only these flows (comma list of file names without .yaml); empty runs all"
|
||||
type: string
|
||||
required: false
|
||||
perf_base_run_id:
|
||||
description: "perf leg: compare against the simulator app of this run (run id)"
|
||||
type: string
|
||||
required: false
|
||||
perf_rounds:
|
||||
description: "perf leg: rounds (each: 3 cold starts and the interaction flow per app)"
|
||||
type: string
|
||||
default: "5"
|
||||
required: false
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
concurrency:
|
||||
# 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:
|
||||
EXPO_NO_TELEMETRY: "1"
|
||||
MAESTRO_CLI_NO_ANALYTICS: "1"
|
||||
# The XCTest driver takes minutes to come up on a cold runner.
|
||||
MAESTRO_DRIVER_STARTUP_TIMEOUT: "600000"
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
signin: ${{ steps.plan.outputs.signin }}
|
||||
features: ${{ steps.plan.outputs.features }}
|
||||
steps:
|
||||
- id: plan
|
||||
env:
|
||||
LEGS: ${{ inputs.legs || 'all' }}
|
||||
run: |
|
||||
node -e '
|
||||
const legs = (process.env.LEGS || "all").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const want = (name, group) => legs.includes("all") || legs.includes(group) || legs.includes(name);
|
||||
const signin = ["phone", "tablet"]
|
||||
.map((device) => ({ name: `signin-${device}`, device }))
|
||||
.filter((l) => want(l.name, "signin"));
|
||||
const features = [
|
||||
{ name: "features-phone-light", device: "phone", appearance: "light", orientation: "portrait", account: "e2e-phone-light" },
|
||||
{ name: "features-phone-dark", device: "phone", appearance: "dark", orientation: "portrait", account: "e2e-phone-dark" },
|
||||
{ name: "features-tablet-light", device: "tablet", appearance: "light", orientation: "portrait", account: "e2e-tablet-light" },
|
||||
{ name: "features-tablet-dark", device: "tablet", appearance: "dark", orientation: "portrait", account: "e2e-tablet-dark" },
|
||||
{ name: "features-tablet-landscape", device: "tablet", appearance: "light", orientation: "landscape", account: "e2e-tablet-land" },
|
||||
].filter((l) => want(l.name, "features"));
|
||||
// Push on iOS (.maestro/push): only on request, the app needs the
|
||||
// notification extension (plugins/ios-push).
|
||||
if (legs.includes("push")) {
|
||||
features.push({ name: "push-phone", device: "phone", appearance: "light", orientation: "portrait", account: "e2e-perf", suite: "push" });
|
||||
}
|
||||
const fs = require("fs");
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT,
|
||||
`signin=${JSON.stringify({ include: signin })}\nfeatures=${JSON.stringify({ include: features })}\n`);
|
||||
console.log({ signin: signin.map((l) => l.name), features: features.map((l) => l.name) });
|
||||
'
|
||||
|
||||
build:
|
||||
if: inputs.app_run_id == ''
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Cache CocoaPods
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/Library/Caches/CocoaPods
|
||||
~/.cocoapods/repos
|
||||
key: pods-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: pods-${{ runner.os }}-
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate the iOS project
|
||||
env:
|
||||
# Simulator device tokens belong to the APNs sandbox.
|
||||
APS_ENVIRONMENT: development
|
||||
run: npx expo prebuild --platform ios --clean
|
||||
|
||||
- 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)
|
||||
mkdir -p e2e-build
|
||||
# Release bundles the JS into the app, so no Metro server is needed.
|
||||
# arm64 only: the runner is Apple silicon, and a second simulator
|
||||
# architecture would double the build time.
|
||||
if ! xcodebuild build \
|
||||
-workspace "$WORKSPACE" \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration Release \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
-derivedDataPath build \
|
||||
ARCHS=arm64 \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
CODE_SIGN_IDENTITY=- \
|
||||
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
|
||||
PROVISIONING_PROFILE_SPECIFIER= \
|
||||
> e2e-build/xcodebuild.log 2>&1; then
|
||||
grep -E "error:|fatal error" e2e-build/xcodebuild.log | head -n 50 || true
|
||||
tail -n 80 e2e-build/xcodebuild.log
|
||||
exit 1
|
||||
fi
|
||||
# Signed ad hoc ("Sign to Run Locally"): no certificate needed, and
|
||||
# Xcode embeds the simulator entitlements the keychain needs. An
|
||||
# unsigned build has none, and every expo-secure-store call fails
|
||||
# with "A required entitlement isn't present".
|
||||
APP_PATH=build/Build/Products/Release-iphonesimulator/BulwarkMobile.app
|
||||
echo "Simulated entitlements:"
|
||||
xcrun otool -X -s __TEXT __entitlements "$APP_PATH/BulwarkMobile" | head -20 || true
|
||||
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 -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
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: simulator-app
|
||||
path: ${{ runner.temp }}/simulator-app.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload the build log
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: xcodebuild-log
|
||||
path: e2e-build/
|
||||
|
||||
signin:
|
||||
needs: [plan, build]
|
||||
if: >-
|
||||
always() && !cancelled() &&
|
||||
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
|
||||
fromJson(needs.plan.outputs.signin).include[0] != null
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 150
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.plan.outputs.signin) }}
|
||||
env:
|
||||
OUT: ${{ github.workspace }}/e2e-output
|
||||
DEVICE: ${{ matrix.device }}
|
||||
APPEARANCE: light
|
||||
ORIENTATION: portrait
|
||||
E2E_SERVER: ${{ vars.E2E_SERVER }}
|
||||
E2E_WEBMAIL: https://bulwark.internal.bulwarkmail.org
|
||||
E2E_ACCOUNT: e2e-login-${{ matrix.device }}
|
||||
E2E_ACCOUNT2: e2e-second-${{ matrix.device }}
|
||||
E2E_TOTP_ACCOUNT: e2e-totp-${{ matrix.device }}
|
||||
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
|
||||
E2E_USERB_PASSWORD: ${{ secrets.E2E_USERB_PASSWORD }}
|
||||
E2E_TOTP_SECRET: ${{ secrets.E2E_TOTP_SECRET }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/e2e-ios-leg
|
||||
with:
|
||||
suite: signin
|
||||
app_run_id: ${{ inputs.app_run_id }}
|
||||
flows: ${{ inputs.flows }}
|
||||
playwright: "true"
|
||||
name: ${{ matrix.name }}
|
||||
|
||||
features:
|
||||
needs: [plan, build]
|
||||
if: >-
|
||||
always() && !cancelled() &&
|
||||
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
|
||||
fromJson(needs.plan.outputs.features).include[0] != null
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.plan.outputs.features) }}
|
||||
env:
|
||||
OUT: ${{ github.workspace }}/e2e-output
|
||||
DEVICE: ${{ matrix.device }}
|
||||
APPEARANCE: ${{ matrix.appearance }}
|
||||
ORIENTATION: ${{ matrix.orientation }}
|
||||
E2E_SERVER: ${{ vars.E2E_SERVER }}
|
||||
E2E_WEBMAIL: https://bulwark.internal.bulwarkmail.org
|
||||
E2E_ACCOUNT: ${{ matrix.account }}
|
||||
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
|
||||
E2E_USERB_PASSWORD: ${{ secrets.E2E_USERB_PASSWORD }}
|
||||
E2E_TOTP_SECRET: ${{ secrets.E2E_TOTP_SECRET }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/e2e-ios-leg
|
||||
with:
|
||||
suite: ${{ matrix.suite || 'features' }}
|
||||
app_run_id: ${{ inputs.app_run_id }}
|
||||
flows: ${{ inputs.flows }}
|
||||
playwright: "false"
|
||||
name: ${{ matrix.name }}
|
||||
|
||||
# Part 2 of docs/ios-test-2026-10.md: timing marks from the app
|
||||
# (src/lib/perf-marks.ts), read by scripts/e2e/perf.mjs. Compares the app
|
||||
# of this run (or app_run_id) with the app of perf_base_run_id, alternating
|
||||
# them on one simulator so both sides share the runner.
|
||||
perf:
|
||||
needs: [plan, build]
|
||||
if: >-
|
||||
always() && !cancelled() &&
|
||||
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
|
||||
contains(inputs.legs, 'perf')
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
env:
|
||||
OUT: ${{ github.workspace }}/e2e-output
|
||||
DEVICE: phone
|
||||
APPEARANCE: light
|
||||
ORIENTATION: portrait
|
||||
E2E_SERVER: ${{ vars.E2E_SERVER }}
|
||||
E2E_ACCOUNT: e2e-bulk
|
||||
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download the app under test (this run)
|
||||
if: inputs.app_run_id == ''
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: simulator-app
|
||||
path: ${{ runner.temp }}/cand
|
||||
- name: Download the app under test (earlier run)
|
||||
if: inputs.app_run_id != ''
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: simulator-app
|
||||
run-id: ${{ inputs.app_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
path: ${{ runner.temp }}/cand
|
||||
- name: Download the baseline app
|
||||
if: inputs.perf_base_run_id != ''
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: simulator-app
|
||||
run-id: ${{ inputs.perf_base_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
path: ${{ runner.temp }}/base
|
||||
|
||||
- name: Unpack
|
||||
run: |
|
||||
for side in cand base; do
|
||||
[ -f "$RUNNER_TEMP/$side/simulator-app.tar.gz" ] || continue
|
||||
mkdir -p "$RUNNER_TEMP/$side/app"
|
||||
tar -C "$RUNNER_TEMP/$side/app" -xzf "$RUNNER_TEMP/$side/simulator-app.tar.gz"
|
||||
done
|
||||
echo "APP=$RUNNER_TEMP/cand/app/BulwarkMobile.app" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Boot a simulator
|
||||
run: bash scripts/e2e/boot-sim.sh "$DEVICE" "$APPEARANCE" "$APP"
|
||||
|
||||
- name: Install Maestro
|
||||
run: |
|
||||
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Sign in once
|
||||
run: |
|
||||
mkdir -p "$OUT/screenshots"
|
||||
cd "$OUT/screenshots"
|
||||
# Warm-up for the driver, then the sign-in (keychain survives reinstalls).
|
||||
maestro --device "$UDID" test "$GITHUB_WORKSPACE/.maestro/launch.yaml"
|
||||
maestro --device "$UDID" test -e "SERVER=$E2E_SERVER" -e "PASSWORD=$E2E_PASSWORD" \
|
||||
-e "ACCOUNT=$E2E_ACCOUNT" -e "ORIENTATION=portrait" "$GITHUB_WORKSPACE/.maestro/perf/sign-in.yaml"
|
||||
|
||||
- name: Measure
|
||||
run: |
|
||||
APPS=(--app "cand=$RUNNER_TEMP/cand/app/BulwarkMobile.app")
|
||||
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 5 \
|
||||
--flows "$(ls "$GITHUB_WORKSPACE"/.maestro/perf/[0-9]-*.yaml | paste -sd, -)" --out "$OUT"
|
||||
|
||||
- name: Upload results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-ios-perf
|
||||
path: e2e-output/
|
||||
if-no-files-found: ignore
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Release Android APK
|
||||
name: Release Android
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew assembleRelease --no-daemon
|
||||
./gradlew assembleGithubRelease --no-daemon
|
||||
|
||||
- name: Put apksigner on the PATH
|
||||
run: |
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
# Oldest signer first: apksigner uses it for v2, and the rotated key
|
||||
# for v3 from API 28. Without --rotation-min-sdk-version it would
|
||||
# only rotate on Android 13+ (v3.1) and leave 9-12L on the debug key.
|
||||
APK=android/app/build/outputs/apk/release/app-release.apk
|
||||
APK=android/app/build/outputs/apk/github/release/app-github-release.apk
|
||||
apksigner sign \
|
||||
--v4-signing-enabled false \
|
||||
--ks android/app/debug.keystore --ks-key-alias androiddebugkey \
|
||||
@@ -210,7 +210,7 @@ jobs:
|
||||
ROTATED: ${{ steps.rotation.outputs.rotated }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APK=android/app/build/outputs/apk/release/app-release.apk
|
||||
APK=android/app/build/outputs/apk/github/release/app-github-release.apk
|
||||
apksigner verify --verbose --print-certs "$APK"
|
||||
# build-tools 36 prints "Signer #1 certificate SHA-256 digest: …",
|
||||
# 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …".
|
||||
@@ -248,12 +248,12 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
SRC=android/app/build/outputs/apk/release/app-release.apk
|
||||
SRC=android/app/build/outputs/apk/github/release/app-github-release.apk
|
||||
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
|
||||
cp "$SRC" "$DEST"
|
||||
sha256sum "$DEST" > "$DEST.sha256"
|
||||
|
||||
MAPPING_SRC=android/app/build/outputs/mapping/release/mapping.txt
|
||||
MAPPING_SRC=android/app/build/outputs/mapping/githubRelease/mapping.txt
|
||||
MAPPING=""
|
||||
if [ -f "$MAPPING_SRC" ]; then
|
||||
MAPPING="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
|
||||
@@ -299,12 +299,12 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# A manual run with no tag only builds: it must not replace the APK
|
||||
# of the latest release with one from another commit.
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
elif [ -n "${{ github.event.inputs.attach_to_release_tag }}" ]; then
|
||||
TAG="${{ github.event.inputs.attach_to_release_tag }}"
|
||||
else
|
||||
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true)
|
||||
TAG="${{ github.event.inputs.attach_to_release_tag }}"
|
||||
fi
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
if [ -n "$TAG" ]; then
|
||||
@@ -319,3 +319,150 @@ jobs:
|
||||
with:
|
||||
tag_name: ${{ steps.target_tag.outputs.tag }}
|
||||
files: ${{ steps.artifact.outputs.files }}
|
||||
|
||||
# The Google Play bundle (docs/android-release.md, "Google Play"). It is
|
||||
# signed with the release key, which Play knows as the upload key; Play
|
||||
# re-signs what it delivers with its own app signing key. A separate job so
|
||||
# the GitHub APK isn't held up by the three-ABI native build.
|
||||
play-bundle:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 75
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
steps:
|
||||
- name: Require the release signing secrets
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
missing=""
|
||||
[ -n "$KEYSTORE_BASE64" ] || missing="$missing ANDROID_KEYSTORE_BASE64"
|
||||
[ -n "$KEYSTORE_PASSWORD" ] || missing="$missing ANDROID_KEYSTORE_PASSWORD"
|
||||
[ -n "$KEY_ALIAS" ] || missing="$missing ANDROID_KEY_ALIAS"
|
||||
[ -n "$KEY_PASSWORD" ] || missing="$missing ANDROID_KEY_PASSWORD"
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error title=Release signing is not configured::Missing repository secrets:$missing. See docs/android-release.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
- uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: platform-tools
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Decode and check the upload key
|
||||
id: key
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEYSTORE="$RUNNER_TEMP/release.keystore"
|
||||
CERT="$RUNNER_TEMP/release-cert.der"
|
||||
echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE"
|
||||
keytool -exportcert -keystore "$KEYSTORE" -alias "$KEY_ALIAS" \
|
||||
-storepass:env KEYSTORE_PASSWORD -file "$CERT"
|
||||
CERT_SHA256=$(sha256sum "$CERT" | cut -d' ' -f1)
|
||||
if [ "$CERT_SHA256" = "$DEBUG_CERT_SHA256" ]; then
|
||||
echo "::error title=Upload key is a debug key::ANDROID_KEYSTORE_BASE64 holds the Android debug key."
|
||||
exit 1
|
||||
fi
|
||||
echo "store_file=$KEYSTORE" >> "$GITHUB_OUTPUT"
|
||||
echo "cert_sha256=$CERT_SHA256" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Play splits a bundle per ABI, so the extra architectures cost a phone
|
||||
# nothing and let 32-bit ARM phones and x86_64 Chromebooks install it.
|
||||
- name: Build the Play bundle
|
||||
working-directory: android
|
||||
env:
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.key.outputs.store_file }}
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew bundlePlayRelease --no-daemon -PreactNativeArchitectures=armeabi-v7a,arm64-v8a,x86_64
|
||||
|
||||
# Play rejects a bundle signed with another key than the registered
|
||||
# upload key, and its policies reject a self-updater and the media
|
||||
# permission. Catch all three here instead of in the Play Console.
|
||||
- name: Check the bundle
|
||||
env:
|
||||
RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
AAB=android/app/build/outputs/bundle/playRelease/app-play-release.aab
|
||||
SIGNER=$(keytool -printcert -jarfile "$AAB" | sed -n 's/^[[:space:]]*SHA256: //p' | head -n 1 | tr -d ':' | tr 'A-F' 'a-f')
|
||||
echo "Bundle signer SHA-256: $SIGNER"
|
||||
if [ "$SIGNER" != "$RELEASE_CERT_SHA256" ]; then
|
||||
echo "::error title=Wrong bundle signer::Expected the upload key $RELEASE_CERT_SHA256, got '$SIGNER'."
|
||||
exit 1
|
||||
fi
|
||||
MANIFEST=$(unzip -p "$AAB" base/manifest/AndroidManifest.xml | tr -c '[:print:]' '\n')
|
||||
if ! grep -q 'org.bulwarkmail.mobile' <<< "$MANIFEST"; then
|
||||
echo "::error title=Wrong package::The bundle is not org.bulwarkmail.mobile."
|
||||
exit 1
|
||||
fi
|
||||
for permission in REQUEST_INSTALL_PACKAGES READ_MEDIA_IMAGES READ_MEDIA_VIDEO RECORD_AUDIO; do
|
||||
if grep -q "android.permission.$permission" <<< "$MANIFEST"; then
|
||||
echo "::error title=Permission not allowed on Play::The Play bundle declares $permission."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Name the bundle and R8 mapping with version
|
||||
id: artifact
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
AAB="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}.aab"
|
||||
MAPPING="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
|
||||
cp android/app/build/outputs/bundle/playRelease/app-play-release.aab "$AAB"
|
||||
cp android/app/build/outputs/mapping/playRelease/mapping.txt "$MAPPING"
|
||||
echo "aab=$AAB" >> "$GITHUB_OUTPUT"
|
||||
echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.aab }}
|
||||
path: |
|
||||
${{ steps.artifact.outputs.aab }}
|
||||
${{ steps.artifact.outputs.mapping }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Runs once PLAY_SERVICE_ACCOUNT_JSON is set (Play only accepts API
|
||||
# uploads after the first bundle was uploaded by hand in the Console).
|
||||
# Every release goes out to the internal and closed (beta) testers right
|
||||
# away; closed testing may wait for Google's review first.
|
||||
- name: Upload to Google Play (internal and closed testing)
|
||||
if: env.PLAY_SERVICE_ACCOUNT_JSON != ''
|
||||
uses: r0adkll/upload-google-play@v1
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
packageName: org.bulwarkmail.mobile
|
||||
releaseFiles: ${{ steps.artifact.outputs.aab }}
|
||||
mappingFile: ${{ steps.artifact.outputs.mapping }}
|
||||
releaseName: ${{ steps.artifact.outputs.version }}
|
||||
tracks: internal,beta
|
||||
status: completed
|
||||
|
||||
@@ -59,9 +59,6 @@ jobs:
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate the iOS project
|
||||
run: npx expo prebuild --platform ios --clean
|
||||
|
||||
- name: Check for signing credentials
|
||||
id: creds
|
||||
env:
|
||||
@@ -69,14 +66,69 @@ jobs:
|
||||
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
NSE_PROFILE: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
|
||||
if [ -z "$NSE_PROFILE" ]; then
|
||||
# The notification service extension (plugins/ios-push) is its
|
||||
# own App ID and needs its own App Store profile.
|
||||
echo "::error::IOS_NSE_PROVISIONING_PROFILE_BASE64 is not set: the notification extension (org.bulwarkmail.mobile.NotificationService) can't be signed."
|
||||
exit 1
|
||||
fi
|
||||
echo "signed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "signed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
|
||||
fi
|
||||
|
||||
- name: Install the provisioning profiles
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
id: profiles
|
||||
env:
|
||||
APP_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
NSE_PROFILE_BASE64: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
# Xcode moved the profile directory in 16; write both so this does not
|
||||
# silently stop working on a runner image bump.
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
|
||||
|
||||
# One App Store profile per target: the app and its notification
|
||||
# service extension (plugins/ios-push).
|
||||
for side in app nse; do
|
||||
if [ "$side" = app ]; then DATA="$APP_PROFILE_BASE64"; else DATA="$NSE_PROFILE_BASE64"; fi
|
||||
PROFILE="$RUNNER_TEMP/$side.mobileprovision"
|
||||
echo "$DATA" | base64 -d > "$PROFILE"
|
||||
|
||||
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
|
||||
# before anything inside it can be read.
|
||||
PLIST=$(security cms -D -i "$PROFILE")
|
||||
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
|
||||
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
|
||||
# application-identifier is "TEAMID.bundle.id" - deriving the bundle
|
||||
# ID from the profile keeps ExportOptions honest if app.config.js
|
||||
# changes.
|
||||
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
|
||||
BUNDLE_ID="${APP_ID#*.}"
|
||||
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
|
||||
|
||||
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
|
||||
echo "${side}_uuid=$UUID" >> "$GITHUB_OUTPUT"
|
||||
echo "${side}_name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "${side}_bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "$side: profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
|
||||
done
|
||||
|
||||
- name: Generate the iOS project
|
||||
env:
|
||||
# plugins/ios-push names each target's profile: the command line
|
||||
# can't, it would give every target the same one.
|
||||
IOS_APP_PROFILE: ${{ steps.profiles.outputs.app_uuid }}
|
||||
IOS_NSE_PROFILE: ${{ steps.profiles.outputs.nse_uuid }}
|
||||
run: npx expo prebuild --platform ios --clean
|
||||
|
||||
- name: Resolve workspace and scheme
|
||||
id: xcode
|
||||
run: |
|
||||
@@ -141,38 +193,6 @@ jobs:
|
||||
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
|
||||
- name: Install the provisioning profile
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
id: profile
|
||||
env:
|
||||
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
PROFILE="$RUNNER_TEMP/profile.mobileprovision"
|
||||
echo "$PROFILE_BASE64" | base64 -d > "$PROFILE"
|
||||
|
||||
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
|
||||
# before anything inside it can be read.
|
||||
PLIST=$(security cms -D -i "$PROFILE")
|
||||
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
|
||||
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
|
||||
# application-identifier is "TEAMID.bundle.id" - deriving the bundle ID
|
||||
# from the profile keeps ExportOptions honest if app.config.js changes.
|
||||
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
|
||||
BUNDLE_ID="${APP_ID#*.}"
|
||||
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
|
||||
|
||||
# Xcode moved the profile directory in 16; write both so this does not
|
||||
# silently stop working on a runner image bump.
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
|
||||
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
|
||||
echo "uuid=$UUID" >> "$GITHUB_OUTPUT"
|
||||
echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "Profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
|
||||
|
||||
- name: Archive
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
env:
|
||||
@@ -192,8 +212,7 @@ jobs:
|
||||
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID" \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
PROVISIONING_PROFILE_SPECIFIER="${{ steps.profile.outputs.uuid }}"
|
||||
CODE_SIGN_IDENTITY="Apple Distribution"
|
||||
|
||||
# Signing failures report a symptom ("no profiles found") rather than the
|
||||
# setting that caused it, and a re-run costs ~30 minutes. Dump the
|
||||
@@ -215,8 +234,10 @@ jobs:
|
||||
id: export
|
||||
env:
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PROFILE_NAME: ${{ steps.profile.outputs.name }}
|
||||
BUNDLE_ID: ${{ steps.profile.outputs.bundle_id }}
|
||||
PROFILE_NAME: ${{ steps.profiles.outputs.app_name }}
|
||||
BUNDLE_ID: ${{ steps.profiles.outputs.app_bundle_id }}
|
||||
NSE_PROFILE_NAME: ${{ steps.profiles.outputs.nse_name }}
|
||||
NSE_BUNDLE_ID: ${{ steps.profiles.outputs.nse_bundle_id }}
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -230,6 +251,7 @@ jobs:
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
|
||||
<key>${NSE_BUNDLE_ID}</key><string>${NSE_PROFILE_NAME}</string>
|
||||
</dict>
|
||||
<key>uploadSymbols</key><true/>
|
||||
<key>destination</key><string>export</string>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Mailbox list and folders: the drawer (or docked sidebar) lists the role
|
||||
# folders and custom ones; opening a folder shows its messages.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Folders
|
||||
tags:
|
||||
- features
|
||||
---
|
||||
- runFlow: ../subflows/signed-in.yaml
|
||||
- takeScreenshot: folders-01-inbox
|
||||
- assertVisible: ".*Your verification code.*"
|
||||
- runFlow: ../subflows/open-drawer.yaml
|
||||
- takeScreenshot: folders-02-drawer
|
||||
- assertVisible: "Inbox([ ,].*)?"
|
||||
- assertVisible: "Sent([ ,].*)?"
|
||||
- assertVisible: "Drafts([ ,].*)?"
|
||||
- assertVisible: "Archive([ ,].*)?"
|
||||
# Parent folders read "Collapse, Projects" (the chevron comes first).
|
||||
- assertVisible: ".*Projects.*"
|
||||
- tapOn: "Archive([ ,].*)?"
|
||||
- extendedWaitUntil:
|
||||
visible: ".*Invoice 2025-12.*"
|
||||
timeout: 20000
|
||||
- takeScreenshot: folders-03-archive
|
||||
- runFlow: ../subflows/open-drawer.yaml
|
||||
# Projects/Alpha holds the seeded thread.
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Alpha([ ,].*)?"
|
||||
commands:
|
||||
- tapOn: "Expand, Projects.*"
|
||||
- tapOn: "Alpha([ ,].*)?"
|
||||
- extendedWaitUntil:
|
||||
visible: ".*Project Alpha kickoff.*"
|
||||
timeout: 20000
|
||||
- takeScreenshot: folders-04-alpha
|
||||
- runFlow: ../subflows/go-to-inbox.yaml
|
||||
@@ -0,0 +1,75 @@
|
||||
# Opening messages: plain text, HTML with blocked remote content, a thread,
|
||||
# and a PDF attachment in the in-app preview.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Read messages
|
||||
tags:
|
||||
- features
|
||||
---
|
||||
- runFlow: ../subflows/signed-in.yaml
|
||||
|
||||
# Plain text
|
||||
- runFlow:
|
||||
file: ../subflows/open-message.yaml
|
||||
env:
|
||||
SUBJECT: Grüße aus München
|
||||
- extendedWaitUntil:
|
||||
visible: ".*Jürgen.*"
|
||||
timeout: 10000
|
||||
- takeScreenshot: read-01-plain
|
||||
- runFlow: ../subflows/close-message.yaml
|
||||
|
||||
# HTML newsletter
|
||||
- runFlow:
|
||||
file: ../subflows/open-message.yaml
|
||||
env:
|
||||
SUBJECT: Weekly digest
|
||||
- extendedWaitUntil:
|
||||
visible: ".*5 new articles.*|Weekly digest"
|
||||
timeout: 20000
|
||||
- takeScreenshot: read-02-html
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Load images"
|
||||
commands:
|
||||
- tapOn: "Load images"
|
||||
- takeScreenshot: read-03-html-images
|
||||
- runFlow: ../subflows/close-message.yaml
|
||||
|
||||
# Attachment and its preview
|
||||
- runFlow:
|
||||
file: ../subflows/open-message.yaml
|
||||
env:
|
||||
SUBJECT: Q3 report
|
||||
- extendedWaitUntil:
|
||||
visible: ".*q3-report.pdf.*"
|
||||
timeout: 20000
|
||||
- takeScreenshot: read-04-attachment
|
||||
# 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
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- 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:
|
||||
notVisible: "Share"
|
||||
timeout: 10000
|
||||
- assertVisible: ".*q3-report.pdf.*"
|
||||
- runFlow: ../subflows/close-message.yaml
|
||||
|
||||
# A thread (Projects/Alpha)
|
||||
- runFlow:
|
||||
file: ../subflows/go-to-folder.yaml
|
||||
env:
|
||||
FOLDER: Alpha
|
||||
- runFlow:
|
||||
file: ../subflows/open-message.yaml
|
||||
env:
|
||||
SUBJECT: Project Alpha kickoff
|
||||
- takeScreenshot: read-06-thread
|
||||
- runFlow: ../subflows/go-to-inbox.yaml
|
||||
@@ -0,0 +1,101 @@
|
||||
# Compose a new message and send it to userb; userb's mailbox (checked over
|
||||
# JMAP) must get it.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Compose and send
|
||||
tags:
|
||||
- features
|
||||
---
|
||||
- runFlow: ../subflows/signed-in.yaml
|
||||
- evalScript: ${output.subject = 'E2E compose ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
|
||||
- tapOn: "Compose"
|
||||
- extendedWaitUntil:
|
||||
visible: "New Message"
|
||||
timeout: 15000
|
||||
- tapOn: "Recipient email addresses"
|
||||
- inputText: "userb@example.org"
|
||||
- pressKey: Enter
|
||||
- tapOn:
|
||||
text: "Subject"
|
||||
index: 1
|
||||
- inputText: ${output.subject}
|
||||
- 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, and picking one opened no picker on iOS).
|
||||
- tapOn: "Attach"
|
||||
- extendedWaitUntil:
|
||||
visible: "Photos & Videos"
|
||||
timeout: 10000
|
||||
- assertVisible: "Camera"
|
||||
- assertVisible: "Files"
|
||||
- takeScreenshot: compose-02-attach-sheet
|
||||
- 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:
|
||||
true: ${!output.pickPhoto}
|
||||
commands:
|
||||
- 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: 20000
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- takeScreenshot: compose-04-with-photo
|
||||
|
||||
- tapOn: "Send"
|
||||
- extendedWaitUntil:
|
||||
visible: "Email sent successfully"
|
||||
timeout: 20000
|
||||
optional: true
|
||||
- runFlow:
|
||||
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"
|
||||
@@ -0,0 +1,72 @@
|
||||
# Incoming mail while the app is open (sent by userb over JMAP), then reply
|
||||
# and forward it; userb receives both, the forward with the attachment.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Incoming, reply, forward
|
||||
tags:
|
||||
- features
|
||||
---
|
||||
- runFlow: ../subflows/signed-in.yaml
|
||||
- evalScript: ${output.incoming = 'E2E incoming ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
|
||||
- runScript:
|
||||
file: ../scripts/helper.js
|
||||
env:
|
||||
CALL: /send?to=${ACCOUNT}&subject=${encodeURIComponent(output.incoming)}&attach=1&html=1
|
||||
# Arrives without a pull to refresh (push over EventSource).
|
||||
- extendedWaitUntil:
|
||||
visible: ".*${output.incoming}.*"
|
||||
timeout: 90000
|
||||
- takeScreenshot: incoming-01-arrived
|
||||
- runFlow:
|
||||
file: ../subflows/open-message.yaml
|
||||
env:
|
||||
SUBJECT: ${output.incoming}
|
||||
- takeScreenshot: incoming-02-open
|
||||
|
||||
# Reply
|
||||
- tapOn:
|
||||
text: "Reply"
|
||||
index: 0
|
||||
- extendedWaitUntil:
|
||||
visible: "Re: ${output.incoming}"
|
||||
timeout: 15000
|
||||
- runFlow:
|
||||
file: ../subflows/compose-body.yaml
|
||||
env:
|
||||
BODY: "Reply from the iOS simulator test."
|
||||
- takeScreenshot: incoming-03-reply
|
||||
- tapOn: "Send"
|
||||
# The toast is gone after a few seconds; delivery is checked below.
|
||||
- extendedWaitUntil:
|
||||
visible: "Email sent successfully"
|
||||
timeout: 20000
|
||||
optional: true
|
||||
- runFlow:
|
||||
file: ../subflows/assert-delivered.yaml
|
||||
env:
|
||||
SUBJECT: "Re: ${output.incoming}"
|
||||
|
||||
# Forward with the attachment
|
||||
- extendedWaitUntil:
|
||||
visible: "Forward"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Forward"
|
||||
index: 0
|
||||
- extendedWaitUntil:
|
||||
visible: "Fwd: ${output.incoming}"
|
||||
timeout: 15000
|
||||
- tapOn: "Recipient email addresses"
|
||||
- inputText: "userb@example.org"
|
||||
- pressKey: Enter
|
||||
- takeScreenshot: incoming-04-forward
|
||||
- tapOn: "Send"
|
||||
- extendedWaitUntil:
|
||||
visible: "Email sent successfully"
|
||||
timeout: 20000
|
||||
optional: true
|
||||
- runFlow:
|
||||
file: ../subflows/assert-delivered.yaml
|
||||
env:
|
||||
SUBJECT: "Fwd: ${output.incoming}"
|
||||
ATTACHMENT: e2e-attachment.pdf
|
||||
- runFlow: ../subflows/close-message.yaml
|
||||
@@ -0,0 +1,27 @@
|
||||
# Search: a hit, a miss, clearing.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Search
|
||||
tags:
|
||||
- features
|
||||
---
|
||||
- runFlow: ../subflows/signed-in.yaml
|
||||
- tapOn: "Search mail..."
|
||||
- inputText: "verification"
|
||||
- pressKey: Enter
|
||||
- extendedWaitUntil:
|
||||
visible: ".*Your verification code.*"
|
||||
timeout: 30000
|
||||
- assertNotVisible: ".*Weekly digest.*"
|
||||
- takeScreenshot: search-01-hit
|
||||
- tapOn: "Clear search"
|
||||
- tapOn: "Search mail..."
|
||||
- inputText: "zzqx-no-such-mail"
|
||||
- pressKey: Enter
|
||||
- extendedWaitUntil:
|
||||
visible: "No results found"
|
||||
timeout: 30000
|
||||
- takeScreenshot: search-02-miss
|
||||
- tapOn: "Clear"
|
||||
- extendedWaitUntil:
|
||||
visible: ".*Weekly digest.*"
|
||||
timeout: 30000
|
||||
@@ -0,0 +1,41 @@
|
||||
# Swipe actions with undo: swipe left archives (undo brings it back), swipe
|
||||
# right toggles read.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Swipe actions and undo
|
||||
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
|
||||
# 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
|
||||
- takeScreenshot: swipe-02-undone
|
||||
|
||||
- swipe:
|
||||
from: ".*Grüße aus München.*"
|
||||
direction: RIGHT
|
||||
duration: 400
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- takeScreenshot: swipe-03-read-toggled
|
||||
- swipe:
|
||||
from: ".*Grüße aus München.*"
|
||||
direction: RIGHT
|
||||
duration: 400
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
@@ -0,0 +1,85 @@
|
||||
# Message actions with undo: move to a folder, delete to Trash, select and
|
||||
# star from the list.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Move, delete, select
|
||||
tags:
|
||||
- features
|
||||
---
|
||||
- runFlow: ../subflows/signed-in.yaml
|
||||
|
||||
# Move via More → Move to...
|
||||
- runFlow:
|
||||
file: ../subflows/open-message.yaml
|
||||
env:
|
||||
SUBJECT: Weekly digest
|
||||
- tapOn: "more"
|
||||
- extendedWaitUntil:
|
||||
visible: "More actions"
|
||||
timeout: 10000
|
||||
- takeScreenshot: actions-01-more
|
||||
- tapOn: "Move to..."
|
||||
- extendedWaitUntil:
|
||||
visible: "Move to..."
|
||||
timeout: 10000
|
||||
- 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
|
||||
# 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.*"
|
||||
timeout: 15000
|
||||
|
||||
# Delete (to Trash) and undo
|
||||
- runFlow:
|
||||
file: ../subflows/open-message.yaml
|
||||
env:
|
||||
SUBJECT: Weekly digest
|
||||
- takeScreenshot: actions-03a-before-delete
|
||||
- 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:
|
||||
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. 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
|
||||
# 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:
|
||||
visible: "Clear selection"
|
||||
commands:
|
||||
- tapOn: "Clear selection"
|
||||
- longPressOn: ".*Grüße aus München.*"
|
||||
- tapOn: "Unstar|Star"
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Clear selection"
|
||||
commands:
|
||||
- tapOn: "Clear selection"
|
||||
@@ -0,0 +1,58 @@
|
||||
# Scheduled send: schedule a message for later, find it under Scheduled,
|
||||
# cancel it.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Scheduled send
|
||||
tags:
|
||||
- features
|
||||
---
|
||||
- runFlow: ../subflows/signed-in.yaml
|
||||
- evalScript: ${output.subject = 'E2E scheduled ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
|
||||
- tapOn: "Compose"
|
||||
- extendedWaitUntil:
|
||||
visible: "New Message"
|
||||
timeout: 15000
|
||||
- tapOn: "Recipient email addresses"
|
||||
- inputText: "userb@example.org"
|
||||
- pressKey: Enter
|
||||
- tapOn:
|
||||
text: "Subject"
|
||||
index: 1
|
||||
- inputText: ${output.subject}
|
||||
- runFlow:
|
||||
file: ../subflows/compose-body.yaml
|
||||
env:
|
||||
FROM_SUBJECT: "yes"
|
||||
BODY: "Scheduled from the iOS simulator test."
|
||||
- tapOn: "Schedule send"
|
||||
- extendedWaitUntil:
|
||||
visible: "Tomorrow morning.*"
|
||||
timeout: 10000
|
||||
- takeScreenshot: scheduled-01-options
|
||||
- tapOn: "Tomorrow morning.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Scheduled"
|
||||
timeout: 30000
|
||||
- takeScreenshot: scheduled-02-confirmed
|
||||
- tapOn: "OK"
|
||||
|
||||
- runFlow:
|
||||
file: ../subflows/go-to-folder.yaml
|
||||
env:
|
||||
FOLDER: Scheduled
|
||||
- extendedWaitUntil:
|
||||
visible: ".*${output.subject}.*"
|
||||
timeout: 30000
|
||||
- takeScreenshot: scheduled-03-list
|
||||
- tapOn: ".*${output.subject}.*"
|
||||
- tapOn: "Cancel send"
|
||||
- extendedWaitUntil:
|
||||
visible: "Keep scheduled"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Cancel send"
|
||||
below: "Keep scheduled|.*will not be delivered.*"
|
||||
- extendedWaitUntil:
|
||||
notVisible: ".*${output.subject}.*"
|
||||
timeout: 30000
|
||||
- takeScreenshot: scheduled-04-cancelled
|
||||
- runFlow: ../subflows/go-to-inbox.yaml
|
||||
@@ -0,0 +1,126 @@
|
||||
# Calendar: the views, create an event with a reminder, edit it, delete it.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Calendar
|
||||
tags:
|
||||
- features
|
||||
---
|
||||
- runFlow: ../subflows/signed-in.yaml
|
||||
- evalScript: ${output.event = 'E2E event ' + DEVICE + ' ' + Date.now() % 100000}
|
||||
- tapOn:
|
||||
id: tab-calendar
|
||||
# The first visit asks for notification permission (event reminders).
|
||||
- extendedWaitUntil:
|
||||
visible: ".*Would Like to Send You Notifications.*|Go to today"
|
||||
timeout: 30000
|
||||
- runFlow: ../subflows/allow-notifications.yaml
|
||||
- extendedWaitUntil:
|
||||
visible: "Go to today"
|
||||
timeout: 30000
|
||||
- takeScreenshot: calendar-01-month
|
||||
|
||||
- tapOn: "Open menu"
|
||||
- tapOn: "Schedule"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- takeScreenshot: calendar-02-schedule
|
||||
- tapOn: "Open menu"
|
||||
- tapOn: "Day"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- takeScreenshot: calendar-03-day
|
||||
- tapOn: "Open menu"
|
||||
- tapOn: "Week"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- takeScreenshot: calendar-04-week
|
||||
|
||||
# Create
|
||||
- tapOn: "New event"
|
||||
- extendedWaitUntil:
|
||||
visible: "Save"
|
||||
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
|
||||
- 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: "Save"
|
||||
timeout: 20000
|
||||
|
||||
# Find it in the schedule and open it
|
||||
- tapOn: "Open menu"
|
||||
- tapOn: "Schedule"
|
||||
- tapOn: "Go to today"
|
||||
- scrollUntilVisible:
|
||||
element: ".*${output.event}.*"
|
||||
direction: DOWN
|
||||
timeout: 30000
|
||||
- tapOn: ".*${output.event}.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Edit"
|
||||
timeout: 10000
|
||||
- takeScreenshot: calendar-06-detail
|
||||
- assertVisible: ".*15 minutes before.*"
|
||||
|
||||
# Edit: add a location (an empty field, so no cursor games in the title)
|
||||
- tapOn: "Edit"
|
||||
- extendedWaitUntil:
|
||||
visible: "Edit event"
|
||||
timeout: 10000
|
||||
- scrollUntilVisible:
|
||||
element: "Place or address"
|
||||
direction: DOWN
|
||||
timeout: 10000
|
||||
- tapOn: "Place or address"
|
||||
- inputText: "Room 42"
|
||||
- tapOn: "Save"
|
||||
- extendedWaitUntil:
|
||||
visible: ".*Room 42.*"
|
||||
timeout: 20000
|
||||
- takeScreenshot: calendar-07-edited
|
||||
|
||||
# Delete (the detail sheet is still open after saving)
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Delete"
|
||||
commands:
|
||||
- tapOn: ".*${output.event}.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Delete"
|
||||
timeout: 10000
|
||||
- tapOn: "Delete"
|
||||
- extendedWaitUntil:
|
||||
visible: "Delete this event.*"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Delete"
|
||||
below: "Delete this event.*"
|
||||
- extendedWaitUntil:
|
||||
notVisible: ".*${output.event}.*"
|
||||
timeout: 20000
|
||||
- takeScreenshot: calendar-08-deleted
|
||||
- tapOn:
|
||||
id: tab-mail
|
||||
@@ -0,0 +1,70 @@
|
||||
# Contacts: the list, a contact's detail, create one and delete it again.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Contacts
|
||||
tags:
|
||||
- features
|
||||
---
|
||||
- runFlow: ../subflows/signed-in.yaml
|
||||
- evalScript: ${output.last = 'Tester' + Date.now() % 100000}
|
||||
- tapOn:
|
||||
id: tab-contacts
|
||||
- extendedWaitUntil:
|
||||
visible: "All Contacts"
|
||||
timeout: 30000
|
||||
- takeScreenshot: contacts-01-list
|
||||
- tapOn: ".*Alice.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Email"
|
||||
timeout: 10000
|
||||
- takeScreenshot: contacts-02-detail
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Back"
|
||||
commands:
|
||||
- tapOn: "Back"
|
||||
|
||||
- tapOn: "New Contact"
|
||||
- extendedWaitUntil:
|
||||
visible: "Save"
|
||||
timeout: 10000
|
||||
- runFlow:
|
||||
file: ../subflows/type.yaml
|
||||
env:
|
||||
FIELD: contact-first-name
|
||||
TEXT: E2E
|
||||
# Return moves to Middle name, then Last name: tapping the field would hit
|
||||
# the keyboard covering it.
|
||||
- pressKey: Enter
|
||||
- pressKey: Enter
|
||||
- runFlow:
|
||||
file: ../subflows/type.yaml
|
||||
env:
|
||||
FIELD: contact-last-name
|
||||
TAP: "no"
|
||||
TEXT: ${output.last}
|
||||
- takeScreenshot: contacts-03-form
|
||||
- tapOn: "Save"
|
||||
- extendedWaitUntil:
|
||||
visible: ".*${output.last}.*"
|
||||
timeout: 20000
|
||||
- takeScreenshot: contacts-04-created
|
||||
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "More actions"
|
||||
commands:
|
||||
- tapOn: ".*E2E ${output.last}.*"
|
||||
- tapOn: "More actions"
|
||||
- tapOn: "Delete"
|
||||
- extendedWaitUntil:
|
||||
visible: "Delete contact"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Delete"
|
||||
below: "Are you sure.*"
|
||||
- extendedWaitUntil:
|
||||
notVisible: ".*E2E ${output.last}.*"
|
||||
timeout: 20000
|
||||
- takeScreenshot: contacts-05-deleted
|
||||
- tapOn:
|
||||
id: tab-mail
|
||||
@@ -0,0 +1,65 @@
|
||||
# Files: open a folder, preview a text and a PDF file, create and delete a
|
||||
# folder.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Files
|
||||
tags:
|
||||
- features
|
||||
---
|
||||
- runFlow: ../subflows/signed-in.yaml
|
||||
- evalScript: ${output.folder = 'E2E folder ' + Date.now() % 100000}
|
||||
- tapOn:
|
||||
id: tab-files
|
||||
- extendedWaitUntil:
|
||||
visible: ".*Documents.*"
|
||||
timeout: 30000
|
||||
- takeScreenshot: files-01-root
|
||||
- tapOn: ".*Documents.*"
|
||||
- extendedWaitUntil:
|
||||
visible: ".*readme.txt.*"
|
||||
timeout: 20000
|
||||
- tapOn: ".*readme.txt.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "readme.txt"
|
||||
timeout: 20000
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- takeScreenshot: files-02-text-preview
|
||||
- tapOn: "Close"
|
||||
- tapOn: ".*q3-report.pdf.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "q3-report.pdf"
|
||||
timeout: 20000
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- takeScreenshot: files-03-pdf-preview
|
||||
- tapOn: "Close"
|
||||
- tapOn: "Back"
|
||||
|
||||
- tapOn: "New Folder"
|
||||
- tapOn: "Folder name"
|
||||
- inputText: ${output.folder}
|
||||
- tapOn: "Create"
|
||||
- extendedWaitUntil:
|
||||
visible: ".*${output.folder}.*"
|
||||
timeout: 20000
|
||||
- takeScreenshot: files-04-folder-created
|
||||
- longPressOn: ".*${output.folder}.*"
|
||||
- repeat:
|
||||
while:
|
||||
notVisible: "Delete this item.*"
|
||||
times: 3
|
||||
commands:
|
||||
- tapOn: "Delete"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- extendedWaitUntil:
|
||||
visible: "Delete this item.*"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Delete"
|
||||
below: "Delete this item.*"
|
||||
- extendedWaitUntil:
|
||||
notVisible: ".*${output.folder}.*"
|
||||
timeout: 20000
|
||||
- tapOn:
|
||||
id: tab-mail
|
||||
@@ -0,0 +1,38 @@
|
||||
# Every settings page opens and shows its content.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Settings pages
|
||||
tags:
|
||||
- features
|
||||
---
|
||||
- runFlow: ../subflows/signed-in.yaml
|
||||
- tapOn:
|
||||
id: tab-settings
|
||||
- extendedWaitUntil:
|
||||
visible: "Search settings"
|
||||
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, .*", 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
|
||||
@@ -0,0 +1,30 @@
|
||||
# Fresh install: the app starts and shows the sign-in choices, and typing
|
||||
# into a field works. Needs no server, so it runs before every suite as the
|
||||
# warm-up for the XCTest driver. It types only a made-up address, so its
|
||||
# debug log is safe to print.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Launch to sign-in
|
||||
tags:
|
||||
- smoke
|
||||
---
|
||||
- launchApp:
|
||||
clearState: true
|
||||
clearKeychain: true
|
||||
# A cold start of a release build on a CI simulator can be slow.
|
||||
- extendedWaitUntil:
|
||||
visible: "Enter server details manually"
|
||||
timeout: 120000
|
||||
- takeScreenshot: warmup-01-sign-in-choices
|
||||
- tapOn: "Enter server details manually"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: login-server-input
|
||||
timeout: 10000
|
||||
- runFlow: subflows/dismiss-keyboard-tip.yaml
|
||||
- runFlow:
|
||||
file: subflows/type.yaml
|
||||
env:
|
||||
FIELD: login-server-input
|
||||
TAP: "no"
|
||||
TEXT: typing-probe.example.net
|
||||
- takeScreenshot: warmup-02-typed
|
||||
@@ -0,0 +1,27 @@
|
||||
# Perf: open a message and go back, five times. The app is already running
|
||||
# with -perfMarks YES (no launchApp here: it would drop the launch argument).
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Perf open message
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: email-row
|
||||
timeout: 60000
|
||||
- repeat:
|
||||
times: 5
|
||||
commands:
|
||||
- tapOn:
|
||||
id: email-row
|
||||
index: 1
|
||||
- extendedWaitUntil:
|
||||
visible: "Reply All"
|
||||
timeout: 30000
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Back"
|
||||
commands:
|
||||
- tapOn: "Back"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
@@ -0,0 +1,33 @@
|
||||
# Perf: switch through the tabs, three times.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Perf tab switches
|
||||
---
|
||||
- repeat:
|
||||
times: 3
|
||||
commands:
|
||||
- tapOn:
|
||||
id: tab-calendar
|
||||
- runFlow:
|
||||
when:
|
||||
visible: ".*Would Like to Send You Notifications.*"
|
||||
commands:
|
||||
- tapOn: "Allow"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn:
|
||||
id: tab-contacts
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- takeScreenshot: perf-contacts
|
||||
- tapOn:
|
||||
id: tab-files
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn:
|
||||
id: tab-settings
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn:
|
||||
id: tab-mail
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
@@ -0,0 +1,23 @@
|
||||
# Perf: open the composer and close it untouched, five times.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Perf compose open
|
||||
---
|
||||
- tapOn:
|
||||
id: tab-mail
|
||||
- repeat:
|
||||
times: 5
|
||||
commands:
|
||||
- tapOn: "Compose"
|
||||
- extendedWaitUntil:
|
||||
visible: "New Message"
|
||||
timeout: 20000
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn: "Close"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Discard"
|
||||
commands:
|
||||
- tapOn: "Discard"
|
||||
@@ -0,0 +1,28 @@
|
||||
# Perf: fling through the long inbox (thousands of messages), then back up.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Perf list scroll
|
||||
---
|
||||
- tapOn:
|
||||
id: tab-mail
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: email-row
|
||||
timeout: 30000
|
||||
- repeat:
|
||||
times: 8
|
||||
commands:
|
||||
- swipe:
|
||||
start: 50%,80%
|
||||
end: 50%,20%
|
||||
duration: 250
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 4000
|
||||
- repeat:
|
||||
times: 8
|
||||
commands:
|
||||
- swipe:
|
||||
start: 50%,20%
|
||||
end: 50%,80%
|
||||
duration: 250
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 4000
|
||||
@@ -0,0 +1,11 @@
|
||||
# Performance runs: sign in once (ACCOUNT, the bulk account with thousands of
|
||||
# messages). Later installs of other builds keep the data and the keychain.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Perf sign-in
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/password-sign-in.yaml
|
||||
env:
|
||||
LOGIN: ${ACCOUNT}@example.org
|
||||
INBOX_TEXT: ".*Bulk.*"
|
||||
@@ -0,0 +1,79 @@
|
||||
# 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:
|
||||
- push
|
||||
---
|
||||
- runFlow: ../subflows/signed-in.yaml
|
||||
# Without notification permission iOS drops the push silently, and the
|
||||
# launch-time grant doesn't reach it on these simulators: let the app ask
|
||||
# (the Calendar tab does, for event reminders) and allow.
|
||||
- tapOn:
|
||||
id: tab-calendar
|
||||
- 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
|
||||
- tapOn:
|
||||
id: tab-mail
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- pressKey: Home
|
||||
|
||||
# 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.subject0)}
|
||||
- extendedWaitUntil:
|
||||
visible: ".*${output.subject0}.*|.*New message.*"
|
||||
timeout: 30000
|
||||
- takeScreenshot: push-01-banner
|
||||
# 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
|
||||
|
||||
# 3. The same from a cold start: the app isn't running.
|
||||
- stopApp
|
||||
- evalScript: ${output.subject2 = 'E2E push cold ' + Date.now()}
|
||||
- runFlow:
|
||||
file: ../subflows/push-and-tap.yaml
|
||||
env:
|
||||
SUBJECT: ${output.subject2}
|
||||
- extendedWaitUntil:
|
||||
visible: "Reply All"
|
||||
timeout: 90000
|
||||
- assertVisible: ".*${output.subject2}.*"
|
||||
- takeScreenshot: push-04-opened-cold
|
||||
@@ -0,0 +1,8 @@
|
||||
// Calls the test helper (scripts/e2e/helper.mjs) on the runner.
|
||||
// env CALL: path and query, e.g. "/totp" or "/pair?user=x&deliver=openurl".
|
||||
// The answer lands in output.helper.
|
||||
const response = http.get(HELPER + CALL);
|
||||
if (!response.ok) {
|
||||
throw new Error('helper ' + CALL.split('?')[0] + ' failed: ' + response.status + ' ' + response.body);
|
||||
}
|
||||
output.helper = json(response.body);
|
||||
@@ -0,0 +1,32 @@
|
||||
# Password sign-in as the first account, sign out, sign in again.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Password, sign out, sign in again
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- takeScreenshot: password-01-choices
|
||||
- runFlow:
|
||||
file: ../subflows/manual-server.yaml
|
||||
env:
|
||||
HOST: ${SERVER}
|
||||
- takeScreenshot: password-02-confirm
|
||||
- tapOn: "Sign in with a password instead"
|
||||
- runFlow:
|
||||
file: ../subflows/password-form.yaml
|
||||
env:
|
||||
LOGIN: ${ACCOUNT}@example.org
|
||||
- runFlow: ../subflows/inbox.yaml
|
||||
- takeScreenshot: password-03-inbox
|
||||
|
||||
- runFlow: ../subflows/sign-out.yaml
|
||||
- extendedWaitUntil:
|
||||
visible: "Enter server details manually"
|
||||
timeout: 30000
|
||||
- takeScreenshot: password-04-signed-out
|
||||
|
||||
- runFlow:
|
||||
file: ../subflows/password-sign-in.yaml
|
||||
env:
|
||||
LOGIN: ${ACCOUNT}@example.org
|
||||
- takeScreenshot: password-05-signed-in-again
|
||||
@@ -0,0 +1,58 @@
|
||||
# A wrong password and an unreachable server: the app says what went wrong
|
||||
# and stays on the form.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Password errors
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/manual-server.yaml
|
||||
env:
|
||||
HOST: ${SERVER}
|
||||
- tapOn: "Sign in with a password instead"
|
||||
- runFlow:
|
||||
file: ../subflows/password-form.yaml
|
||||
env:
|
||||
LOGIN: ${ACCOUNT}@example.org
|
||||
PASSWORD: wrong-password-123
|
||||
- extendedWaitUntil:
|
||||
visible: "That didn't work"
|
||||
timeout: 30000
|
||||
- takeScreenshot: errors-01-wrong-password
|
||||
- assertVisible:
|
||||
id: login-password-input
|
||||
|
||||
# Back to the server step, then a host that doesn't resolve.
|
||||
- tapOn: "Back"
|
||||
- tapOn: "Back"
|
||||
- runFlow:
|
||||
file: ../subflows/type.yaml
|
||||
env:
|
||||
FIELD: login-server-input
|
||||
TAP: "no"
|
||||
ERASE: "yes"
|
||||
TEXT: mail.does-not-exist.invalid
|
||||
- tapOn: "Continue"
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign in with a password instead"
|
||||
timeout: 20000
|
||||
- tapOn: "Sign in with a password instead"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: login-password-input
|
||||
timeout: 10000
|
||||
- runFlow:
|
||||
file: ../subflows/type-secret.yaml
|
||||
env:
|
||||
FIELD: login-password-input
|
||||
# The email is filled in, so the password field focuses itself.
|
||||
TAP: "no"
|
||||
TEXT: ${PASSWORD}
|
||||
- pressKey: Enter
|
||||
- extendedWaitUntil:
|
||||
visible: "Can't reach mail.does-not-exist.invalid|No mail server at mail.does-not-exist.invalid|Sign-in failed"
|
||||
timeout: 60000
|
||||
- takeScreenshot: errors-02-unreachable
|
||||
- assertVisible:
|
||||
id: login-password-input
|
||||
@@ -0,0 +1,24 @@
|
||||
# A TOTP account signs in with a password: Stalwart answers 402 for the right
|
||||
# password, and the app asks for the code.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Password with TOTP
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/manual-server.yaml
|
||||
env:
|
||||
HOST: ${SERVER}
|
||||
- tapOn: "Sign in with a password instead"
|
||||
- runFlow:
|
||||
file: ../subflows/password-form.yaml
|
||||
env:
|
||||
LOGIN: ${TOTP_ACCOUNT}@example.org
|
||||
- extendedWaitUntil:
|
||||
visible: "Authentication code"
|
||||
timeout: 30000
|
||||
- takeScreenshot: totp-01-code-asked
|
||||
- runFlow: ../subflows/two-factor.yaml
|
||||
- runFlow: ../subflows/inbox.yaml
|
||||
- takeScreenshot: totp-02-inbox
|
||||
@@ -0,0 +1,20 @@
|
||||
# OAuth with Stalwart's own authorization server: the confirm step's
|
||||
# Continue opens an ASWebAuthenticationSession on Stalwart's sign-in page.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: OAuth (Stalwart)
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/manual-server.yaml
|
||||
env:
|
||||
HOST: ${SERVER}
|
||||
- tapOn: "Continue"
|
||||
- runFlow: ../subflows/allow-auth-session.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/stalwart-login-page.yaml
|
||||
env:
|
||||
LOGIN: ${ACCOUNT}@example.org
|
||||
- runFlow: ../subflows/inbox.yaml
|
||||
- takeScreenshot: oauth-01-inbox
|
||||
@@ -0,0 +1,39 @@
|
||||
# Backing out of OAuth: Cancel on the system alert, and closing the sheet
|
||||
# halfway. Both leave the user on the confirm step without an error.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: OAuth cancelled
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/manual-server.yaml
|
||||
env:
|
||||
HOST: ${SERVER}
|
||||
- tapOn: "Continue"
|
||||
- extendedWaitUntil:
|
||||
visible: ".*Wants to Use.*to Sign In.*"
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
text: "Cancel"
|
||||
below: ".*Wants to Use.*to Sign In.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign in with a password instead"
|
||||
timeout: 20000
|
||||
- assertNotVisible: "Sign-in failed"
|
||||
- assertNotVisible: "Sign-in was interrupted"
|
||||
- takeScreenshot: oauth-cancel-01-alert-cancelled
|
||||
|
||||
# Accept the alert, then close the sheet on Stalwart's page.
|
||||
- tapOn: "Continue"
|
||||
- runFlow: ../subflows/allow-auth-session.yaml
|
||||
- extendedWaitUntil:
|
||||
visible: "you@example.com"
|
||||
timeout: 60000
|
||||
- tapOn: "Cancel"
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign in with a password instead"
|
||||
timeout: 20000
|
||||
- assertNotVisible: "Sign-in failed"
|
||||
- assertNotVisible: "Sign-in was interrupted"
|
||||
- takeScreenshot: oauth-cancel-02-sheet-closed
|
||||
@@ -0,0 +1,20 @@
|
||||
# Sign in via webmail: the confirm step on a Bulwark webmail address hands
|
||||
# off to the webmail's sign-in page in an auth session.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Webmail hand-off
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/manual-server.yaml
|
||||
env:
|
||||
HOST: ${WEBMAIL}
|
||||
- tapOn: "Continue"
|
||||
- runFlow: ../subflows/allow-auth-session.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/webmail-login-page.yaml
|
||||
env:
|
||||
LOGIN: ${ACCOUNT}@example.org
|
||||
- runFlow: ../subflows/inbox.yaml
|
||||
- takeScreenshot: webmail-01-inbox
|
||||
@@ -0,0 +1,24 @@
|
||||
# Webmail hand-off for a TOTP account: the webmail asks for the code; if it
|
||||
# hands back the password, the app finishes with a code of its own. Uses the
|
||||
# beta webmail: the stable one still has 06's session in the browser, which
|
||||
# strands the hand-off (see 20-webmail-session.yaml).
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Webmail hand-off with TOTP
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/manual-server.yaml
|
||||
env:
|
||||
HOST: ${WEBMAIL_BETA}
|
||||
- tapOn: "Continue"
|
||||
- runFlow: ../subflows/allow-auth-session.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/webmail-login-page.yaml
|
||||
env:
|
||||
LOGIN: ${TOTP_ACCOUNT}@example.org
|
||||
TOTP: "yes"
|
||||
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
|
||||
- runFlow: ../subflows/inbox.yaml
|
||||
- takeScreenshot: webmail-totp-01-inbox
|
||||
@@ -0,0 +1,17 @@
|
||||
# Sign-in link from another app (simctl openurl), password account. The
|
||||
# helper signs in to the test webmail, creates a fresh "Link mobile app" code
|
||||
# and opens its bulwarkmail://pair link on the simulator.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Sign-in link from another app
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runScript:
|
||||
file: ../scripts/helper.js
|
||||
env:
|
||||
CALL: /pair?user=${ACCOUNT}&deliver=openurl
|
||||
- runFlow: ../subflows/confirm-link.yaml
|
||||
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
|
||||
- runFlow: ../subflows/inbox.yaml
|
||||
- takeScreenshot: link-password-01-inbox
|
||||
@@ -0,0 +1,16 @@
|
||||
# "Paste sign-in link" with a fresh pairing link on the clipboard (simctl
|
||||
# pbcopy), password account.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Paste sign-in link
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runScript:
|
||||
file: ../scripts/helper.js
|
||||
env:
|
||||
CALL: /pair?user=${ACCOUNT}&deliver=pbcopy
|
||||
- runFlow: ../subflows/paste-link.yaml
|
||||
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
|
||||
- runFlow: ../subflows/inbox.yaml
|
||||
- takeScreenshot: paste-password-01-inbox
|
||||
@@ -0,0 +1,15 @@
|
||||
# Sign-in link from another app for a TOTP account.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Sign-in link, TOTP account
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runScript:
|
||||
file: ../scripts/helper.js
|
||||
env:
|
||||
CALL: /pair?user=${TOTP_ACCOUNT}&deliver=openurl
|
||||
- runFlow: ../subflows/confirm-link.yaml
|
||||
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
|
||||
- runFlow: ../subflows/inbox.yaml
|
||||
- takeScreenshot: link-totp-01-inbox
|
||||
@@ -0,0 +1,15 @@
|
||||
# "Paste sign-in link" for a TOTP account.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Paste sign-in link, TOTP account
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runScript:
|
||||
file: ../scripts/helper.js
|
||||
env:
|
||||
CALL: /pair?user=${TOTP_ACCOUNT}&deliver=pbcopy
|
||||
- runFlow: ../subflows/paste-link.yaml
|
||||
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
|
||||
- runFlow: ../subflows/inbox.yaml
|
||||
- takeScreenshot: paste-totp-01-inbox
|
||||
@@ -0,0 +1,21 @@
|
||||
# An expired code (codes live 2 minutes): the helper creates one now and
|
||||
# opens it after 130 s. The app says so and stays on the sign-in screen.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Expired sign-in code
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runScript:
|
||||
file: ../scripts/helper.js
|
||||
env:
|
||||
CALL: /pair-later?user=${ACCOUNT}&deliver=openurl&delay=130
|
||||
- runFlow:
|
||||
file: ../subflows/confirm-link.yaml
|
||||
env:
|
||||
LINK_WAIT: 200000
|
||||
- extendedWaitUntil:
|
||||
visible: "That code has expired.*"
|
||||
timeout: 30000
|
||||
- takeScreenshot: link-expired-01-notice
|
||||
- assertVisible: "Enter server details manually"
|
||||
@@ -0,0 +1,44 @@
|
||||
# A code that was already used: sign in with it, sign out, open the same link
|
||||
# again (same app process, then after a restart, which asks the server).
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Used sign-in code
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runScript:
|
||||
file: ../scripts/helper.js
|
||||
env:
|
||||
CALL: /pair?user=${ACCOUNT}&deliver=openurl
|
||||
- runFlow: ../subflows/confirm-link.yaml
|
||||
- runFlow: ../subflows/inbox.yaml
|
||||
- runFlow: ../subflows/sign-out.yaml
|
||||
- extendedWaitUntil:
|
||||
visible: "Enter server details manually"
|
||||
timeout: 30000
|
||||
|
||||
- runScript:
|
||||
file: ../scripts/helper.js
|
||||
env:
|
||||
CALL: /redeliver?user=${ACCOUNT}&deliver=openurl
|
||||
- runFlow: ../subflows/confirm-link.yaml
|
||||
- extendedWaitUntil:
|
||||
visible: "That code was already used|That code has expired or was already used"
|
||||
timeout: 30000
|
||||
- takeScreenshot: link-used-01-same-process
|
||||
|
||||
- stopApp
|
||||
- launchApp
|
||||
- extendedWaitUntil:
|
||||
visible: "Enter server details manually"
|
||||
timeout: 60000
|
||||
- runScript:
|
||||
file: ../scripts/helper.js
|
||||
env:
|
||||
CALL: /redeliver?user=${ACCOUNT}&deliver=openurl
|
||||
- runFlow: ../subflows/confirm-link.yaml
|
||||
- extendedWaitUntil:
|
||||
visible: "That code was already used|That code has expired or was already used"
|
||||
timeout: 30000
|
||||
- takeScreenshot: link-used-02-after-restart
|
||||
- assertVisible: "Enter server details manually"
|
||||
@@ -0,0 +1,32 @@
|
||||
# The QR scanner with camera access denied, then allowed (the simulator has
|
||||
# no camera, so nothing can be scanned). Neither may crash the app.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Scanner without camera
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/start.yaml
|
||||
env:
|
||||
CAMERA: deny
|
||||
- tapOn: "Scan a sign-in code.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Camera access is.*|Scanning a sign-in QR code uses the camera.*"
|
||||
timeout: 15000
|
||||
- takeScreenshot: scanner-01-denied
|
||||
- tapOn: "Close"
|
||||
- assertVisible: "Enter server details manually"
|
||||
|
||||
- runFlow:
|
||||
file: ../subflows/start.yaml
|
||||
env:
|
||||
CAMERA: allow
|
||||
- tapOn: "Scan a sign-in code.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign-in code"
|
||||
timeout: 15000
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- takeScreenshot: scanner-02-allowed
|
||||
- tapOn: "Close"
|
||||
- assertVisible: "Enter server details manually"
|
||||
@@ -0,0 +1,23 @@
|
||||
# "Use my email address": example.org has no autodiscovery, so the app moves
|
||||
# on to the server step and names the domain.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Email address discovery
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- tapOn: "Use my email address.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "What's your email address."
|
||||
timeout: 10000
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- inputText: ${ACCOUNT}@example.org
|
||||
- assertVisible: ${ACCOUNT}@example.org
|
||||
- tapOn: "Continue"
|
||||
- extendedWaitUntil:
|
||||
visible: "We couldn't find a server for example.org.*"
|
||||
timeout: 60000
|
||||
- takeScreenshot: discovery-01-not-found
|
||||
- assertVisible:
|
||||
id: login-server-input
|
||||
@@ -0,0 +1,29 @@
|
||||
# Second account by password via "Another account on <host>", then switch
|
||||
# between the two.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Add account, password
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/password-sign-in.yaml
|
||||
env:
|
||||
LOGIN: ${ACCOUNT}@example.org
|
||||
- runFlow: ../subflows/open-add-account.yaml
|
||||
- takeScreenshot: add-password-01-choices
|
||||
- tapOn: "Another account on .*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign in with a password instead"
|
||||
timeout: 20000
|
||||
- tapOn: "Sign in with a password instead"
|
||||
- runFlow:
|
||||
file: ../subflows/password-form.yaml
|
||||
env:
|
||||
LOGIN: ${ACCOUNT2}@example.org
|
||||
- runFlow: ../subflows/inbox.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/check-active-account.yaml
|
||||
env:
|
||||
ACTIVE: ${ACCOUNT2}@example.org
|
||||
- takeScreenshot: add-password-02-two-accounts
|
||||
@@ -0,0 +1,41 @@
|
||||
# Second account by OAuth. Adding uses an ephemeral auth session, so the
|
||||
# first account's Stalwart sign-in must not be reused.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Add account, OAuth
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/manual-server.yaml
|
||||
env:
|
||||
HOST: ${SERVER}
|
||||
- tapOn: "Continue"
|
||||
- runFlow: ../subflows/allow-auth-session.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/stalwart-login-page.yaml
|
||||
env:
|
||||
LOGIN: ${ACCOUNT}@example.org
|
||||
- runFlow: ../subflows/inbox.yaml
|
||||
|
||||
- runFlow: ../subflows/open-add-account.yaml
|
||||
- tapOn: "Another account on .*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign in with a password instead"
|
||||
timeout: 20000
|
||||
- tapOn: "Continue"
|
||||
- runFlow:
|
||||
when:
|
||||
visible: ".*Wants to Use.*to Sign In.*"
|
||||
commands:
|
||||
- runFlow: ../subflows/allow-auth-session.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/stalwart-login-page.yaml
|
||||
env:
|
||||
LOGIN: ${ACCOUNT2}@example.org
|
||||
- runFlow: ../subflows/inbox.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/check-active-account.yaml
|
||||
env:
|
||||
ACTIVE: ${ACCOUNT2}@example.org
|
||||
- takeScreenshot: add-oauth-01-two-accounts
|
||||
@@ -0,0 +1,43 @@
|
||||
# Second account via the webmail hand-off, on top of a password account.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Add account, webmail
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/password-sign-in.yaml
|
||||
env:
|
||||
LOGIN: ${ACCOUNT}@example.org
|
||||
- runFlow: ../subflows/open-add-account.yaml
|
||||
- tapOn: "An account somewhere else.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "What's the other address."
|
||||
timeout: 10000
|
||||
- tapOn: "I know my server address"
|
||||
- runFlow:
|
||||
file: ../subflows/type.yaml
|
||||
env:
|
||||
FIELD: login-server-input
|
||||
TAP: "no"
|
||||
TEXT: ${WEBMAIL}
|
||||
- tapOn: "Continue"
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign in with a password instead"
|
||||
timeout: 20000
|
||||
- tapOn: "Continue"
|
||||
- runFlow:
|
||||
when:
|
||||
visible: ".*Wants to Use.*to Sign In.*"
|
||||
commands:
|
||||
- runFlow: ../subflows/allow-auth-session.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/webmail-login-page.yaml
|
||||
env:
|
||||
LOGIN: ${ACCOUNT2}@example.org
|
||||
- runFlow: ../subflows/inbox.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/check-active-account.yaml
|
||||
env:
|
||||
ACTIVE: ${ACCOUNT2}@example.org
|
||||
- takeScreenshot: add-webmail-01-two-accounts
|
||||
@@ -0,0 +1,37 @@
|
||||
# Second account from a sign-in link opened by another app while signed in.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Add account, sign-in link
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/password-sign-in.yaml
|
||||
env:
|
||||
LOGIN: ${ACCOUNT}@example.org
|
||||
- runScript:
|
||||
file: ../scripts/helper.js
|
||||
env:
|
||||
CALL: /pair?user=${ACCOUNT2}&deliver=openurl
|
||||
- extendedWaitUntil:
|
||||
visible: "Open in .*Bulwark Mobile.*|Sign in with a link."
|
||||
timeout: 60000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Open in .*Bulwark Mobile.*"
|
||||
commands:
|
||||
- tapOn: "Open"
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign in with a link."
|
||||
timeout: 20000
|
||||
- takeScreenshot: add-link-01-prompt
|
||||
- assertVisible: "This link adds an account.*"
|
||||
- tapOn:
|
||||
text: "Continue"
|
||||
below: "Sign in with a link."
|
||||
- runFlow: ../subflows/inbox.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/check-active-account.yaml
|
||||
env:
|
||||
ACTIVE: ${ACCOUNT2}@example.org
|
||||
- takeScreenshot: add-link-02-two-accounts
|
||||
@@ -0,0 +1,52 @@
|
||||
# Sign in via webmail while the browser still has a webmail session (from
|
||||
# 06-webmail, or from using the webmail in Safari): the hand-off must still
|
||||
# reach the app. Before the fix, the webmail's login page redirected an
|
||||
# existing session to its own inbox inside the sign-in sheet, and the app
|
||||
# never got the sign-in.
|
||||
appId: org.bulwarkmail.mobile
|
||||
name: Webmail hand-off with an existing webmail session
|
||||
tags:
|
||||
- signin
|
||||
---
|
||||
# Leaves a webmail session in the shared browser cookies.
|
||||
- runFlow: ../subflows/start.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/manual-server.yaml
|
||||
env:
|
||||
HOST: ${WEBMAIL}
|
||||
- tapOn: "Continue"
|
||||
- runFlow: ../subflows/allow-auth-session.yaml
|
||||
- extendedWaitUntil:
|
||||
visible: "user@example.com|Hand over to the app|.*Inbox.*"
|
||||
timeout: 60000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "user@example.com"
|
||||
commands:
|
||||
- runFlow:
|
||||
file: ../subflows/webmail-login-page.yaml
|
||||
env:
|
||||
LOGIN: ${ACCOUNT}@example.org
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Hand over to the app"
|
||||
commands:
|
||||
- tapOn: "Hand over to the app"
|
||||
- runFlow: ../subflows/inbox.yaml
|
||||
- runFlow: ../subflows/sign-out.yaml
|
||||
|
||||
# Again: the webmail now knows the user.
|
||||
- extendedWaitUntil:
|
||||
visible: "Enter server details manually"
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
file: ../subflows/manual-server.yaml
|
||||
env:
|
||||
HOST: ${WEBMAIL}
|
||||
- tapOn: "Continue"
|
||||
- runFlow: ../subflows/allow-auth-session.yaml
|
||||
- extendedWaitUntil:
|
||||
visible: "user@example.com|Hand over to the app|Search mail.*"
|
||||
timeout: 60000
|
||||
- takeScreenshot: webmail-session-01-second-visit
|
||||
- assertNotVisible: "Search mail.*"
|
||||
@@ -0,0 +1,11 @@
|
||||
# iOS asks before an app opens a sign-in session in the system browser:
|
||||
# "“Bulwark Mobile” Wants to Use “<domain>” to Sign In". Accept it.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: ".*Wants to Use.*to Sign In.*"
|
||||
timeout: 30000
|
||||
- takeScreenshot: auth-session-alert
|
||||
- tapOn:
|
||||
text: "Continue"
|
||||
below: ".*Wants to Use.*to Sign In.*"
|
||||
@@ -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
|
||||
@@ -0,0 +1,18 @@
|
||||
# The helper checks over JMAP that AS (default userb) received SUBJECT; with
|
||||
# ATTACHMENT set, that the message carries that file.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- runScript:
|
||||
file: ../scripts/helper.js
|
||||
env:
|
||||
CALL: /find?as=${AS || 'userb'}&subject=${encodeURIComponent(SUBJECT)}&timeout=60
|
||||
- assertTrue:
|
||||
condition: ${output.helper.found}
|
||||
label: "${SUBJECT} delivered to ${AS || 'userb'}"
|
||||
- runFlow:
|
||||
when:
|
||||
true: ${!!ATTACHMENT}
|
||||
commands:
|
||||
- assertTrue:
|
||||
condition: ${output.helper.attachments.indexOf(ATTACHMENT) >= 0}
|
||||
label: "${SUBJECT} carries ${ATTACHMENT}"
|
||||
@@ -0,0 +1,17 @@
|
||||
# The Settings account card names the active account ACTIVE, and the Account
|
||||
# page lists another account to switch to. Goes back to Mail afterwards.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- tapOn:
|
||||
id: tab-settings
|
||||
- extendedWaitUntil:
|
||||
visible: ".*${ACTIVE}.*"
|
||||
timeout: 20000
|
||||
- tapOn: ".*${ACTIVE}.*"
|
||||
- scrollUntilVisible:
|
||||
element: "Switch to this account"
|
||||
direction: DOWN
|
||||
timeout: 20000
|
||||
- takeScreenshot: accounts-${ACTIVE}
|
||||
- tapOn:
|
||||
id: tab-mail
|
||||
@@ -0,0 +1,11 @@
|
||||
# Empty the field with id FIELD. A tap puts the cursor where it lands and
|
||||
# eraseText only deletes before the cursor, so erase in passes: each tap
|
||||
# lands in the middle of what is left.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- repeat:
|
||||
times: 4
|
||||
commands:
|
||||
- tapOn:
|
||||
id: ${FIELD}
|
||||
- eraseText: 100
|
||||
@@ -0,0 +1,11 @@
|
||||
# Leave the message view: Back on a phone; the split layout keeps the list.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Back"
|
||||
commands:
|
||||
- tapOn: "Back"
|
||||
# A tap during the transition lands on stale coordinates.
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
@@ -0,0 +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:
|
||||
true: ${FROM_SUBJECT == 'yes'}
|
||||
commands:
|
||||
- pressKey: Enter
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 2000
|
||||
- inputText: ${BODY}
|
||||
- runFlow:
|
||||
when:
|
||||
true: ${FROM_SUBJECT != 'yes'}
|
||||
commands:
|
||||
- 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}
|
||||
@@ -0,0 +1,20 @@
|
||||
# A sign-in link from another app asks first. Continue.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
# simctl openurl acts like a link from another app: iOS asks first.
|
||||
- extendedWaitUntil:
|
||||
visible: "Open in .*Bulwark Mobile.*|Sign in with a link.|Open the sign-in page of.*"
|
||||
timeout: ${LINK_WAIT || 60000}
|
||||
- runFlow:
|
||||
when:
|
||||
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: 60000
|
||||
- takeScreenshot: link-confirm-prompt
|
||||
- tapOn:
|
||||
text: "Continue"
|
||||
below: "Sign in with a link."
|
||||
@@ -0,0 +1,12 @@
|
||||
# iOS shows a one-time slide-to-type tip over the keyboard on a fresh
|
||||
# simulator. The workflow turns it off, but dismiss it if it still shows up.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Speed up your typing by sliding.*"
|
||||
commands:
|
||||
# The sign-in screen has its own Continue button; take the tip's.
|
||||
- tapOn:
|
||||
text: "Continue"
|
||||
below: "Speed up your typing by sliding.*"
|
||||
@@ -0,0 +1,12 @@
|
||||
# Some routes hand the app a password for a TOTP account; the app then asks
|
||||
# for a code itself. Answer it when it shows up.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: "Authentication code|Inbox"
|
||||
timeout: 90000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Authentication code"
|
||||
commands:
|
||||
- runFlow: two-factor.yaml
|
||||
@@ -0,0 +1,9 @@
|
||||
# Open folder FOLDER from the drawer or docked sidebar.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- runFlow: open-drawer.yaml
|
||||
# A folder row's label joins its texts: "<name>, <unread>, /, <total>".
|
||||
- tapOn: ${FOLDER}([ ,].*)?
|
||||
- extendedWaitUntil:
|
||||
visible: ${FOLDER}
|
||||
timeout: 15000
|
||||
@@ -0,0 +1,16 @@
|
||||
# Back to the inbox list, from wherever in the Mail tab.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Back"
|
||||
commands:
|
||||
- tapOn: "Back"
|
||||
- runFlow:
|
||||
file: go-to-folder.yaml
|
||||
env:
|
||||
FOLDER: Inbox
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: email-row
|
||||
timeout: 60000
|
||||
@@ -0,0 +1,26 @@
|
||||
# Signed in: the tab bar is there and the inbox shows messages. Dismisses the
|
||||
# one-time prompts on the way.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: tab-mail
|
||||
timeout: 90000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Not now"
|
||||
commands:
|
||||
- tapOn: "Not now"
|
||||
# Adding an account from Settings returns to Settings.
|
||||
- tapOn:
|
||||
id: tab-mail
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: email-row
|
||||
timeout: 60000
|
||||
# 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
|
||||
@@ -0,0 +1,31 @@
|
||||
# From the sign-in choices: enter a server address by hand and continue to the
|
||||
# confirm step. env HOST: the address as a user types it.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- tapOn: "Enter server details manually"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: login-server-input
|
||||
timeout: 10000
|
||||
- runFlow:
|
||||
file: type.yaml
|
||||
env:
|
||||
FIELD: login-server-input
|
||||
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
|
||||
@@ -0,0 +1,19 @@
|
||||
# Signed in: Settings → account card → Account page → Add account, up to the
|
||||
# add-mode choices. (On the iPad split layout the Account page is already
|
||||
# open; tapping the card again is harmless.)
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- tapOn:
|
||||
id: tab-settings
|
||||
- extendedWaitUntil:
|
||||
visible: ".*@example.org, .*"
|
||||
timeout: 10000
|
||||
- tapOn: ".*@example.org, .*"
|
||||
- scrollUntilVisible:
|
||||
element: "Add account"
|
||||
direction: DOWN
|
||||
timeout: 20000
|
||||
- tapOn: "Add account"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add another account"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,24 @@
|
||||
# Show the folder list: the modal drawer, or nothing to do where the sidebar
|
||||
# is docked (iPad landscape).
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Toggle menu"
|
||||
commands:
|
||||
- tapOn: "Toggle menu"
|
||||
- extendedWaitUntil:
|
||||
visible: "Scheduled.*"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
# Once more if the first tap went to a list still settling.
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Scheduled.*"
|
||||
commands:
|
||||
- tapOn: "Toggle menu"
|
||||
- extendedWaitUntil:
|
||||
visible: "Scheduled.*"
|
||||
timeout: 10000
|
||||
@@ -0,0 +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
|
||||
---
|
||||
- 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: "more"
|
||||
timeout: 20000
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
@@ -0,0 +1,19 @@
|
||||
# On the password step: fill in LOGIN and PASSWORD and sign in.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: login-username-input
|
||||
timeout: 20000
|
||||
- runFlow:
|
||||
file: type.yaml
|
||||
env:
|
||||
FIELD: login-username-input
|
||||
TEXT: ${LOGIN}
|
||||
- runFlow:
|
||||
file: type-secret.yaml
|
||||
env:
|
||||
FIELD: login-password-input
|
||||
TEXT: ${PASSWORD}
|
||||
# Return on the password field signs in.
|
||||
- pressKey: Enter
|
||||
@@ -0,0 +1,11 @@
|
||||
# From the sign-in choices to the inbox: manual server, password.
|
||||
# env LOGIN: the account's address.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- runFlow:
|
||||
file: manual-server.yaml
|
||||
env:
|
||||
HOST: ${SERVER}
|
||||
- tapOn: "Sign in with a password instead"
|
||||
- runFlow: password-form.yaml
|
||||
- runFlow: inbox.yaml
|
||||
@@ -0,0 +1,22 @@
|
||||
# The helper has put a sign-in link on the simulator's clipboard: open
|
||||
# "Paste sign-in link", paste it and sign in.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- tapOn: "Paste sign-in link.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Paste"
|
||||
timeout: 10000
|
||||
- tapOn: "Paste"
|
||||
# iOS asks before an app reads the clipboard.
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Allow Paste"
|
||||
commands:
|
||||
- tapOn: "Allow Paste"
|
||||
- extendedWaitUntil:
|
||||
visible: "bulwarkmail://pair.*"
|
||||
timeout: 10000
|
||||
- takeScreenshot: paste-link-filled
|
||||
- tapOn:
|
||||
text: "Sign in"
|
||||
below: "bulwarkmail://pair.*"
|
||||
@@ -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
|
||||
@@ -0,0 +1,87 @@
|
||||
# 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
|
||||
---
|
||||
- runFlow:
|
||||
when:
|
||||
true: ${!!Q}
|
||||
commands:
|
||||
- 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
|
||||
- takeScreenshot: settings-${NAME}
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Back"
|
||||
commands:
|
||||
- tapOn: "Back"
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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
|
||||
- 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.*"
|
||||
timeout: 10000
|
||||
# The dialog's button, not the Settings row of the same name behind it.
|
||||
- tapOn:
|
||||
text: "Sign out"
|
||||
rightOf: "Cancel"
|
||||
below: "The account is removed from this device.*"
|
||||
- takeScreenshot: sign-out-tapped
|
||||
@@ -0,0 +1,36 @@
|
||||
# Feature flows start here: the app signed in to ACCOUNT, on the inbox.
|
||||
# Keeps the app's data between flows; signs in only when needed.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- launchApp:
|
||||
permissions:
|
||||
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'}
|
||||
commands:
|
||||
- setOrientation: LANDSCAPE_LEFT
|
||||
- extendedWaitUntil:
|
||||
visible: "Enter server details manually|Inbox"
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Enter server details manually"
|
||||
commands:
|
||||
- runFlow:
|
||||
file: password-sign-in.yaml
|
||||
env:
|
||||
LOGIN: ${ACCOUNT}@example.org
|
||||
- runFlow: allow-notifications.yaml
|
||||
- tapOn:
|
||||
id: tab-mail
|
||||
- runFlow: go-to-inbox.yaml
|
||||
@@ -0,0 +1,28 @@
|
||||
# Stalwart's own sign-in page inside the auth session. env LOGIN, TOTP (any
|
||||
# non-empty value: the account needs a one-time code).
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: "you@example.com"
|
||||
timeout: 60000
|
||||
- takeScreenshot: stalwart-login-page
|
||||
- runFlow:
|
||||
file: type-web.yaml
|
||||
env:
|
||||
HINT: "you@example.com"
|
||||
TEXT: ${LOGIN}
|
||||
- tapOn: "••••••••"
|
||||
- inputText: ${PASSWORD}
|
||||
- runFlow:
|
||||
when:
|
||||
true: ${TOTP == 'yes'}
|
||||
commands:
|
||||
- runScript:
|
||||
file: ../scripts/helper.js
|
||||
env:
|
||||
CALL: /totp
|
||||
- tapOn: "000000"
|
||||
- inputText: ${output.helper.code}
|
||||
- tapOn:
|
||||
text: "Sign in"
|
||||
index: 1
|
||||
@@ -0,0 +1,27 @@
|
||||
# Fresh install state, rotated for the landscape leg, on the sign-in choices.
|
||||
# env CAMERA: allow (default) or deny.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
# A system prompt left over from an earlier flow ("Open in …?") stays on
|
||||
# screen across app launches.
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Open in .*"
|
||||
commands:
|
||||
- tapOn: "Cancel"
|
||||
- launchApp:
|
||||
clearState: true
|
||||
clearKeychain: true
|
||||
permissions:
|
||||
notifications: allow
|
||||
camera: ${CAMERA || 'allow'}
|
||||
photos: allow
|
||||
- runFlow:
|
||||
when:
|
||||
true: ${ORIENTATION == 'landscape'}
|
||||
commands:
|
||||
- setOrientation: LANDSCAPE_LEFT
|
||||
# A cold start of a release build on a CI simulator can be slow.
|
||||
- extendedWaitUntil:
|
||||
visible: "Enter server details manually"
|
||||
timeout: 120000
|
||||
@@ -0,0 +1,21 @@
|
||||
# The two-factor field is showing (it focuses itself): type a fresh code from
|
||||
# the helper and sign in.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: "Authentication code"
|
||||
timeout: 30000
|
||||
- runScript:
|
||||
file: ../scripts/helper.js
|
||||
env:
|
||||
CALL: /totp
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- inputText: ${output.helper.code}
|
||||
# The number pad has no return key.
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Sign in"
|
||||
commands:
|
||||
- hideKeyboard
|
||||
- tapOn: "Sign in"
|
||||
@@ -0,0 +1,22 @@
|
||||
# Type a secret into the field with id FIELD (TAP=no for a field that focuses
|
||||
# itself; see type.yaml for why the first character goes alone). The value
|
||||
# can't be checked (a secure field shows dots) and must never be shown, so a
|
||||
# lost keystroke surfaces as a failed sign-in.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- evalScript: ${output.secretHead = TEXT.slice(0, 1); output.secretTail = TEXT.slice(1)}
|
||||
- runFlow:
|
||||
when:
|
||||
true: ${TAP != 'no'}
|
||||
commands:
|
||||
- tapOn:
|
||||
id: ${FIELD}
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- inputText: ${output.secretHead}
|
||||
# A short pause: wait for something that never shows.
|
||||
- extendedWaitUntil:
|
||||
visible: "zz-no-such-element-zz"
|
||||
timeout: 1000
|
||||
optional: true
|
||||
- inputText: ${output.secretTail}
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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 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}
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- inputText: ${TEXT}
|
||||
- runFlow:
|
||||
when:
|
||||
true: ${SECRET != 'yes'}
|
||||
commands:
|
||||
- repeat:
|
||||
while:
|
||||
notVisible: ${TEXT}
|
||||
times: 2
|
||||
commands:
|
||||
- eraseText: 150
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 2000
|
||||
- inputText: ${TEXT}
|
||||
- assertVisible: ${TEXT}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Type TEXT into the field with id FIELD and check it arrived in full.
|
||||
#
|
||||
# iOS drops keystrokes in two situations seen on the CI simulator: while the
|
||||
# edit menu (the "AutoFill" bubble) opens, which happens when a flow taps a
|
||||
# field that already has focus, and right after the first character, while
|
||||
# the field re-renders. So: no tap for a field that focuses itself (TAP=no;
|
||||
# Maestro can't see a React Native field's focus), type the first character,
|
||||
# wait until it shows, then type the rest. Retyped up to twice.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- evalScript: ${output.typeHead = TEXT.slice(0, 1); output.typeTail = TEXT.slice(1)}
|
||||
- runFlow:
|
||||
when:
|
||||
true: ${TAP != 'no'}
|
||||
commands:
|
||||
- tapOn:
|
||||
id: ${FIELD}
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
# ERASE=yes: the field already holds text.
|
||||
- runFlow:
|
||||
when:
|
||||
true: ${ERASE == 'yes'}
|
||||
commands:
|
||||
- runFlow: clear-field.yaml
|
||||
- inputText: ${output.typeHead}
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: ${FIELD}
|
||||
text: "${output.typeHead}.*"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
- inputText: ${output.typeTail}
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible:
|
||||
id: ${FIELD}
|
||||
text: ${TEXT_PATTERN || TEXT}
|
||||
commands:
|
||||
- retry:
|
||||
maxRetries: 2
|
||||
commands:
|
||||
- runFlow: clear-field.yaml
|
||||
- inputText: ${output.typeHead}
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: ${FIELD}
|
||||
text: "${output.typeHead}.*"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
- inputText: ${output.typeTail}
|
||||
- assertVisible:
|
||||
id: ${FIELD}
|
||||
text: ${TEXT_PATTERN || TEXT}
|
||||
@@ -0,0 +1,41 @@
|
||||
# The Bulwark webmail's sign-in page inside the auth session (the app's
|
||||
# "Sign in via webmail" hand-off). env LOGIN, TOTP=yes for a TOTP account.
|
||||
appId: org.bulwarkmail.mobile
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: "user@example.com"
|
||||
timeout: 60000
|
||||
- takeScreenshot: webmail-login-page
|
||||
- runFlow:
|
||||
file: type-web.yaml
|
||||
env:
|
||||
HINT: "user@example.com"
|
||||
TEXT: ${LOGIN}
|
||||
- runFlow:
|
||||
file: type-web.yaml
|
||||
env:
|
||||
HINT: "Enter your password"
|
||||
TEXT: ${PASSWORD}
|
||||
SECRET: "yes"
|
||||
- tapOn: "Sign in"
|
||||
- runFlow:
|
||||
when:
|
||||
true: ${TOTP == 'yes'}
|
||||
commands:
|
||||
- extendedWaitUntil:
|
||||
visible: "000000"
|
||||
timeout: 30000
|
||||
- runScript:
|
||||
file: ../scripts/helper.js
|
||||
env:
|
||||
CALL: /totp
|
||||
- tapOn: "000000"
|
||||
- inputText: ${output.helper.code}
|
||||
- tapOn: "Sign in|Verify"
|
||||
|
||||
# Before handing over, the webmail asks whether the app may take the sign-in.
|
||||
- extendedWaitUntil:
|
||||
visible: "Hand over to the app"
|
||||
timeout: 60000
|
||||
- takeScreenshot: webmail-handover-prompt
|
||||
- tapOn: "Hand over to the app"
|
||||
@@ -94,6 +94,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 +262,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.
|
||||
@@ -297,6 +302,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
name="Mail"
|
||||
options={{
|
||||
title: tabLabels.mail,
|
||||
tabBarButtonTestID: 'tab-mail',
|
||||
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
|
||||
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
|
||||
tabBarBadgeStyle: {
|
||||
@@ -320,6 +326,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
component={CalendarScreen}
|
||||
options={{
|
||||
title: tabLabels.calendar,
|
||||
tabBarButtonTestID: 'tab-calendar',
|
||||
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
|
||||
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar),
|
||||
@@ -335,6 +342,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
component={ContactsTab}
|
||||
options={{
|
||||
title: tabLabels.contacts,
|
||||
tabBarButtonTestID: 'tab-contacts',
|
||||
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
||||
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts),
|
||||
@@ -350,6 +358,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
component={FilesScreen}
|
||||
options={{
|
||||
title: tabLabels.files,
|
||||
tabBarButtonTestID: 'tab-files',
|
||||
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
|
||||
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files),
|
||||
@@ -364,6 +373,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
name="Settings"
|
||||
options={{
|
||||
title: tabLabels.settings,
|
||||
tabBarButtonTestID: 'tab-settings',
|
||||
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -90,8 +90,8 @@ react {
|
||||
/* Variants */
|
||||
// The list of variants to that are debuggable. For those we're going to
|
||||
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||
// debuggableVariants = ["liteDebug", "prodDebug"]
|
||||
// The distribution flavors (github, play) need their debug variants listed.
|
||||
debuggableVariants = ["githubDebug", "playDebug"]
|
||||
|
||||
/* Bundling */
|
||||
// A list containing the node command and its flags. Default is just 'node'.
|
||||
@@ -154,13 +154,39 @@ android {
|
||||
versionCode appVersionCode.toInteger()
|
||||
versionName appVersionName
|
||||
|
||||
// Android account type of device sync (#34), derived from the package
|
||||
// name so a later rename needs no other change. The XML in res/xml and
|
||||
// the Kotlin in sync/ read it as @string/device_sync_account_type.
|
||||
resValue "string", "device_sync_account_type", "${applicationId}.account"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the build is distributed. The two are separate apps on a phone:
|
||||
*
|
||||
* - github: the APK on GitHub Releases, sideloaded, updated by the in-app
|
||||
* updater. Keeps the original package name so existing installs update.
|
||||
* - play: the Google Play bundle. Play forbids an app updating itself
|
||||
* outside Play, so this flavor has no installer permission and the JS
|
||||
* hides the updater (BuildConfig.DISTRIBUTION, lib/platform-capabilities).
|
||||
*
|
||||
* See docs/android-release.md.
|
||||
*/
|
||||
flavorDimensions = ["distribution"]
|
||||
productFlavors {
|
||||
github {
|
||||
dimension "distribution"
|
||||
applicationId 'com.anonymous.bulwarkmobile'
|
||||
buildConfigField "String", "DISTRIBUTION", '"github"'
|
||||
}
|
||||
play {
|
||||
dimension "distribution"
|
||||
applicationId 'org.bulwarkmail.mobile'
|
||||
buildConfigField "String", "DISTRIBUTION", '"play"'
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
// Android account type of device sync (#34), derived from the package
|
||||
// name of each flavor. The XML in res/xml and the Kotlin in sync/ read
|
||||
// it as @string/device_sync_account_type.
|
||||
variant.resValue "string", "device_sync_account_type", "${variant.applicationId}.account"
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file('debug.keystore')
|
||||
@@ -245,7 +271,7 @@ def checkReleaseSigning = tasks.register('checkReleaseSigning') {
|
||||
}
|
||||
}
|
||||
tasks.configureEach { task ->
|
||||
if (task.name == 'preReleaseBuild') {
|
||||
if (task.name ==~ /pre\w*ReleaseBuild/) {
|
||||
task.dependsOn(checkReleaseSigning)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "504486613721",
|
||||
"project_id": "bulwark-mobile",
|
||||
"storage_bucket": "bulwark-mobile.firebasestorage.app"
|
||||
"project_number": "752109678859",
|
||||
"project_id": "bulwark-20c72",
|
||||
"storage_bucket": "bulwark-20c72.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:504486613721:android:69a5d7ba9148fb14cb24c8",
|
||||
"mobilesdk_app_id": "1:752109678859:android:952e8373ad508e780cf20e",
|
||||
"android_client_info": {
|
||||
"package_name": "com.anonymous.bulwarkmobile"
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
|
||||
"current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
@@ -26,15 +26,15 @@
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:504486613721:android:14c6a69aa49cac3ecb24c8",
|
||||
"mobilesdk_app_id": "1:752109678859:android:2692d26569e5d9950cf20e",
|
||||
"android_client_info": {
|
||||
"package_name": "com.bulwark.bulwarkmobile"
|
||||
"package_name": "org.bulwarkmail.mobile"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
|
||||
"current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The in-app updater hands the downloaded release APK to the system
|
||||
installer (src/lib/install-update.ts). Only the sideloaded GitHub build
|
||||
updates itself; the Play build is updated by Play. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "504486613721",
|
||||
"project_id": "bulwark-mobile",
|
||||
"storage_bucket": "bulwark-mobile.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:504486613721:android:69a5d7ba9148fb14cb24c8",
|
||||
"android_client_info": {
|
||||
"package_name": "com.anonymous.bulwarkmobile"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:504486613721:android:14c6a69aa49cac3ecb24c8",
|
||||
"android_client_info": {
|
||||
"package_name": "com.bulwark.bulwarkmobile"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -2,9 +2,13 @@
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<!-- REQUEST_INSTALL_PACKAGES (in-app updater) is declared by the github flavor only. -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<!-- Photos are picked with the system photo picker, which needs no media
|
||||
permission; Play's photo and video policy rejects READ_MEDIA_IMAGES for that. -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
|
||||
<!-- expo-camera declares the microphone for video; QR scanning never records audio. -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<!-- Device sync (#34): contacts and calendars in Android's providers, driven by the sync framework. -->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
@@ -110,224 +114,224 @@
|
||||
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_inboxwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TriageWidget" android:exported="false" android:label="@string/widgetprovider_triagewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_triagewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.UnreadCountWidget" android:exported="false" android:label="@string/widgetprovider_unreadcountwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_unreadcountwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.LatestMessageWidget" android:exported="false" android:label="@string/widgetprovider_latestmessagewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_latestmessagewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.FolderCountsWidget" android:exported="false" android:label="@string/widgetprovider_foldercountswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_foldercountswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MailShortcutsWidget" android:exported="false" android:label="@string/widgetprovider_mailshortcutswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailshortcutswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AllAccountsWidget" android:exported="false" android:label="@string/widgetprovider_allaccountswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_allaccountswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.StarredWidget" android:exported="false" android:label="@string/widgetprovider_starredwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_starredwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.FavouritePeopleWidget" android:exported="false" android:label="@string/widgetprovider_favouritepeoplewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_favouritepeoplewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MailSearchWidget" android:exported="false" android:label="@string/widgetprovider_mailsearchwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailsearchwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.OutboxWidget" android:exported="false" android:label="@string/widgetprovider_outboxwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_outboxwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TagWidget" android:exported="false" android:label="@string/widgetprovider_tagwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_tagwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.LastDraftWidget" android:exported="false" android:label="@string/widgetprovider_lastdraftwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_lastdraftwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AgendaWidget" android:exported="false" android:label="@string/widgetprovider_agendawidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_agendawidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.WeekWidget" android:exported="false" android:label="@string/widgetprovider_weekwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_weekwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MonthWidget" android:exported="false" android:label="@string/widgetprovider_monthwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_monthwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TodayEventsWidget" android:exported="false" android:label="@string/widgetprovider_todayeventswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayeventswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.NextEventWidget" android:exported="false" android:label="@string/widgetprovider_nexteventwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nexteventwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.DateNextWidget" android:exported="false" android:label="@string/widgetprovider_datenextwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_datenextwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.UpNextWidget" android:exported="false" android:label="@string/widgetprovider_upnextwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_upnextwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.InvitationsWidget" android:exported="false" android:label="@string/widgetprovider_invitationswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_invitationswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.FreeTimeWidget" android:exported="false" android:label="@string/widgetprovider_freetimewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_freetimewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TasksWidget" android:exported="false" android:label="@string/widgetprovider_taskswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TaskProgressWidget" android:exported="false" android:label="@string/widgetprovider_taskprogresswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskprogresswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.CountdownWidget" android:exported="false" android:label="@string/widgetprovider_countdownwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_countdownwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.BirthdaysWidget" android:exported="false" android:label="@string/widgetprovider_birthdayswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_birthdayswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TodayHubWidget" android:exported="false" android:label="@string/widgetprovider_todayhubwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayhubwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MailAndNextWidget" android:exported="false" android:label="@string/widgetprovider_mailandnextwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailandnextwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.RecentFilesWidget" android:exported="false" android:label="@string/widgetprovider_recentfileswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_recentfileswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AttachmentsWidget" android:exported="false" android:label="@string/widgetprovider_attachmentswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_attachmentswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.VacationWidget" android:exported="false" android:label="@string/widgetprovider_vacationwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_vacationwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.StorageWidget" android:exported="false" android:label="@string/widgetprovider_storagewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_storagewidget"/>
|
||||
</receiver>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
|
||||
/**
|
||||
* Facts about this build that JS can't see on its own. `distribution` is the
|
||||
* product flavor (app/build.gradle): "github" for the sideloaded APK with its
|
||||
* own updater, "play" for the Google Play bundle, which must not update
|
||||
* itself (lib/platform-capabilities).
|
||||
*/
|
||||
class BulwarkBuildModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
override fun getName() = "BulwarkBuild"
|
||||
|
||||
override fun getConstants(): Map<String, Any> = mapOf(
|
||||
"distribution" to BuildConfig.DISTRIBUTION,
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class BulwarkFcmPackage : ReactPackage {
|
||||
add(BulwarkDeviceSyncModule(reactContext))
|
||||
add(BulwarkWidgetsModule(reactContext))
|
||||
add(BulwarkPdfModule(reactContext))
|
||||
add(BulwarkBuildModule(reactContext))
|
||||
// The bridge registers React Native's own module under this name.
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
||||
|
||||
@@ -22,6 +22,13 @@ COMMIT = COMMIT.slice(0, 7);
|
||||
// fall back to 1 (never uploaded).
|
||||
const IOS_BUILD_NUMBER = process.env.IOS_BUILD_NUMBER || '1';
|
||||
|
||||
// Apple team: prefixes the keychain groups the app shares with its
|
||||
// notification service extension (plugins/ios-push). Not a secret.
|
||||
const APPLE_TEAM_ID = process.env.APPLE_TEAM_ID || '8ZANF9LQ39';
|
||||
// Which APNs host the device tokens belong to: TestFlight and App Store builds
|
||||
// use production, simulators and development builds the sandbox.
|
||||
const APS_ENVIRONMENT = process.env.APS_ENVIRONMENT === 'development' ? 'development' : 'production';
|
||||
|
||||
module.exports = {
|
||||
expo: {
|
||||
name: 'Bulwark Mobile',
|
||||
@@ -96,12 +103,17 @@ module.exports = {
|
||||
microphonePermission: false,
|
||||
},
|
||||
],
|
||||
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
|
||||
],
|
||||
extra: {
|
||||
commit: COMMIT,
|
||||
// Settings → About → Source code. A modified build sets SOURCE_CODE_URL
|
||||
// to where its own source is (AGPL §13); the default is upstream.
|
||||
sourceCodeUrl: process.env.SOURCE_CODE_URL || 'https://github.com/bulwarkmail/native',
|
||||
// src/lib/ios-push-shared.ts
|
||||
iosSharedKeychainGroup: `${APPLE_TEAM_ID}.org.bulwarkmail.shared`,
|
||||
iosAppKeychainGroup: `${APPLE_TEAM_ID}.org.bulwarkmail.mobile`,
|
||||
apnsEnvironment: APS_ENVIRONMENT === 'development' ? 'sandbox' : 'production',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+43
-3
@@ -3,7 +3,8 @@
|
||||
The release APK is built by
|
||||
[.github/workflows/release-android.yml](../.github/workflows/release-android.yml)
|
||||
on an Ubuntu runner whenever a GitHub release is published, and attached to that
|
||||
release. Installed apps pick it up through the in-app updater.
|
||||
release. Installed apps pick it up through the in-app updater. The same workflow
|
||||
builds the Google Play bundle; see [Google Play](#google-play).
|
||||
|
||||
Every APK up to and including 0.1.62 was signed with `android/app/debug.keystore`,
|
||||
the debug key that ships with the React Native template. Its password is
|
||||
@@ -199,6 +200,44 @@ Once you delete it, every APK is signed with the release key alone:
|
||||
the update. Those users have to uninstall, reinstall and sign in again, and
|
||||
they lose local data. Afterwards their devices trust only the release key.
|
||||
|
||||
## Google Play
|
||||
|
||||
The app has two product flavors (`android/app/build.gradle`). They install
|
||||
side by side as separate apps:
|
||||
|
||||
| Flavor | Package | Output | Updated by | Firebase project |
|
||||
|---|---|---|---|---|
|
||||
| `github` | `com.anonymous.bulwarkmobile` | APK on GitHub Releases | in-app updater | `bulwark-mobile` (`src/github/google-services.json`) |
|
||||
| `play` | `org.bulwarkmail.mobile` | AAB for Google Play | Google Play | `bulwark-20c72` (`google-services.json`) |
|
||||
|
||||
Play forbids an app updating itself outside Play, so the `play` flavor has no
|
||||
`REQUEST_INSTALL_PACKAGES` (only `src/github/AndroidManifest.xml` declares it)
|
||||
and `BuildConfig.DISTRIBUTION` is `"play"`, which the `BulwarkBuild` native
|
||||
module hands to JS. `supportsSideloadUpdates` in `src/lib/platform-capabilities.ts`
|
||||
is false there, which hides the updater. Neither flavor declares
|
||||
`READ_MEDIA_IMAGES`: photos come from the system photo picker, and Play's photo
|
||||
and video policy rejects the permission for that use. `RECORD_AUDIO`, which
|
||||
expo-camera adds, is removed as well: QR scanning never records audio.
|
||||
|
||||
Play App Signing holds the key that signs what Play delivers (generated by
|
||||
Google, certificate SHA-256 `7e:e1:fd:35:…:91:fa:e3`). The workflow signs the
|
||||
bundle with our release key, which Play registered as the upload key with the
|
||||
first upload. The two flavors therefore have different signers too.
|
||||
|
||||
The `play-bundle` job builds `bundlePlayRelease` for arm64-v8a, armeabi-v7a and
|
||||
x86_64 (Play splits the bundle per ABI), checks the signer, the package name and
|
||||
the permissions, and keeps the AAB and its R8 mapping as a workflow artifact.
|
||||
With the `PLAY_SERVICE_ACCOUNT_JSON` secret set (vault: "Google Play – service
|
||||
account bulwark-google-play-api") it also uploads the bundle and rolls it out
|
||||
on the internal and closed (beta) testing tracks, so testers get every release
|
||||
without a step in the Play Console; closed testing may wait for Google's
|
||||
review. Play only accepts API uploads once a first bundle has been uploaded by
|
||||
hand in the Play Console.
|
||||
|
||||
The `github` flavor stays on the old Firebase project until the app registers
|
||||
its new FCM token with the relay when the token changes; moving it before that
|
||||
would silence push for every updated install.
|
||||
|
||||
## Local release builds
|
||||
|
||||
CI signs releases, so you normally don't need the release key on your machine.
|
||||
@@ -206,7 +245,8 @@ For a local release build, for profiling or checking R8, opt in to the debug key
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleRelease -PallowDebugSignedRelease=true
|
||||
./gradlew assembleGithubRelease -PallowDebugSignedRelease=true
|
||||
./gradlew bundlePlayRelease -PallowDebugSignedRelease=true
|
||||
```
|
||||
|
||||
```powershell
|
||||
@@ -252,7 +292,7 @@ retrace bulwark-mobile-0.1.63-abc1234-mapping.txt stacktrace.txt
|
||||
|
||||
`retrace` is in the Android SDK command-line tools (`cmdline-tools/latest/bin`).
|
||||
A local build writes its mapping to
|
||||
`android/app/build/outputs/mapping/release/mapping.txt`.
|
||||
`android/app/build/outputs/mapping/<flavor>Release/mapping.txt`.
|
||||
|
||||
## versionCode
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -639,6 +639,12 @@
|
||||
"up_to_date": "Already up to date"
|
||||
}
|
||||
},
|
||||
"push": {
|
||||
"ios": {
|
||||
"more_messages": "and {count} more",
|
||||
"no_subject": "(no subject)"
|
||||
}
|
||||
},
|
||||
"push_prompt": {
|
||||
"body": "Turn on background notifications so new messages reach you while the app is closed.",
|
||||
"dismiss": "Not now",
|
||||
@@ -1008,6 +1014,8 @@
|
||||
"confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.",
|
||||
"description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
|
||||
"err_android_only": "Push notifications are only available on Android.",
|
||||
"err_apns_empty": "iOS returned an empty push token.",
|
||||
"err_apns_token": "iOS could not issue a push token ({detail}).",
|
||||
"err_fcm_empty": "Firebase returned an empty device token.",
|
||||
"err_fcm_token": "Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device.",
|
||||
"err_fcm_token_up": "Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device. This device has a UnifiedPush distributor installed - switch the delivery method to UnifiedPush instead.",
|
||||
@@ -1015,6 +1023,7 @@
|
||||
"err_no_account": "No account loaded - cannot set up push.",
|
||||
"err_permission": "Notification permission was not granted.",
|
||||
"err_relay_https": "The relay URL must use https://.",
|
||||
"err_relay_no_apns": "The push relay at {url} does not deliver to iPhones and iPads yet.",
|
||||
"err_relay_rejected": "The push relay rejected the registration: {detail}",
|
||||
"err_relay_unreachable": "Could not reach the push relay at {url} ({detail}).",
|
||||
"err_up_android_only": "UnifiedPush is only available on Android.",
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
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;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.83",
|
||||
"version": "0.1.86",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.83",
|
||||
"version": "0.1.86",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.83",
|
||||
"version": "0.1.86",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import Foundation
|
||||
|
||||
/// The little JMAP the extension needs: who sent the new messages and what
|
||||
/// they are about, plus the inbox's unread count for the badge.
|
||||
struct PushedEmail {
|
||||
let id: String
|
||||
let threadId: String
|
||||
let sender: String
|
||||
let subject: String
|
||||
let preview: String
|
||||
}
|
||||
|
||||
struct JmapFetchResult {
|
||||
let emails: [PushedEmail]
|
||||
let inboxUnread: Int?
|
||||
}
|
||||
|
||||
enum JmapError: Error {
|
||||
case noCredentials
|
||||
case http(Int)
|
||||
case badResponse
|
||||
}
|
||||
|
||||
/// Credentials as the app stores them (src/api/jmap-client.ts StoredCredentials).
|
||||
struct StoredCredentials {
|
||||
var json: [String: Any]
|
||||
|
||||
var username: String { json["username"] as? String ?? "" }
|
||||
var password: String { json["password"] as? String ?? "" }
|
||||
var accessToken: String? { json["accessToken"] as? String }
|
||||
var refreshToken: String? { json["refreshToken"] as? String }
|
||||
var tokenEndpoint: String? { json["tokenEndpoint"] as? String }
|
||||
var clientId: String? { json["clientId"] as? String }
|
||||
/// Milliseconds since 1970, like Date.now() in the app.
|
||||
var expiresAt: Double? { (json["expiresAt"] as? NSNumber)?.doubleValue }
|
||||
}
|
||||
|
||||
enum Jmap {
|
||||
static let session: URLSession = {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 12
|
||||
config.timeoutIntervalForResource = 20
|
||||
return URLSession(configuration: config)
|
||||
}()
|
||||
|
||||
/// The Authorization header for these credentials, refreshing an OAuth
|
||||
/// access token that is about to expire. A refreshed bundle is written back
|
||||
/// to the shared keychain item so the app picks it up (and doesn't refresh
|
||||
/// again with a refresh token the server has already replaced).
|
||||
static func authorization(credentialsKey: String) async throws -> String {
|
||||
guard let json = SharedKeychain.readJSON(credentialsKey) else { throw JmapError.noCredentials }
|
||||
var creds = StoredCredentials(json: json)
|
||||
if let token = creds.accessToken {
|
||||
let nowMs = Date().timeIntervalSince1970 * 1000
|
||||
if let expiresAt = creds.expiresAt, expiresAt - nowMs < 60_000,
|
||||
let refresh = creds.refreshToken, let endpoint = creds.tokenEndpoint, let clientId = creds.clientId,
|
||||
let url = URL(string: endpoint) {
|
||||
if let fresh = try? await refreshToken(url: url, refreshToken: refresh, clientId: clientId) {
|
||||
creds.json["accessToken"] = fresh.accessToken
|
||||
if let newRefresh = fresh.refreshToken { creds.json["refreshToken"] = newRefresh }
|
||||
if let expiresIn = fresh.expiresIn { creds.json["expiresAt"] = nowMs + expiresIn * 1000 }
|
||||
SharedKeychain.update(credentialsKey, json: creds.json)
|
||||
return "Bearer \(fresh.accessToken)"
|
||||
}
|
||||
}
|
||||
return "Bearer \(token)"
|
||||
}
|
||||
let basic = Data("\(creds.username):\(creds.password)".utf8).base64EncodedString()
|
||||
return "Basic \(basic)"
|
||||
}
|
||||
|
||||
struct FreshTokens {
|
||||
let accessToken: String
|
||||
let refreshToken: String?
|
||||
let expiresIn: Double?
|
||||
}
|
||||
|
||||
static func refreshToken(url: URL, refreshToken: String, clientId: String) async throws -> FreshTokens {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
var form = URLComponents()
|
||||
form.queryItems = [
|
||||
URLQueryItem(name: "grant_type", value: "refresh_token"),
|
||||
URLQueryItem(name: "refresh_token", value: refreshToken),
|
||||
URLQueryItem(name: "client_id", value: clientId),
|
||||
]
|
||||
request.httpBody = form.percentEncodedQuery?.data(using: .utf8)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse }
|
||||
guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) }
|
||||
guard let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||
let access = body["access_token"] as? String else { throw JmapError.badResponse }
|
||||
return FreshTokens(
|
||||
accessToken: access,
|
||||
refreshToken: body["refresh_token"] as? String,
|
||||
expiresIn: (body["expires_in"] as? NSNumber)?.doubleValue
|
||||
)
|
||||
}
|
||||
|
||||
static func fetch(apiUrl: URL, accountId: String, emailIds: [String], authorization: String) async throws -> JmapFetchResult {
|
||||
var request = URLRequest(url: apiUrl)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue(authorization, forHTTPHeaderField: "Authorization")
|
||||
let body: [String: Any] = [
|
||||
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||
"methodCalls": [
|
||||
["Email/get", [
|
||||
"accountId": accountId,
|
||||
"ids": Array(emailIds.prefix(5)),
|
||||
"properties": ["threadId", "from", "subject", "preview"],
|
||||
], "0"],
|
||||
["Mailbox/query", ["accountId": accountId, "filter": ["role": "inbox"]], "1"],
|
||||
["Mailbox/get", [
|
||||
"accountId": accountId,
|
||||
"#ids": ["resultOf": "1", "name": "Mailbox/query", "path": "/ids"],
|
||||
"properties": ["unreadEmails"],
|
||||
], "2"],
|
||||
],
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse }
|
||||
guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) }
|
||||
guard let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||
let responses = root["methodResponses"] as? [[Any]] else { throw JmapError.badResponse }
|
||||
|
||||
var emails: [PushedEmail] = []
|
||||
var inboxUnread: Int?
|
||||
for response in responses where response.count >= 2 {
|
||||
guard let name = response[0] as? String, let args = response[1] as? [String: Any] else { continue }
|
||||
if name == "Email/get", let list = args["list"] as? [[String: Any]] {
|
||||
// Keep the order the push gave (newest first from the server's filter).
|
||||
// Stalwart can list an id twice; keep the first.
|
||||
let byId = Dictionary(list.compactMap { item -> (String, [String: Any])? in
|
||||
guard let id = item["id"] as? String else { return nil }
|
||||
return (id, item)
|
||||
}, uniquingKeysWith: { first, _ in first })
|
||||
for id in emailIds {
|
||||
guard let item = byId[id] else { continue }
|
||||
let from = (item["from"] as? [[String: Any]])?.first
|
||||
let name = (from?["name"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||
let sender = name ?? (from?["email"] as? String) ?? ""
|
||||
emails.append(PushedEmail(
|
||||
id: id,
|
||||
threadId: item["threadId"] as? String ?? "",
|
||||
sender: sender,
|
||||
subject: item["subject"] as? String ?? "",
|
||||
preview: item["preview"] as? String ?? ""
|
||||
))
|
||||
}
|
||||
} else if name == "Mailbox/get", let list = args["list"] as? [[String: Any]] {
|
||||
inboxUnread = (list.first?["unreadEmails"] as? NSNumber)?.intValue
|
||||
}
|
||||
}
|
||||
return JmapFetchResult(emails: emails, inboxUnread: inboxUnread)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
import os
|
||||
import UserNotifications
|
||||
|
||||
/// Ids and steps only, never credentials or message content.
|
||||
let pushLog = Logger(subsystem: "org.bulwarkmail.mobile.NotificationService", category: "push")
|
||||
|
||||
/// Turns the relay's content-blind push ("New message" plus ids) into a real
|
||||
/// notification: sender, subject and preview, fetched over JMAP on this
|
||||
/// device with the account's own credentials. Nothing about the message ever
|
||||
/// passes through the relay or Apple.
|
||||
///
|
||||
/// What the app shares through the keychain group (src/lib/ios-push-shared.ts):
|
||||
/// bulwark_push_account__<JMAP account id> {credentialsKey, apiUrl}
|
||||
/// bulwark_push_settings {badge, activeAccountId, strings{...}}
|
||||
/// <credentialsKey> the account's stored credentials
|
||||
/// If anything is missing or the server can't be reached, the relay's
|
||||
/// placeholder text is shown, so the user still learns there is new mail.
|
||||
final class NotificationService: UNNotificationServiceExtension {
|
||||
private var contentHandler: ((UNNotificationContent) -> Void)?
|
||||
private var placeholder: UNMutableNotificationContent?
|
||||
private var work: Task<Void, Never>?
|
||||
|
||||
override func didReceive(
|
||||
_ request: UNNotificationRequest,
|
||||
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
||||
) {
|
||||
self.contentHandler = contentHandler
|
||||
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
placeholder = content
|
||||
let push = request.content.userInfo["body"] as? [String: Any]
|
||||
work = Task { [weak self] in
|
||||
let enriched = await NotificationService.enrich(content, push: push)
|
||||
self?.finish(enriched)
|
||||
}
|
||||
}
|
||||
|
||||
override func serviceExtensionTimeWillExpire() {
|
||||
work?.cancel()
|
||||
finish(placeholder)
|
||||
}
|
||||
|
||||
private func finish(_ content: UNNotificationContent?) {
|
||||
guard let handler = contentHandler, let content else { return }
|
||||
contentHandler = nil
|
||||
handler(content)
|
||||
}
|
||||
|
||||
static func sanitize(_ id: String) -> String {
|
||||
String(id.map { ch in (ch.isASCII && (ch.isLetter || ch.isNumber)) || ch == "." || ch == "-" || ch == "_" ? ch : "_" })
|
||||
}
|
||||
|
||||
static func enrich(_ content: UNMutableNotificationContent, push: [String: Any]?) async -> UNMutableNotificationContent {
|
||||
guard let push, let accountId = push["accountId"] as? String, !accountId.isEmpty else {
|
||||
pushLog.error("push without an account id")
|
||||
return content
|
||||
}
|
||||
pushLog.info("push for account \(accountId, privacy: .public), group \(SharedKeychain.group ?? "none", privacy: .public)")
|
||||
guard let account = SharedKeychain.readJSON("bulwark_push_account__\(sanitize(accountId))"),
|
||||
let credentialsKey = account["credentialsKey"] as? String,
|
||||
let apiString = account["apiUrl"] as? String,
|
||||
let apiUrl = URL(string: apiString) else {
|
||||
pushLog.error("no shared account entry for \(accountId, privacy: .public)")
|
||||
return content
|
||||
}
|
||||
let emailIds = push["emailIds"] as? [String] ?? []
|
||||
let settings = SharedKeychain.readJSON("bulwark_push_settings") ?? [:]
|
||||
let strings = settings["strings"] as? [String: String] ?? [:]
|
||||
|
||||
content.threadIdentifier = accountId
|
||||
// The app reads userInfo.body when the notification is tapped.
|
||||
var body = push
|
||||
|
||||
do {
|
||||
let authorization = try await Jmap.authorization(credentialsKey: credentialsKey)
|
||||
let result = try await Jmap.fetch(apiUrl: apiUrl, accountId: accountId, emailIds: emailIds, authorization: authorization)
|
||||
pushLog.info("fetched \(result.emails.count, privacy: .public) of \(emailIds.count, privacy: .public) messages")
|
||||
|
||||
if let first = result.emails.first {
|
||||
content.title = first.sender
|
||||
content.subtitle = first.subject.isEmpty ? (strings["noSubject"] ?? "(no subject)") : first.subject
|
||||
content.body = first.preview
|
||||
if result.emails.count > 1 {
|
||||
let more = (strings["more"] ?? "and {count} more").replacingOccurrences(of: "{count}", with: String(result.emails.count - 1))
|
||||
content.body = first.preview.isEmpty ? more : "\(first.preview)\n\(more)"
|
||||
}
|
||||
body["emailId"] = first.id
|
||||
body["threadId"] = first.threadId
|
||||
}
|
||||
|
||||
// Badge: like the app, the active account's inbox unread count. A push
|
||||
// for another account leaves the badge as it is.
|
||||
if settings["badge"] as? Bool == true,
|
||||
settings["activeAccountId"] as? String == accountId,
|
||||
let unread = result.inboxUnread {
|
||||
content.badge = NSNumber(value: unread)
|
||||
}
|
||||
} catch {
|
||||
// Keep the placeholder.
|
||||
pushLog.error("fetch failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
var userInfo = content.userInfo
|
||||
userInfo["body"] = body
|
||||
content.userInfo = userInfo
|
||||
return content
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Items the app shares with this extension through a keychain access group
|
||||
/// (BulwarkKeychainGroup in Info.plist). The app writes them with
|
||||
/// expo-secure-store: generic passwords whose account and generic attributes
|
||||
/// are the UTF-8 key and whose service is "app:no-auth".
|
||||
enum SharedKeychain {
|
||||
static let service = "app:no-auth"
|
||||
|
||||
static var group: String? {
|
||||
Bundle.main.object(forInfoDictionaryKey: "BulwarkKeychainGroup") as? String
|
||||
}
|
||||
|
||||
static func read(_ key: String) -> Data? {
|
||||
guard let group else { return nil }
|
||||
let account = Data(key.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: group,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard status == errSecSuccess else {
|
||||
pushLog.error("keychain read of \(key, privacy: .public) failed: \(status, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
return item as? Data
|
||||
}
|
||||
|
||||
static func readJSON(_ key: String) -> [String: Any]? {
|
||||
guard let data = read(key) else { return nil }
|
||||
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
||||
}
|
||||
|
||||
/// Replaces the value of an existing item (refreshed OAuth tokens). Never
|
||||
/// creates one: the app owns which items exist.
|
||||
@discardableResult
|
||||
static func update(_ key: String, json: [String: Any]) -> Bool {
|
||||
guard let group, let data = try? JSONSerialization.data(withJSONObject: json) else { return false }
|
||||
let account = Data(key.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: group,
|
||||
]
|
||||
let attributes: [String: Any] = [kSecValueData as String: data]
|
||||
return SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecSuccess
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Config plugin: push notifications on iOS.
|
||||
//
|
||||
// - The app's entitlements: aps-environment, and a keychain group shared with
|
||||
// the notification service extension (the app's own group stays first, so
|
||||
// items saved before keep their group).
|
||||
// - A Notification Service Extension target ("NotificationService",
|
||||
// org.bulwarkmail.mobile.NotificationService). The relay's push carries
|
||||
// only ids; the extension fetches sender and subject over JMAP on the device
|
||||
// before iOS shows the notification. Its sources live next to this file and
|
||||
// are copied into ios/ at prebuild, like everything else in ios/.
|
||||
//
|
||||
// Options (app.config.js):
|
||||
// apsEnvironment: 'production' | 'development' (APS_ENVIRONMENT env wins)
|
||||
// teamId: Apple team id, for the shared keychain group
|
||||
// extensionProfile / appProfile: provisioning profile specifiers for the
|
||||
// Release configuration of the extension and the app (IOS_NSE_PROFILE and
|
||||
// IOS_APP_PROFILE env win). Set per target because xcodebuild's command
|
||||
// line applies a specifier to every target. Empty = leave unset.
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
withEntitlementsPlist,
|
||||
withXcodeProject,
|
||||
withDangerousMod,
|
||||
createRunOncePlugin,
|
||||
} = require('@expo/config-plugins');
|
||||
|
||||
const TARGET = 'NotificationService';
|
||||
const DEPLOYMENT_TARGET = '15.1';
|
||||
const SOURCE_FILES = ['NotificationService.swift', 'SharedKeychain.swift', 'Jmap.swift'];
|
||||
|
||||
function sharedGroupSuffix() {
|
||||
return 'org.bulwarkmail.shared';
|
||||
}
|
||||
|
||||
function withAppEntitlements(config, { apsEnvironment }) {
|
||||
return withEntitlementsPlist(config, (cfg) => {
|
||||
const bundleId = cfg.ios?.bundleIdentifier;
|
||||
cfg.modResults['aps-environment'] = apsEnvironment;
|
||||
const groups = new Set(cfg.modResults['keychain-access-groups'] ?? []);
|
||||
groups.add(`$(AppIdentifierPrefix)${bundleId}`);
|
||||
groups.add(`$(AppIdentifierPrefix)${sharedGroupSuffix()}`);
|
||||
cfg.modResults['keychain-access-groups'] = [...groups];
|
||||
return cfg;
|
||||
});
|
||||
}
|
||||
|
||||
function infoPlist({ version, buildNumber }) {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Bulwark Mail notifications</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${version}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${buildNumber}</string>
|
||||
<key>BulwarkKeychainGroup</key>
|
||||
<string>$(DEVELOPMENT_TEAM).${sharedGroupSuffix()}</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.usernotifications.service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
function entitlementsPlist() {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)${sharedGroupSuffix()}</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
function withExtensionFiles(config) {
|
||||
return withDangerousMod(config, [
|
||||
'ios',
|
||||
async (cfg) => {
|
||||
const dir = path.join(cfg.modRequest.platformProjectRoot, TARGET);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const file of SOURCE_FILES) {
|
||||
fs.copyFileSync(path.join(__dirname, file), path.join(dir, file));
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(dir, `${TARGET}-Info.plist`),
|
||||
infoPlist({ version: cfg.version, buildNumber: cfg.ios?.buildNumber ?? '1' }),
|
||||
);
|
||||
fs.writeFileSync(path.join(dir, `${TARGET}.entitlements`), entitlementsPlist());
|
||||
return cfg;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function setReleaseProfiles(cfg, { appProfile, extensionProfile }) {
|
||||
if (!appProfile && !extensionProfile) return;
|
||||
const project = cfg.modResults;
|
||||
const appName = `"${cfg.modRequest.projectName}"`;
|
||||
const configs = project.pbxXCBuildConfigurationSection();
|
||||
for (const key of Object.keys(configs)) {
|
||||
const entry = configs[key];
|
||||
if (!entry.buildSettings || entry.name !== 'Release') continue;
|
||||
const name = entry.buildSettings.PRODUCT_NAME;
|
||||
const profile = name === `"${TARGET}"` ? extensionProfile
|
||||
: (name === appName || name === '"$(TARGET_NAME)"') && entry.buildSettings.PRODUCT_BUNDLE_IDENTIFIER ? appProfile
|
||||
: '';
|
||||
if (profile) entry.buildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${profile}"`;
|
||||
}
|
||||
}
|
||||
|
||||
// One mod for creating the target and naming the profiles: separate
|
||||
// withXcodeProject mods don't run in the order they are registered.
|
||||
function withExtensionTarget(config, { teamId, appProfile, extensionProfile }) {
|
||||
return withXcodeProject(config, (cfg) => {
|
||||
addExtensionTarget(cfg, { teamId });
|
||||
setReleaseProfiles(cfg, { appProfile, extensionProfile });
|
||||
return cfg;
|
||||
});
|
||||
}
|
||||
|
||||
function addExtensionTarget(cfg, { teamId }) {
|
||||
const project = cfg.modResults;
|
||||
if (project.pbxTargetByName(TARGET)) return;
|
||||
|
||||
// addTarget expects these sections; a fresh Expo project may not have them.
|
||||
const objects = project.hash.project.objects;
|
||||
objects.PBXTargetDependency = objects.PBXTargetDependency || {};
|
||||
objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {};
|
||||
|
||||
const group = project.addPbxGroup(
|
||||
[...SOURCE_FILES, `${TARGET}-Info.plist`, `${TARGET}.entitlements`],
|
||||
TARGET,
|
||||
TARGET,
|
||||
);
|
||||
// Hang the group off the project's main group.
|
||||
const mainGroupId = project.getFirstProject().firstProject.mainGroup;
|
||||
project.addToPbxGroup(group.uuid, mainGroupId);
|
||||
|
||||
const bundleId = `${cfg.ios.bundleIdentifier}.${TARGET}`;
|
||||
const target = project.addTarget(TARGET, 'app_extension', TARGET, bundleId);
|
||||
project.addBuildPhase(SOURCE_FILES, 'PBXSourcesBuildPhase', 'Sources', target.uuid);
|
||||
project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid);
|
||||
project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid);
|
||||
|
||||
const configs = project.pbxXCBuildConfigurationSection();
|
||||
for (const key of Object.keys(configs)) {
|
||||
const entry = configs[key];
|
||||
if (!entry.buildSettings || entry.buildSettings.PRODUCT_NAME !== `"${TARGET}"`) continue;
|
||||
const s = entry.buildSettings;
|
||||
s.CODE_SIGN_ENTITLEMENTS = `${TARGET}/${TARGET}.entitlements`;
|
||||
s.IPHONEOS_DEPLOYMENT_TARGET = DEPLOYMENT_TARGET;
|
||||
s.TARGETED_DEVICE_FAMILY = '"1,2"';
|
||||
s.SWIFT_VERSION = '5.0';
|
||||
s.CLANG_ENABLE_MODULES = 'YES';
|
||||
s.GENERATE_INFOPLIST_FILE = 'NO';
|
||||
if (teamId) s.DEVELOPMENT_TEAM = teamId;
|
||||
}
|
||||
}
|
||||
|
||||
function withIosPush(config, options = {}) {
|
||||
const apsEnvironment = process.env.APS_ENVIRONMENT || options.apsEnvironment || 'production';
|
||||
const teamId = process.env.APPLE_TEAM_ID || options.teamId || '';
|
||||
const extensionProfile = process.env.IOS_NSE_PROFILE || options.extensionProfile || '';
|
||||
const appProfile = process.env.IOS_APP_PROFILE || options.appProfile || '';
|
||||
config = withAppEntitlements(config, { apsEnvironment });
|
||||
config = withExtensionFiles(config);
|
||||
config = withExtensionTarget(config, { teamId, appProfile, extensionProfile });
|
||||
return config;
|
||||
}
|
||||
|
||||
module.exports = createRunOncePlugin(withIosPush, 'bulwark-ios-push', '1.0.0');
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# Boot a fresh simulator for one test leg and install the app.
|
||||
#
|
||||
# scripts/e2e/boot-sim.sh <phone|tablet> <light|dark> <app path>
|
||||
#
|
||||
# Writes UDID and SIM_NAME to $GITHUB_ENV. Every simulator gets the keyboard
|
||||
# set up for XCTest typing: a fresh one shows the slide-to-type tip over the
|
||||
# keyboard the first time a field gets focus, and predictions, autocorrect and
|
||||
# password AutoFill can swallow or rewrite typed text.
|
||||
set -euo pipefail
|
||||
DEVICE=$1
|
||||
APPEARANCE=$2
|
||||
APP=$3
|
||||
|
||||
case "$DEVICE" in
|
||||
phone) PATTERN='^iPhone' ;;
|
||||
tablet) PATTERN='^iPad Pro 11|^iPad Air 11|^iPad \(' ;;
|
||||
*) echo "unknown device $DEVICE" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# The newest matching device type on the newest iOS runtime the image ships.
|
||||
RUNTIME=$(xcrun simctl list runtimes available -j | jq -r '[.runtimes[] | select(.platform == "iOS")] | sort_by(.version | split(".") | map(tonumber)) | last | .identifier')
|
||||
UDID=$(xcrun simctl list devices available -j | jq -r --arg rt "$RUNTIME" --arg re "$PATTERN" '
|
||||
[.devices[$rt][] | select(.name | test($re))] | last | .udid // empty')
|
||||
if [ -z "$UDID" ]; then
|
||||
xcrun simctl list devices available
|
||||
echo "::error::No $DEVICE simulator on $RUNTIME"
|
||||
exit 1
|
||||
fi
|
||||
NAME=$(xcrun simctl list devices -j | jq -r --arg u "$UDID" '.devices[][] | select(.udid == $u) | .name')
|
||||
echo "Using $NAME ($UDID) on $RUNTIME"
|
||||
|
||||
xcrun simctl boot "$UDID"
|
||||
xcrun simctl bootstatus "$UDID" -b
|
||||
|
||||
PREFS=com.apple.keyboard.preferences
|
||||
xcrun simctl spawn "$UDID" defaults write $PREFS DidShowContinuousPathIntroduction -bool true
|
||||
xcrun simctl spawn "$UDID" defaults write $PREFS KeyboardContinuousPathEnabled -bool false
|
||||
xcrun simctl spawn "$UDID" defaults write $PREFS KeyboardPrediction -bool false
|
||||
xcrun simctl spawn "$UDID" defaults write $PREFS KeyboardAutocorrection -bool false
|
||||
xcrun simctl spawn "$UDID" defaults write $PREFS KeyboardAutocapitalization -bool false
|
||||
xcrun simctl spawn "$UDID" defaults write $PREFS KeyboardShowPredictionBar -bool false
|
||||
xcrun simctl spawn "$UDID" defaults write com.apple.WebUI AutoFillPasswords -bool false || true
|
||||
|
||||
xcrun simctl ui "$UDID" appearance "$APPEARANCE"
|
||||
xcrun simctl install "$UDID" "$APP"
|
||||
# A photo for the compose flow's attachment.
|
||||
xcrun simctl addmedia "$UDID" "$(dirname "$0")/../../assets/icon.png" || true
|
||||
|
||||
{
|
||||
echo "UDID=$UDID"
|
||||
echo "SIM_NAME=$NAME"
|
||||
} >> "${GITHUB_ENV:-/dev/null}"
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
// Summarise Maestro's debug output (~/.maestro/tests) without its secrets.
|
||||
//
|
||||
// The JSON there records every command with its arguments, so typed
|
||||
// passwords are in it; it is never uploaded. This prints, per flow, the
|
||||
// command types and their status (no arguments), and for a failed command
|
||||
// the element tree Maestro saw (labels, ids and bounds), which is what a
|
||||
// failing selector needs. run-flows.sh scrubs the secret values from the
|
||||
// output as well.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = process.argv[2];
|
||||
const out = [];
|
||||
|
||||
function walkFiles(dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = path.join(dir, e.name);
|
||||
return e.isDirectory() ? walkFiles(p) : [p];
|
||||
});
|
||||
}
|
||||
|
||||
function tree(node, depth, lines) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
const a = node.attributes || {};
|
||||
const label = [a.accessibilityText, a.text, a.hintText, a['resource-id'] && `#${a['resource-id']}`]
|
||||
.filter(Boolean)
|
||||
.filter((v, i, all) => all.indexOf(v) === i)
|
||||
.join(' | ');
|
||||
if (label) lines.push(`${' '.repeat(Math.min(depth, 12))}${label} ${a.bounds || ''}${a.focused === 'true' ? ' [focused]' : ''}`);
|
||||
for (const c of node.children || []) tree(c, depth + 1, lines);
|
||||
}
|
||||
|
||||
function findHierarchy(obj, seen = new Set()) {
|
||||
if (!obj || typeof obj !== 'object' || seen.has(obj)) return null;
|
||||
seen.add(obj);
|
||||
if (obj.attributes && Array.isArray(obj.children)) return obj;
|
||||
for (const v of Object.values(obj)) {
|
||||
const h = findHierarchy(v, seen);
|
||||
if (h) return h;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const file of walkFiles(root).filter((f) => /commands-.*\.json$/.test(f))) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
out.push(`== ${path.basename(file)}`);
|
||||
for (const entry of Array.isArray(data) ? data : []) {
|
||||
const command = entry.command || {};
|
||||
const type = Object.keys(command).find((k) => command[k]) || '?';
|
||||
const status = entry.metadata?.status || '?';
|
||||
out.push(` ${status.padEnd(9)} ${type}`);
|
||||
if (status === 'FAILED') {
|
||||
const err = entry.metadata?.error?.message;
|
||||
if (err) out.push(` error: ${String(err).slice(0, 300)}`);
|
||||
const h = findHierarchy(entry.metadata);
|
||||
if (h) {
|
||||
const lines = [];
|
||||
tree(h, 0, lines);
|
||||
out.push(...lines.slice(0, 250).map((l) => ` ${l}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Maestro 2 keeps the screen hierarchy of each failed step under
|
||||
// screen-hierarchy/; it holds what the screen showed (like the screenshot).
|
||||
for (const file of walkFiles(root).filter((f) => f.includes('screen-hierarchy') && f.endsWith('.json'))) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const h = findHierarchy(data);
|
||||
if (!h) continue;
|
||||
const parts = file.split(path.sep).join('/').split('/');
|
||||
out.push(`== ${parts[parts.length - 3]} / ${parts[parts.length - 1]}`);
|
||||
const lines = [];
|
||||
tree(h, 0, lines);
|
||||
out.push(...lines.slice(0, 300).map((l) => ` ${l}`));
|
||||
}
|
||||
process.stdout.write(out.join('\n') + '\n');
|
||||
@@ -0,0 +1,439 @@
|
||||
#!/usr/bin/env node
|
||||
// Test helper for the iOS simulator flows (.maestro/). Runs on the CI runner
|
||||
// next to the simulator; Maestro flows call it from runScript over
|
||||
// http://127.0.0.1:8787 for things a flow can't do itself:
|
||||
//
|
||||
// GET /totp current TOTP code of the e2e-totp-* accounts
|
||||
// GET /pair?user=&deliver= sign in to the test webmail with Playwright,
|
||||
// create a fresh "Link mobile app" code and hand
|
||||
// its bulwarkmail://pair link to the app
|
||||
// (deliver=openurl: simctl openurl, as a link
|
||||
// from another app; pbcopy: simctl pbcopy for
|
||||
// "Paste sign-in link"; none: keep it only)
|
||||
// GET /redeliver?user=&deliver= hand the last link for that user over again
|
||||
// GET /pair-later?user=&deliver=&delay= create a code now, deliver it after
|
||||
// <delay> seconds (expired-code test)
|
||||
// GET /send?to=&subject=&attach=1&html=1 userb sends a message over JMAP
|
||||
// GET /find?subject=&as=userb&timeout= wait until a message with that subject
|
||||
// arrives; answers {found, attachments}
|
||||
// GET /simpush?to=&subject= userb sends a message to `to`; then the
|
||||
// relay's APNs payload for it (ids only) is
|
||||
// pushed to the simulator with simctl push,
|
||||
// so the app's notification extension runs
|
||||
// GET /openurl?url= simctl openurl for other links (mailto:, ...)
|
||||
// GET /clipboard?text= simctl pbcopy of a plain value
|
||||
//
|
||||
// Secrets come from the environment and never appear in a response: the
|
||||
// pairing link is delivered to the simulator directly, not returned. The
|
||||
// answers are JSON. Logs go to stdout without secrets or links.
|
||||
//
|
||||
// Env: E2E_SERVER (JMAP server), E2E_PASSWORD (shared by the e2e accounts),
|
||||
// E2E_USERB_PASSWORD, E2E_TOTP_SECRET (base32), E2E_WEBMAIL (test webmail base URL), UDID
|
||||
// (booted simulator), PLAYWRIGHT_DIR (node_modules folder with playwright).
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import http from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
const PORT = Number(process.env.HELPER_PORT || 8787);
|
||||
const SERVER = (process.env.E2E_SERVER || '').replace(/\/+$/, '');
|
||||
const WEBMAIL = (process.env.E2E_WEBMAIL || 'https://bulwark.internal.bulwarkmail.org').replace(/\/+$/, '');
|
||||
const PASSWORD = process.env.E2E_PASSWORD || '';
|
||||
const TOTP_SECRET = process.env.E2E_TOTP_SECRET || '';
|
||||
const UDID = process.env.UDID || 'booted';
|
||||
const DOMAIN = 'example.org';
|
||||
|
||||
const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a);
|
||||
|
||||
// ── TOTP (RFC 6238, SHA-1, 6 digits, 30 s) ──────────────────────────────────
|
||||
|
||||
function base32Decode(s) {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const out = [];
|
||||
for (const ch of s.replace(/=+$/, '').toUpperCase()) {
|
||||
const idx = alphabet.indexOf(ch);
|
||||
if (idx < 0) continue;
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((value >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
function totpAt(secret, time) {
|
||||
const counter = Math.floor(time / 30);
|
||||
const buf = Buffer.alloc(8);
|
||||
buf.writeBigUInt64BE(BigInt(counter));
|
||||
const mac = crypto.createHmac('sha1', base32Decode(secret)).update(buf).digest();
|
||||
const offset = mac[mac.length - 1] & 0xf;
|
||||
const code = (mac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
|
||||
return String(code).padStart(6, '0');
|
||||
}
|
||||
|
||||
// A code with at least `minLeft` seconds to live, waiting for the next window
|
||||
// when the current one is about to close.
|
||||
async function freshTotp(minLeft = 10) {
|
||||
if (!TOTP_SECRET) throw new Error('E2E_TOTP_SECRET is not set');
|
||||
const now = Date.now() / 1000;
|
||||
const left = 30 - (now % 30);
|
||||
if (left < minLeft) await sleep((left + 0.5) * 1000);
|
||||
return totpAt(TOTP_SECRET, Date.now() / 1000);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ── simulator ───────────────────────────────────────────────────────────────
|
||||
|
||||
function simctl(args, input) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = execFile('xcrun', ['simctl', ...args], { timeout: 30_000 }, (err, stdout, stderr) => {
|
||||
if (err) reject(new Error(`simctl ${args[0]} failed: ${stderr || err.message}`));
|
||||
else resolve(stdout);
|
||||
});
|
||||
if (input !== undefined) {
|
||||
child.stdin.end(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 pbcopy(link);
|
||||
else if (how !== 'none') throw new Error(`unknown deliver=${how}`);
|
||||
}
|
||||
|
||||
// ── webmail pairing (Playwright) ────────────────────────────────────────────
|
||||
|
||||
let chromium = null;
|
||||
function loadPlaywright() {
|
||||
if (chromium) return chromium;
|
||||
const dir = process.env.PLAYWRIGHT_DIR;
|
||||
const req = createRequire(dir ? path.join(dir, 'noop.js') : import.meta.url);
|
||||
({ chromium } = req('playwright'));
|
||||
return chromium;
|
||||
}
|
||||
|
||||
const lastLinks = new Map();
|
||||
|
||||
async function createPairingLink(user) {
|
||||
const browser = await loadPlaywright().launch();
|
||||
const started = Date.now();
|
||||
let page = null;
|
||||
try {
|
||||
const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1280, height: 900 } });
|
||||
page = await context.newPage();
|
||||
await page.goto(`${WEBMAIL}/en/login`, { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('#username').fill(`${user}@${DOMAIN}`);
|
||||
await page.locator('#password').fill(PASSWORD);
|
||||
// Keep the session across page loads (a Basic session without "remember
|
||||
// me" is signed out by the next navigation).
|
||||
const remember = page.getByRole('checkbox', { name: /remember/i });
|
||||
if (await remember.count()) await remember.first().check().catch(() => undefined);
|
||||
const totpField = page.locator('#totp');
|
||||
if (await totpField.isVisible().catch(() => false)) await totpField.fill(await freshTotp());
|
||||
await page.locator('button[type=submit]').first().click();
|
||||
|
||||
// A TOTP account: the webmail asks for the code after the password.
|
||||
const signedIn = page.waitForURL((u) => !/\/login/.test(u.pathname), { timeout: 30_000 }).then(() => 'in');
|
||||
const askedCode = totpField.waitFor({ state: 'visible', timeout: 30_000 }).then(() => 'totp');
|
||||
const first = await Promise.race([signedIn, askedCode]);
|
||||
if (first === 'totp' && /\/login/.test(new URL(page.url()).pathname)) {
|
||||
await totpField.fill(await freshTotp());
|
||||
await page.locator('button[type=submit]').first().click();
|
||||
await page.waitForURL((u) => !/\/login/.test(u.pathname), { timeout: 30_000 });
|
||||
}
|
||||
|
||||
await page.goto(`${WEBMAIL}/en/settings/security`, { waitUntil: 'domcontentloaded' });
|
||||
const show = page.getByRole('button', { name: /Show QR code/i });
|
||||
await show.waitFor({ timeout: 30_000 });
|
||||
await show.click();
|
||||
|
||||
// Step-up: the password again (and a code for a TOTP account).
|
||||
const linkInput = page.locator('input[readonly][value^="bulwarkmail://"]');
|
||||
// The Security page has other password forms; use the link-device one.
|
||||
const form = page.locator('form', { hasText: /confirm it's you/i });
|
||||
const pw = form.locator('input[type=password]').first();
|
||||
await Promise.race([
|
||||
linkInput.waitFor({ timeout: 30_000 }),
|
||||
pw.waitFor({ timeout: 30_000 }),
|
||||
]);
|
||||
if (!(await linkInput.count())) {
|
||||
await pw.fill(PASSWORD);
|
||||
// For a TOTP account the code field is there from the start.
|
||||
const codeField = form.locator('input:not([type=password])');
|
||||
if (await codeField.count()) await codeField.last().fill(await freshTotp());
|
||||
await form.getByRole('button', { name: /^Continue$/ }).click();
|
||||
const codeAsked = page.getByText(/code from your authenticator app/i);
|
||||
await Promise.race([
|
||||
linkInput.waitFor({ timeout: 30_000 }),
|
||||
codeAsked.waitFor({ timeout: 30_000 }),
|
||||
]);
|
||||
if (!(await linkInput.count())) {
|
||||
if (!(await pw.inputValue())) await pw.fill(PASSWORD);
|
||||
await form.locator('input:not([type=password])').last().fill(await freshTotp());
|
||||
await form.getByRole('button', { name: /^Continue$/ }).click();
|
||||
await linkInput.waitFor({ timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
const link = await linkInput.inputValue();
|
||||
if (!link.startsWith('bulwarkmail://pair?')) throw new Error('no pairing link on the page');
|
||||
log(`pair: code for ${user} after ${Date.now() - started} ms`);
|
||||
return link;
|
||||
} catch (err) {
|
||||
// What the webmail showed, for the run's artifacts (passwords are masked
|
||||
// on the page; a TOTP code is dead in 30 s).
|
||||
if (page && process.env.HELPER_SHOTS) {
|
||||
await page.screenshot({ path: path.join(process.env.HELPER_SHOTS, `pair-${user}-${Date.now()}.png`), fullPage: true }).catch(() => undefined);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── JMAP as userb (or any e2e account) ─────────────────────────────────────
|
||||
|
||||
const sessions = new Map();
|
||||
async function jmapSession(user) {
|
||||
if (sessions.has(user)) return sessions.get(user);
|
||||
const pass = user === 'userb' && process.env.E2E_USERB_PASSWORD ? process.env.E2E_USERB_PASSWORD : PASSWORD;
|
||||
const auth = 'Basic ' + Buffer.from(`${user}@${DOMAIN}:${pass}`).toString('base64');
|
||||
const res = await fetch(`${SERVER}/.well-known/jmap`, { headers: { Authorization: auth } });
|
||||
if (!res.ok) throw new Error(`session for ${user}: HTTP ${res.status}`);
|
||||
const session = await res.json();
|
||||
const s = {
|
||||
auth,
|
||||
apiUrl: session.apiUrl,
|
||||
uploadUrl: session.uploadUrl,
|
||||
accountId: session.primaryAccounts['urn:ietf:params:jmap:mail'],
|
||||
};
|
||||
sessions.set(user, s);
|
||||
return s;
|
||||
}
|
||||
|
||||
async function jmap(user, calls) {
|
||||
const s = await jmapSession(user);
|
||||
const res = await fetch(s.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: s.auth, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:submission'],
|
||||
methodCalls: calls,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`JMAP as ${user}: HTTP ${res.status}`);
|
||||
return (await res.json()).methodResponses;
|
||||
}
|
||||
|
||||
async function upload(user, data, type) {
|
||||
const s = await jmapSession(user);
|
||||
const res = await fetch(s.uploadUrl.replace('{accountId}', s.accountId), {
|
||||
method: 'POST',
|
||||
headers: { Authorization: s.auth, 'Content-Type': type },
|
||||
body: data,
|
||||
});
|
||||
if (!res.ok) throw new Error(`upload as ${user}: HTTP ${res.status}`);
|
||||
return (await res.json()).blobId;
|
||||
}
|
||||
|
||||
async function sendMail({ from = 'userb', to, subject, text, html, attach }) {
|
||||
const s = await jmapSession(from);
|
||||
const [[, boxes], [, ids]] = await jmap(from, [
|
||||
['Mailbox/get', { accountId: s.accountId, properties: ['role'] }, '0'],
|
||||
['Identity/get', { accountId: s.accountId }, '1'],
|
||||
]);
|
||||
const drafts = boxes.list.find((m) => m.role === 'drafts')?.id;
|
||||
const sent = boxes.list.find((m) => m.role === 'sent')?.id;
|
||||
const identity = ids.list.find((i) => i.email === `${from}@${DOMAIN}`) ?? ids.list[0];
|
||||
const email = {
|
||||
mailboxIds: { [drafts]: true },
|
||||
keywords: { $draft: true },
|
||||
from: [{ name: identity.name || from, email: `${from}@${DOMAIN}` }],
|
||||
to: [{ email: to.includes('@') ? to : `${to}@${DOMAIN}` }],
|
||||
subject,
|
||||
bodyValues: { t: { value: text || `Test message "${subject}" from the iOS simulator tests.\n` } },
|
||||
textBody: [{ partId: 't', type: 'text/plain' }],
|
||||
};
|
||||
if (html) {
|
||||
email.bodyValues.h = { value: `<html><body><h1 style="color:#2563eb">${subject}</h1><p>An <b>HTML</b> test message with a <a href="https://bulwarkmail.org">link</a>.</p><table border="1"><tr><td>cell 1</td><td>cell 2</td></tr></table></body></html>` };
|
||||
email.htmlBody = [{ partId: 'h', type: 'text/html' }];
|
||||
}
|
||||
if (attach) {
|
||||
const pdf = Buffer.from(
|
||||
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj ' +
|
||||
'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 300 144]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj ' +
|
||||
'4 0 obj<</Length 44>>stream\nBT /F1 18 Tf 20 70 Td (E2E attachment) Tj ET\nendstream endobj ' +
|
||||
'5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n',
|
||||
);
|
||||
const blobId = await upload(from, pdf, 'application/pdf');
|
||||
email.attachments = [{ blobId, type: 'application/pdf', name: 'e2e-attachment.pdf', disposition: 'attachment' }];
|
||||
}
|
||||
const resp = await jmap(from, [
|
||||
['Email/set', { accountId: s.accountId, create: { m: email } }, '0'],
|
||||
['EmailSubmission/set', {
|
||||
accountId: s.accountId,
|
||||
create: { s: { identityId: identity.id, emailId: '#m' } },
|
||||
onSuccessUpdateEmail: { '#s': { [`mailboxIds/${drafts}`]: null, [`mailboxIds/${sent}`]: true, 'keywords/$draft': null } },
|
||||
}, '1'],
|
||||
]);
|
||||
const created = resp.find(([name, args]) => name === 'EmailSubmission/set' && args.created?.s);
|
||||
if (!created) throw new Error(`send failed: ${JSON.stringify(resp).slice(0, 400)}`);
|
||||
log(`send: ${from} -> ${to} "${subject}"`);
|
||||
}
|
||||
|
||||
async function findMail({ as = 'userb', subject, timeout = 30 }) {
|
||||
const s = await jmapSession(as);
|
||||
const deadline = Date.now() + timeout * 1000;
|
||||
// Stalwart's subject filter finds nothing for "Re: x" or "Fwd: x" (it
|
||||
// indexes the base subject), so search without the prefixes and compare
|
||||
// the full subject below.
|
||||
const base = subject.replace(/^((re|fwd?|aw|wg)\s*:\s*)+/i, '');
|
||||
for (;;) {
|
||||
const [[, q], [, got]] = await jmap(as, [
|
||||
['Email/query', { accountId: s.accountId, filter: { subject: base }, sort: [{ property: 'receivedAt', isAscending: false }], limit: 10 }, '0'],
|
||||
['Email/get', { accountId: s.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['subject', 'attachments', 'mailboxIds', 'from'] }, '1'],
|
||||
]);
|
||||
const hit = got.list.find((m) => m.subject === subject);
|
||||
if (hit) {
|
||||
return {
|
||||
found: true,
|
||||
id: hit.id,
|
||||
accountId: s.accountId,
|
||||
attachments: (hit.attachments || []).map((a) => a.name),
|
||||
from: hit.from?.[0]?.email ?? null,
|
||||
};
|
||||
}
|
||||
if (Date.now() > deadline) return { found: false, total: q.ids.length };
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ── server ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const routes = {
|
||||
async '/health'() {
|
||||
return { ok: true };
|
||||
},
|
||||
async '/totp'(q) {
|
||||
return { code: await freshTotp(Number(q.get('minLeft') || 10)) };
|
||||
},
|
||||
async '/pair'(q) {
|
||||
const user = q.get('user');
|
||||
const how = q.get('deliver') || 'openurl';
|
||||
const link = await createPairingLink(user);
|
||||
lastLinks.set(user, link);
|
||||
await deliver(link, how);
|
||||
return { ok: true };
|
||||
},
|
||||
async '/redeliver'(q) {
|
||||
const link = lastLinks.get(q.get('user'));
|
||||
if (!link) throw new Error('no link for that user yet');
|
||||
await deliver(link, q.get('deliver') || 'openurl');
|
||||
return { ok: true };
|
||||
},
|
||||
async '/pair-later'(q) {
|
||||
const user = q.get('user');
|
||||
const how = q.get('deliver') || 'openurl';
|
||||
const delay = Number(q.get('delay') || 130);
|
||||
const link = await createPairingLink(user);
|
||||
lastLinks.set(user, link);
|
||||
setTimeout(() => {
|
||||
deliver(link, how).then(() => log(`pair-later: delivered after ${delay}s`), (e) => log('pair-later:', e.message));
|
||||
}, delay * 1000);
|
||||
return { ok: true, deliverIn: delay };
|
||||
},
|
||||
async '/send'(q) {
|
||||
await sendMail({
|
||||
from: q.get('from') || 'userb',
|
||||
to: q.get('to'),
|
||||
subject: q.get('subject'),
|
||||
text: q.get('text') || undefined,
|
||||
html: q.get('html') === '1',
|
||||
attach: q.get('attach') === '1',
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
async '/find'(q) {
|
||||
return findMail({ as: q.get('as') || 'userb', subject: q.get('subject'), timeout: Number(q.get('timeout') || 30) });
|
||||
},
|
||||
async '/simpush'(q) {
|
||||
const to = q.get('to');
|
||||
const subject = q.get('subject');
|
||||
await sendMail({ from: 'userb', to, subject, attach: false, html: false });
|
||||
const found = await findMail({ as: to, subject, timeout: 60 });
|
||||
if (!found.found) throw new Error('the message did not arrive');
|
||||
// The same payload the relay sends (relay/src/apns.ts apnsPayload).
|
||||
const payload = {
|
||||
aps: {
|
||||
alert: { title: 'Bulwark Mail', body: 'New message' },
|
||||
sound: 'default',
|
||||
'mutable-content': 1,
|
||||
'thread-id': found.accountId,
|
||||
},
|
||||
body: {
|
||||
kind: 'jmap-email-push',
|
||||
accountLabel: `${to}@${DOMAIN}`,
|
||||
accountId: found.accountId,
|
||||
emailIds: [found.id],
|
||||
changed: {},
|
||||
},
|
||||
};
|
||||
const file = path.join(os.tmpdir(), `push-${Date.now()}.json`);
|
||||
fs.writeFileSync(file, JSON.stringify(payload));
|
||||
await simctl(['push', UDID, 'org.bulwarkmail.mobile', file]);
|
||||
log(`simpush: ${to} "${subject}"`);
|
||||
return { ok: true, emailId: found.id };
|
||||
},
|
||||
async '/openurl'(q) {
|
||||
await simctl(['openurl', UDID, q.get('url')]);
|
||||
return { ok: true };
|
||||
},
|
||||
async '/clipboard'(q) {
|
||||
await pbcopy(q.get('text') || '');
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const route = routes[url.pathname];
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (!route) {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: 'not found' }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await route(url.searchParams);
|
||||
log(`${url.pathname} ok`);
|
||||
res.end(JSON.stringify(body));
|
||||
} catch (err) {
|
||||
log(`${url.pathname} failed: ${err.message}`);
|
||||
res.statusCode = 500;
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
}).listen(PORT, '127.0.0.1', () => log(`e2e helper on :${PORT}`));
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env node
|
||||
// Performance harness for the iOS simulator (Part 2 of docs/ios-test-2026-10.md).
|
||||
//
|
||||
// node scripts/e2e/perf.mjs --app base=<path.app> --app cand=<path.app> \
|
||||
// --rounds 5 --cold 3 --flows .maestro/perf/1-open-message.yaml,... --out <dir>
|
||||
//
|
||||
// The app logs `[perf] {...}` lines when launched with `-perfMarks YES`
|
||||
// (src/lib/perf-marks.ts); they reach the simulator's system log at info
|
||||
// level. This script streams that log, and per round and app:
|
||||
// 1. installs the app over the previous one (data and keychain stay, so it
|
||||
// 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). 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.
|
||||
// Apps alternate inside every round, so both sides of a comparison see the
|
||||
// same runner, the same simulator and the same server load.
|
||||
//
|
||||
// Output: <out>/perf.json (every sample) and <out>/perf.md (medians).
|
||||
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (name, fallback) => {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
return i >= 0 ? args[i + 1] : fallback;
|
||||
};
|
||||
const apps = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--app') {
|
||||
const [label, p] = args[i + 1].split('=');
|
||||
apps.push({ label, path: p });
|
||||
}
|
||||
}
|
||||
const UDID = process.env.UDID;
|
||||
const BUNDLE = 'org.bulwarkmail.mobile';
|
||||
const rounds = Number(opt('rounds', 5));
|
||||
const coldRuns = Number(opt('cold', 3));
|
||||
const flows = (opt('flows', '') || '').split(',').filter(Boolean);
|
||||
const out = opt('out', 'perf-output');
|
||||
const maestroEnv = (opt('env', '') || '').split(',').filter(Boolean);
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
|
||||
if (!UDID || apps.length === 0) {
|
||||
console.error('need UDID and at least one --app label=path');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const simctl = (...a) => execFileSync('xcrun', ['simctl', ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
// ── log stream ──────────────────────────────────────────────────────────────
|
||||
const marks = []; // { n, t, ...detail, label }
|
||||
let currentLabel = null;
|
||||
const waiters = [];
|
||||
const stream = spawn('xcrun', [
|
||||
'simctl', 'spawn', UDID, 'log', 'stream', '--level', 'info', '--style', 'ndjson',
|
||||
'--predicate', 'subsystem == "com.facebook.react.log" AND eventMessage CONTAINS "[perf]"',
|
||||
]);
|
||||
let buf = '';
|
||||
stream.stdout.on('data', (chunk) => {
|
||||
buf += chunk;
|
||||
let nl;
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl);
|
||||
buf = buf.slice(nl + 1);
|
||||
let ev;
|
||||
try { ev = JSON.parse(line); } catch { continue; }
|
||||
const msg = String(ev.eventMessage || '');
|
||||
const at = msg.indexOf('[perf] ');
|
||||
if (at < 0) continue;
|
||||
// The message is the console line, possibly quoted by RCTLog.
|
||||
const json = msg.slice(at + 7).replace(/^'|'$/g, '');
|
||||
let mark;
|
||||
try { mark = JSON.parse(json); } catch { continue; }
|
||||
mark.label = currentLabel;
|
||||
marks.push(mark);
|
||||
for (const w of [...waiters]) {
|
||||
if (w.name === mark.n) {
|
||||
waiters.splice(waiters.indexOf(w), 1);
|
||||
w.resolve(mark);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function waitForMark(name, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const w = { name, resolve };
|
||||
waiters.push(w);
|
||||
setTimeout(() => {
|
||||
const i = waiters.indexOf(w);
|
||||
if (i >= 0) { waiters.splice(i, 1); resolve(null); }
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
// ── steps ───────────────────────────────────────────────────────────────────
|
||||
const samples = []; // { label, metric, ms, round }
|
||||
const moduleRuns = {}; // label -> [{ total, top: { id: ms } }] (module-times marks)
|
||||
|
||||
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]);
|
||||
if (!mark) {
|
||||
console.log(` ${label} cold start: no inbox-rendered mark within 120 s`);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// All interaction flows in one Maestro process: every process start brings
|
||||
// the XCTest driver up again (30-60 s). A failing flow doesn't stop the
|
||||
// others; a hung driver is killed after FLOW_CAP_MS.
|
||||
const FLOW_CAP_MS = 12 * 60 * 1000;
|
||||
function runFlows(label) {
|
||||
const before = marks.length;
|
||||
const env = maestroEnv.flatMap((kv) => ['-e', kv]);
|
||||
const res = spawn('maestro', ['--device', UDID, 'test', ...env, ...flows], { stdio: 'inherit' });
|
||||
const cap = setTimeout(() => res.kill('SIGKILL'), FLOW_CAP_MS);
|
||||
return new Promise((resolve) => {
|
||||
res.on('exit', (code) => {
|
||||
clearTimeout(cap);
|
||||
const got = marks.slice(before).filter((m) => typeof m.ms === 'number');
|
||||
console.log(` ${label} interactions: exit ${code}, ${got.length} timed marks`);
|
||||
resolve(got);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function writeReport(final = false) {
|
||||
|
||||
const median = (xs) => {
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
const m = Math.floor(s.length / 2);
|
||||
return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
|
||||
};
|
||||
const metrics = [...new Set(samples.map((s) => s.metric))].sort();
|
||||
const labels = apps.map((a) => a.label);
|
||||
const lines = [
|
||||
`| metric | ${labels.map((l) => `${l} median (n)`).join(' | ')} |${labels.length === 2 ? ' change |' : ''}`,
|
||||
`|---|${labels.map(() => '---').join('|')}|${labels.length === 2 ? '---|' : ''}`,
|
||||
];
|
||||
for (const metric of metrics) {
|
||||
const cells = labels.map((l) => {
|
||||
const xs = samples.filter((s) => s.label === l && s.metric === metric).map((s) => s.ms);
|
||||
return xs.length ? { med: median(xs), n: xs.length } : null;
|
||||
});
|
||||
let change = '';
|
||||
if (labels.length === 2 && cells[0] && cells[1]) {
|
||||
change = ` ${Math.round(((cells[1].med - cells[0].med) / cells[0].med) * 100)}% |`;
|
||||
} else if (labels.length === 2) change = ' |';
|
||||
lines.push(`| ${metric} | ${cells.map((c) => (c ? `${c.med} ms (${c.n})` : '-')).join(' | ')} |${change}`);
|
||||
}
|
||||
const scroll = labels.map((l) => {
|
||||
const xs = samples.filter((s) => s.label === l && s.metric === 'list-scroll');
|
||||
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);
|
||||
// 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);
|
||||
if (final && process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## Performance\n\n${md}\n`);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await sleep(2000); // let the log stream attach
|
||||
for (let round = 1; round <= rounds; round++) {
|
||||
console.log(`round ${round}/${rounds}`);
|
||||
// Alternate the order too, so neither app always goes first.
|
||||
const order = round % 2 ? apps : [...apps].reverse();
|
||||
for (const app of order) {
|
||||
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, 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;
|
||||
samples.push({ label: app.label, round, metric, ms: m.ms, ...(m.long !== undefined ? { long: m.long, frames: m.frames, worst: m.worst } : {}) });
|
||||
}
|
||||
}
|
||||
// A job that runs out of time still leaves the rounds it finished.
|
||||
writeReport();
|
||||
}
|
||||
stream.kill();
|
||||
writeReport(true);
|
||||
process.exit(0);
|
||||
})();
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run one suite of Maestro flows against the booted simulator ($UDID).
|
||||
#
|
||||
# scripts/e2e/run-flows.sh <suite dir under .maestro> [flow names, comma separated]
|
||||
#
|
||||
# Needs in the environment: UDID, OUT (output folder), and for the flows the
|
||||
# E2E_* values (see .github/workflows/e2e-ios.yml). Output: $OUT/report.xml,
|
||||
# $OUT/screenshots, $OUT/run.mp4, $OUT/app.log, $OUT/steps.txt.
|
||||
#
|
||||
# Each flow runs in its own Maestro process with a time cap: when the XCTest
|
||||
# driver dies (it does, now and then), only that flow fails and the next one
|
||||
# starts a fresh driver.
|
||||
#
|
||||
# Secrets: Maestro's debug output holds every typed text, passwords included,
|
||||
# and artifacts of this public repo are public. Only the warm-up (which types
|
||||
# a made-up address) prints a debug log; the debug output of the real flows
|
||||
# stays on the runner, and only PNGs and an argument-free summary of the
|
||||
# steps (failed-steps.mjs) are copied. Every text file in $OUT is scrubbed of
|
||||
# the secret values before upload.
|
||||
set -uo pipefail
|
||||
SUITE=$1
|
||||
ONLY=${2:-}
|
||||
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
FLOW_TIMEOUT=${FLOW_TIMEOUT:-900}
|
||||
mkdir -p "$OUT/screenshots" "$OUT/junit"
|
||||
DEBUG_DIR="$RUNNER_TEMP/maestro-debug"
|
||||
mkdir -p "$DEBUG_DIR"
|
||||
|
||||
# perl's alarm stands in for coreutils timeout, which macOS lacks.
|
||||
capped() { perl -e 'alarm shift; exec @ARGV' "$FLOW_TIMEOUT" "$@"; }
|
||||
|
||||
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. 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::"
|
||||
done
|
||||
|
||||
if [ $STATUS -eq 0 ]; then
|
||||
ARGS=(
|
||||
-e "SERVER=$E2E_SERVER"
|
||||
-e "PASSWORD=$E2E_PASSWORD"
|
||||
-e "ACCOUNT=${E2E_ACCOUNT:-}"
|
||||
-e "ACCOUNT2=${E2E_ACCOUNT2:-}"
|
||||
-e "TOTP_ACCOUNT=${E2E_TOTP_ACCOUNT:-}"
|
||||
-e "WEBMAIL=${E2E_WEBMAIL:-}"
|
||||
-e "WEBMAIL_BETA=${E2E_WEBMAIL_BETA:-https://bulwark-beta.internal.bulwarkmail.org}"
|
||||
-e "HELPER=http://127.0.0.1:${HELPER_PORT:-8787}"
|
||||
-e "ORIENTATION=${ORIENTATION:-portrait}"
|
||||
-e "DEVICE=${DEVICE:-phone}"
|
||||
-e "APPEARANCE=${APPEARANCE:-light}"
|
||||
)
|
||||
FLOWS=()
|
||||
if [ -n "$ONLY" ]; then
|
||||
IFS=',' read -ra NAMES <<< "$ONLY"
|
||||
for n in "${NAMES[@]}"; do
|
||||
f="$ROOT/.maestro/$SUITE/${n%.yaml}.yaml"
|
||||
if [ -f "$f" ]; then FLOWS+=("$f"); else echo "::warning::no flow $SUITE/$n"; fi
|
||||
done
|
||||
else
|
||||
for f in "$ROOT/.maestro/$SUITE"/*.yaml; do FLOWS+=("$f"); done
|
||||
fi
|
||||
|
||||
FAILED=()
|
||||
for f in "${FLOWS[@]}"; do
|
||||
name=$(basename "$f" .yaml)
|
||||
started=$(date +%s)
|
||||
echo "::group::$name"
|
||||
(cd "$OUT/screenshots" && capped maestro --device "$UDID" test \
|
||||
--format junit --output "$OUT/junit/$name.xml" \
|
||||
--debug-output "$DEBUG_DIR/$name" \
|
||||
"${ARGS[@]}" "$f")
|
||||
rc=$?
|
||||
echo "::endgroup::"
|
||||
echo "$name: exit $rc after $(( $(date +%s) - started )) s"
|
||||
if [ $rc -ne 0 ]; then
|
||||
FAILED+=("$name")
|
||||
# What the screen showed when it failed.
|
||||
xcrun simctl io "$UDID" screenshot "$OUT/screenshots/FAILED-$name.png" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
echo "::error::Failed flows: ${FAILED[*]}"
|
||||
STATUS=1
|
||||
fi
|
||||
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
const dir = process.argv[1];
|
||||
const cases = fs.readdirSync(dir).filter((f) => f.endsWith(".xml")).sort()
|
||||
.map((f) => (fs.readFileSync(path.join(dir, f), "utf8").match(/<testcase[\s\S]*?<\/testcase>/g) || []).join("\n"));
|
||||
fs.writeFileSync(process.argv[2], `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites>\n<testsuite name="${process.argv[3]}">\n${cases.join("\n")}\n</testsuite>\n</testsuites>\n`);
|
||||
' "$OUT/junit" "$OUT/report.xml" "$SUITE" || true
|
||||
fi
|
||||
|
||||
kill -INT "$RECORDER" 2>/dev/null || true
|
||||
wait "$RECORDER" 2>/dev/null || true
|
||||
|
||||
# Images of failed steps, plus a summary of the steps without arguments.
|
||||
find "$DEBUG_DIR" "$HOME/.maestro/tests" -name '*.png' -exec cp {} "$OUT/screenshots/" \; 2>/dev/null || true
|
||||
node "$ROOT/scripts/e2e/failed-steps.mjs" "$DEBUG_DIR" > "$OUT/steps.txt" 2>&1 || true
|
||||
find "$DEBUG_DIR" -type f | sed "s|$DEBUG_DIR/||" > "$OUT/debug-files.txt" 2>/dev/null || true
|
||||
|
||||
# --info: console.log of the JS lands at info level.
|
||||
xcrun simctl spawn "$UDID" log show --last 90m --style compact --info \
|
||||
--predicate 'process == "BulwarkMobile" AND (subsystem == "com.facebook.react.log" OR messageType >= 16)' \
|
||||
> "$OUT/app.log" 2>&1 || true
|
||||
|
||||
# Push: what iOS did with the notification (the extension's own log, and
|
||||
# SpringBoard / usernotificationsd / apsd lines naming the app), plus the
|
||||
# notification settings the simulator stored for it.
|
||||
if [ "$SUITE" = push ]; then
|
||||
xcrun simctl spawn "$UDID" log show --last 90m --style compact --info --predicate \
|
||||
'process == "NotificationService" OR subsystem == "org.bulwarkmail.mobile.NotificationService" OR ((process == "SpringBoard" OR process CONTAINS "usernotification" OR process == "apsd") AND eventMessage CONTAINS[c] "bulwarkmail")' \
|
||||
> "$OUT/push.log" 2>&1 || true
|
||||
SIMDATA="$HOME/Library/Developer/CoreSimulator/Devices/$UDID/data"
|
||||
{
|
||||
find "$SIMDATA/Library" \( -path '*BulletinBoard*' -o -path '*UserNotifications*' \) -maxdepth 4 2>/dev/null | head -50
|
||||
for f in $(find "$SIMDATA/Library" \( -path '*BulletinBoard*' -o -path '*UserNotifications*' \) -name '*.plist' 2>/dev/null | head -20); do
|
||||
echo "== $f"
|
||||
plutil -p "$f" 2>/dev/null | grep -i -A12 bulwarkmail | head -60
|
||||
done
|
||||
echo "== app extension in the bundle"
|
||||
ls "$(xcrun simctl get_app_container "$UDID" org.bulwarkmail.mobile app 2>/dev/null)/PlugIns" 2>&1
|
||||
} > "$OUT/push-settings.txt" 2>&1 || true
|
||||
fi
|
||||
|
||||
# Scrub secrets from every text file that gets uploaded.
|
||||
find "$OUT" -type f \( -name '*.xml' -o -name '*.log' -o -name '*.json' -o -name '*.txt' \) -print0 |
|
||||
while IFS= read -r -d '' f; do
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const f = process.argv[1];
|
||||
let s = fs.readFileSync(f, "utf8");
|
||||
for (const k of ["E2E_PASSWORD", "E2E_USERB_PASSWORD", "E2E_TOTP_SECRET"]) {
|
||||
const v = process.env[k];
|
||||
if (v && v.length >= 6) s = s.split(v).join("***");
|
||||
}
|
||||
fs.writeFileSync(f, s);
|
||||
' "$f"
|
||||
done
|
||||
|
||||
echo "screenshots: $(ls "$OUT/screenshots" | wc -l)"
|
||||
exit $STATUS
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user