mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
Merge branch 'main' into feat/stalwart-1.0
This commit is contained in:
@@ -1,91 +0,0 @@
|
||||
// Babel plugin: rewrite named imports from the `lucide-react-native` barrel to
|
||||
// the per-icon modules they re-export, so the bundle only contains (and the
|
||||
// app only evaluates) the icons it uses. The barrel re-exports all ~1,700
|
||||
// icons, and Metro has no tree shaking, so one `import { Mail }` used to pull
|
||||
// in and run every icon at startup.
|
||||
//
|
||||
// import { Mail, Trash2 as Bin } from 'lucide-react-native';
|
||||
// becomes
|
||||
// import Mail from 'lucide-react-native/dist/esm/icons/mail.js';
|
||||
// import Bin from 'lucide-react-native/dist/esm/icons/trash-2.js';
|
||||
//
|
||||
// The name -> file table is read from the package's own ESM barrel, so icon
|
||||
// aliases (`MailIcon`, `LucideMail`, renamed icons) resolve exactly as they do
|
||||
// through the barrel. metro.config.js resolves these deep paths, which the
|
||||
// package's `exports` map does not list.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const PACKAGE = 'lucide-react-native';
|
||||
const ESM_DIR = 'dist/esm';
|
||||
|
||||
/** Map every name the barrel exports to `{ source, imported }`. */
|
||||
function loadExportMap() {
|
||||
// `main` is dist/cjs/lucide-react-native.js; the ESM tree sits next to it.
|
||||
const pkgDir = path.resolve(path.dirname(require.resolve(PACKAGE)), '..', '..');
|
||||
const barrel = fs.readFileSync(path.join(pkgDir, ESM_DIR, `${PACKAGE}.js`), 'utf8');
|
||||
const map = new Map();
|
||||
for (const m of barrel.matchAll(/export\s*\{([^}]*)\}\s*from\s*'\.\/([^']+)'/g)) {
|
||||
const source = `${PACKAGE}/${ESM_DIR}/${m[2]}`;
|
||||
for (const spec of m[1].split(',')) {
|
||||
const [imported, exported = imported] = spec.trim().split(/\s+as\s+/);
|
||||
if (imported) map.set(exported, { source, imported });
|
||||
}
|
||||
}
|
||||
if (map.size === 0) {
|
||||
throw new Error(`${PACKAGE}: could not read the export list from its ESM barrel`);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
let exportMap = null;
|
||||
function getExportMap() {
|
||||
if (!exportMap) exportMap = loadExportMap();
|
||||
return exportMap;
|
||||
}
|
||||
|
||||
// A value-less binding (only used as a type) is dropped by the TypeScript
|
||||
// transform anyway; treat it the same way instead of failing the build.
|
||||
function isOnlyUsedAsType(importPath, localName) {
|
||||
const binding = importPath.scope.getBinding(localName);
|
||||
if (!binding) return true;
|
||||
return binding.referencePaths.every((ref) => Boolean(ref.findParent((p) => p.isTSType())));
|
||||
}
|
||||
|
||||
function lucideImportsPlugin({ types: t }) {
|
||||
return {
|
||||
name: 'lucide-direct-imports',
|
||||
visitor: {
|
||||
ImportDeclaration(importPath) {
|
||||
const { node } = importPath;
|
||||
if (node.source.value !== PACKAGE || node.importKind === 'type') return;
|
||||
// Side-effect, default and namespace imports keep using the barrel.
|
||||
if (node.specifiers.length === 0) return;
|
||||
if (!node.specifiers.every((s) => t.isImportSpecifier(s))) return;
|
||||
|
||||
const map = getExportMap();
|
||||
const replacements = [];
|
||||
for (const spec of node.specifiers) {
|
||||
if (spec.importKind === 'type') continue;
|
||||
const name = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
|
||||
const target = map.get(name);
|
||||
if (!target) {
|
||||
if (isOnlyUsedAsType(importPath, spec.local.name)) continue;
|
||||
throw importPath.buildCodeFrameError(`${PACKAGE} has no export named "${name}"`);
|
||||
}
|
||||
const local = t.identifier(spec.local.name);
|
||||
const specifier = target.imported === 'default'
|
||||
? t.importDefaultSpecifier(local)
|
||||
: t.importSpecifier(local, t.identifier(target.imported));
|
||||
replacements.push(t.importDeclaration([specifier], t.stringLiteral(target.source)));
|
||||
}
|
||||
if (replacements.length === 0) importPath.remove();
|
||||
else importPath.replaceWithMultiple(replacements);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = lucideImportsPlugin;
|
||||
module.exports.loadExportMap = loadExportMap;
|
||||
module.exports.PACKAGE = PACKAGE;
|
||||
@@ -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
|
||||
@@ -0,0 +1,243 @@
|
||||
// Writes src/components/icons/index.ts from Tabler's outline path data.
|
||||
//
|
||||
// The app draws the same icon set as the webmail (Tabler Icons, MIT) under the
|
||||
// names it used when it was on Lucide, so screens import `{ Mail, Trash2 }`
|
||||
// from components/icons. Only the glyphs listed in MAP ship in the bundle.
|
||||
//
|
||||
// node scripts/generate-icons.mjs [path/to/tabler-nodes-outline.json]
|
||||
//
|
||||
// The default path is the webmail checkout this repo usually sits in
|
||||
// (repos/react-native inside jmap-webmail), whose node_modules has
|
||||
// @tabler/icons. Add a line to MAP to make a new icon importable.
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SOURCE = process.argv[2]
|
||||
?? join(ROOT, '..', '..', 'node_modules', '@tabler', 'icons', 'tabler-nodes-outline.json');
|
||||
|
||||
// App name -> Tabler outline name. Where the webmail (components/icons) maps
|
||||
// the same name, this uses its choice.
|
||||
const MAP = {
|
||||
AlarmClock: 'alarm',
|
||||
AlertCircle: 'alert-circle',
|
||||
AlertTriangle: 'alert-triangle',
|
||||
AlignCenter: 'align-center',
|
||||
AlignLeft: 'align-left',
|
||||
AlignRight: 'align-right',
|
||||
Archive: 'archive',
|
||||
ArrowDown: 'arrow-down',
|
||||
ArrowDownWideNarrow: 'sort-descending',
|
||||
ArrowLeft: 'arrow-left',
|
||||
ArrowRight: 'arrow-right',
|
||||
ArrowUp: 'arrow-up',
|
||||
ArrowUpDown: 'arrows-sort',
|
||||
ArrowUpNarrowWide: 'sort-ascending',
|
||||
Ban: 'ban',
|
||||
Bell: 'bell',
|
||||
BellOff: 'bell-off',
|
||||
BellRing: 'bell-ringing',
|
||||
Bold: 'bold',
|
||||
Book: 'book-2',
|
||||
BookOpen: 'book',
|
||||
BookUser: 'address-book',
|
||||
Bug: 'bug',
|
||||
Building: 'building',
|
||||
Cake: 'cake',
|
||||
Calendar: 'calendar',
|
||||
CalendarDays: 'calendar-month',
|
||||
CalendarPlus: 'calendar-plus',
|
||||
Camera: 'camera',
|
||||
Check: 'check',
|
||||
CheckCheck: 'checks',
|
||||
CheckCircle: 'circle-check',
|
||||
CheckCircle2: 'circle-check',
|
||||
CheckSquare: 'square-check',
|
||||
ChevronDown: 'chevron-down',
|
||||
ChevronLeft: 'chevron-left',
|
||||
ChevronRight: 'chevron-right',
|
||||
ChevronUp: 'chevron-up',
|
||||
CircleCheck: 'circle-check',
|
||||
ClipboardPaste: 'clipboard-text',
|
||||
Clock: 'clock',
|
||||
CloudDownload: 'cloud-download',
|
||||
CloudOff: 'cloud-off',
|
||||
Code: 'code',
|
||||
Columns3: 'columns-3',
|
||||
Columns4: 'layout-columns',
|
||||
Copy: 'copy',
|
||||
Database: 'database',
|
||||
Download: 'download',
|
||||
Edit2: 'pencil',
|
||||
Eraser: 'eraser',
|
||||
ExternalLink: 'external-link',
|
||||
Eye: 'eye',
|
||||
EyeOff: 'eye-off',
|
||||
File: 'file',
|
||||
FileArchive: 'file-zip',
|
||||
FileAudio: 'file-music',
|
||||
FileCode: 'file-code',
|
||||
FileCode2: 'file-code-2',
|
||||
FileImage: 'photo',
|
||||
FileInput: 'file-import',
|
||||
FileSpreadsheet: 'file-spreadsheet',
|
||||
FileSymlink: 'file-symlink',
|
||||
FileText: 'file-text',
|
||||
FileVideo: 'video',
|
||||
Filter: 'filter',
|
||||
Flag: 'flag',
|
||||
FlaskConical: 'flask',
|
||||
Folder: 'folder',
|
||||
FolderArchive: 'archive',
|
||||
FolderInput: 'folder-down',
|
||||
FolderOpen: 'folder-open',
|
||||
FolderPlus: 'folder-plus',
|
||||
FolderSync: 'folders',
|
||||
Forward: 'arrow-forward-up',
|
||||
Globe: 'world',
|
||||
Grid3x3: 'grid-dots',
|
||||
GripVertical: 'grip-vertical',
|
||||
HardDrive: 'server-2',
|
||||
Heading1: 'h-1',
|
||||
Heading2: 'h-2',
|
||||
Heart: 'heart',
|
||||
HelpCircle: 'help-circle',
|
||||
Highlighter: 'highlight',
|
||||
Image: 'photo',
|
||||
Import: 'file-import',
|
||||
Inbox: 'inbox',
|
||||
Info: 'info-circle',
|
||||
Italic: 'italic',
|
||||
Key: 'key',
|
||||
KeyRound: 'key',
|
||||
Languages: 'language',
|
||||
Layers: 'stack',
|
||||
LayoutGrid: 'layout-grid',
|
||||
LayoutTemplate: 'template',
|
||||
Link2: 'link',
|
||||
Link2Off: 'unlink',
|
||||
List: 'list',
|
||||
ListChecks: 'list-check',
|
||||
ListOrdered: 'list-numbers',
|
||||
Lock: 'lock',
|
||||
LogOut: 'logout',
|
||||
Mail: 'mail',
|
||||
MailCheck: 'mail-check',
|
||||
MailMinus: 'mail-minus',
|
||||
MailOpen: 'mail-opened',
|
||||
Mails: 'inbox',
|
||||
MapPin: 'map-pin',
|
||||
Maximize2: 'maximize',
|
||||
Menu: 'menu-2',
|
||||
MessageSquare: 'message',
|
||||
Minus: 'minus',
|
||||
Monitor: 'device-desktop',
|
||||
Moon: 'moon',
|
||||
MoreHorizontal: 'dots',
|
||||
MoreVertical: 'dots-vertical',
|
||||
MoveVertical: 'arrows-vertical',
|
||||
Palette: 'palette',
|
||||
Palmtree: 'beach',
|
||||
PanelLeftClose: 'layout-sidebar-left-collapse',
|
||||
PanelRight: 'layout-sidebar-right',
|
||||
Paperclip: 'paperclip',
|
||||
PenLine: 'pencil',
|
||||
PenSquare: 'edit',
|
||||
Pencil: 'pencil',
|
||||
Phone: 'phone',
|
||||
Pin: 'pinned',
|
||||
PinOff: 'pinned-off',
|
||||
Plus: 'plus',
|
||||
Puzzle: 'puzzle',
|
||||
QrCode: 'qrcode',
|
||||
Quote: 'quote',
|
||||
Redo2: 'arrow-forward',
|
||||
RefreshCw: 'refresh',
|
||||
RemoveFormatting: 'clear-formatting',
|
||||
Repeat: 'repeat',
|
||||
Reply: 'arrow-back-up',
|
||||
ReplyAll: 'arrow-back-up-double',
|
||||
RotateCcw: 'rotate',
|
||||
Rows2: 'layout-rows',
|
||||
Rss: 'rss',
|
||||
Save: 'device-floppy',
|
||||
ScanSearch: 'zoom-scan',
|
||||
Scale: 'scale',
|
||||
Search: 'search',
|
||||
Send: 'send',
|
||||
Server: 'server',
|
||||
Settings: 'settings',
|
||||
Share2: 'share-2',
|
||||
Shield: 'shield',
|
||||
ShieldAlert: 'shield-exclamation',
|
||||
ShieldCheck: 'shield-check',
|
||||
ShieldQuestion: 'shield-question',
|
||||
Shuffle: 'arrows-shuffle',
|
||||
Smartphone: 'device-mobile',
|
||||
Square: 'square',
|
||||
SquareCheck: 'square-check',
|
||||
SquarePen: 'edit',
|
||||
Star: 'star',
|
||||
StickyNote: 'note',
|
||||
Strikethrough: 'strikethrough',
|
||||
Sun: 'sun',
|
||||
Table: 'table',
|
||||
Tag: 'tag',
|
||||
Tags: 'tags',
|
||||
Terminal: 'terminal',
|
||||
Trash: 'trash',
|
||||
Trash2: 'trash',
|
||||
Type: 'typography',
|
||||
Underline: 'underline',
|
||||
Undo2: 'arrow-back',
|
||||
Upload: 'upload',
|
||||
User: 'user',
|
||||
UserCircle: 'user-circle',
|
||||
UserMinus: 'user-minus',
|
||||
UserPen: 'user-edit',
|
||||
UserPlus: 'user-plus',
|
||||
UserRound: 'user',
|
||||
UserRoundPlus: 'user-plus',
|
||||
Users: 'users',
|
||||
Video: 'video',
|
||||
X: 'x',
|
||||
XCircle: 'circle-x',
|
||||
};
|
||||
|
||||
function main() {
|
||||
const nodes = JSON.parse(readFileSync(SOURCE, 'utf8'));
|
||||
const glyphs = new Map();
|
||||
const missing = [];
|
||||
for (const [appName, tabler] of Object.entries(MAP)) {
|
||||
const icon = nodes[tabler];
|
||||
if (!icon) { missing.push(`${appName} -> ${tabler}`); continue; }
|
||||
const paths = icon.map(([tag, attrs]) => {
|
||||
if (tag !== 'path') throw new Error(`${tabler}: unexpected <${tag}>`);
|
||||
return attrs.d;
|
||||
});
|
||||
glyphs.set(tabler, paths);
|
||||
}
|
||||
if (missing.length) throw new Error(`Not in Tabler: ${missing.join(', ')}`);
|
||||
|
||||
const lines = [
|
||||
'// Generated by scripts/generate-icons.mjs from @tabler/icons (MIT).',
|
||||
'// Do not edit by hand: add the icon to MAP in the script and run it.',
|
||||
'',
|
||||
"import { makeIcon } from './icon';",
|
||||
'',
|
||||
"export type { AppIcon, IconProps } from './icon';",
|
||||
'',
|
||||
'const G = {',
|
||||
...[...glyphs.keys()].sort().map((k) => ` '${k}': ${JSON.stringify(glyphs.get(k))},`),
|
||||
'} as const;',
|
||||
'',
|
||||
...Object.entries(MAP).map(([a, t]) => `export const ${a} = makeIcon('${a}', G['${t}']);`),
|
||||
'',
|
||||
];
|
||||
writeFileSync(join(ROOT, 'src', 'components', 'icons', 'index.ts'), lines.join('\n'));
|
||||
console.log(`${Object.keys(MAP).length} names, ${glyphs.size} glyphs`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,46 @@
|
||||
// Writes src/lib/licences.json: the libraries the app ships (the runtime
|
||||
// dependencies in package.json) with their licence, for Settings → About →
|
||||
// Open-source licences. Run after adding or upgrading a dependency:
|
||||
//
|
||||
// node scripts/generate-licences.mjs
|
||||
//
|
||||
// The test in src/lib/__tests__/licences.test.ts fails when a dependency is
|
||||
// missing from the list.
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
||||
|
||||
// Code that isn't an npm dependency but ships in the app.
|
||||
const VENDORED = [
|
||||
{ name: 'Tabler Icons', version: '', license: 'MIT', url: 'https://tabler.io/icons' },
|
||||
];
|
||||
|
||||
function repoUrl(meta) {
|
||||
const repo = typeof meta.repository === 'string' ? meta.repository : meta.repository?.url;
|
||||
const url = meta.homepage || repo || '';
|
||||
return url
|
||||
.replace(/^git\+/, '')
|
||||
.replace(/^git:\/\//, 'https://')
|
||||
.replace(/^github:/, 'https://github.com/')
|
||||
.replace(/\.git$/, '')
|
||||
.replace(/#.*$/, '');
|
||||
}
|
||||
|
||||
const libraries = Object.keys(pkg.dependencies).sort().map((name) => {
|
||||
const file = join(ROOT, 'node_modules', name, 'package.json');
|
||||
if (!existsSync(file)) throw new Error(`${name} is not installed`);
|
||||
const meta = JSON.parse(readFileSync(file, 'utf8'));
|
||||
const license = typeof meta.license === 'string' ? meta.license : meta.license?.type ?? 'See package';
|
||||
return { name, version: meta.version, license, url: repoUrl(meta) };
|
||||
});
|
||||
|
||||
const out = {
|
||||
app: { name: pkg.name, license: pkg.license },
|
||||
libraries: [...libraries, ...VENDORED],
|
||||
};
|
||||
writeFileSync(join(ROOT, 'src', 'lib', 'licences.json'), `${JSON.stringify(out, null, 2)}\n`);
|
||||
console.log(`${out.libraries.length} libraries`);
|
||||
@@ -0,0 +1,30 @@
|
||||
// Metro polyfill for the performance harness (metro.config.js adds it only
|
||||
// when BULWARK_MODULE_TIMINGS is set at bundle time; release builds don't
|
||||
// have it). It wraps every module factory and adds up how long each module's
|
||||
// own code ran when it was first evaluated, without the modules it required
|
||||
// meanwhile. src/lib/perf-marks.ts logs the slowest ones at first inbox.
|
||||
(function (g) {
|
||||
var define = g.__d;
|
||||
if (typeof define !== 'function') return;
|
||||
var now = typeof g.nativePerformanceNow === 'function'
|
||||
? g.nativePerformanceNow
|
||||
: function () { return Date.now(); };
|
||||
var times = {};
|
||||
var stack = [];
|
||||
g.__bulwarkModuleTimes = times;
|
||||
g.__d = function (factory, moduleId, dependencyMap, verboseName) {
|
||||
var timed = function () {
|
||||
var start = now();
|
||||
stack.push(0);
|
||||
try {
|
||||
return factory.apply(this, arguments);
|
||||
} finally {
|
||||
var total = now() - start;
|
||||
var nested = stack.pop();
|
||||
times[moduleId] = (times[moduleId] || 0) + (total - nested);
|
||||
if (stack.length > 0) stack[stack.length - 1] += total;
|
||||
}
|
||||
};
|
||||
return define(timed, moduleId, dependencyMap, verboseName);
|
||||
};
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
@@ -0,0 +1,193 @@
|
||||
// Generate the Android side of the home-screen widgets from
|
||||
// src/widgets/catalog.json. Run from the RN repo root:
|
||||
// node scripts/sync-android-widgets.mjs [--check]
|
||||
//
|
||||
// android/ is committed and hand-maintained (no `expo prebuild`), so the
|
||||
// react-native-android-widget config plugin never runs for it. This script
|
||||
// does the same job: one RNWidgetProvider subclass per widget (the library
|
||||
// uses the class name as the widget name), a provider XML per widget, the
|
||||
// picker labels and descriptions, and the manifest receivers between the
|
||||
// BEGIN/END WIDGETS markers. With --check it only reports drift and exits 1.
|
||||
import { readFile, writeFile, readdir, unlink, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const CATALOG = join(ROOT, 'src', 'widgets', 'catalog.json');
|
||||
const MAIN = join(ROOT, 'android', 'app', 'src', 'main');
|
||||
const PACKAGE = 'com.anonymous.bulwarkmobile';
|
||||
const KOTLIN = join(MAIN, 'java', ...PACKAGE.split('.'), 'widget', 'WidgetProviders.kt');
|
||||
const XML_DIR = join(MAIN, 'res', 'xml');
|
||||
const STRINGS = join(MAIN, 'res', 'values', 'widget_strings.xml');
|
||||
// Optional picker previews: src/widgets/previews/<WidgetName>.png is copied to
|
||||
// res/drawable-nodpi and set as the widget's previewImage.
|
||||
const PREVIEWS = join(ROOT, 'src', 'widgets', 'previews');
|
||||
const PREVIEW_DIR = join(MAIN, 'res', 'drawable-nodpi');
|
||||
const MANIFEST = join(MAIN, 'AndroidManifest.xml');
|
||||
const CHECK_ONLY = process.argv.includes('--check');
|
||||
|
||||
// Android's sizing table: n cells is roughly 70n - 30 dp in either direction.
|
||||
const dp = (cells) => 70 * cells - 30;
|
||||
// Launchers refresh at most every 30 minutes; the app pushes fresher data
|
||||
// itself whenever it has some (see src/widgets/sync.ts).
|
||||
const UPDATE_PERIOD_MS = 30 * 60 * 1000;
|
||||
|
||||
const escapeXml = (s) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "\\'");
|
||||
const resName = (name) => `widgetprovider_${name.toLowerCase()}`;
|
||||
|
||||
const hasPreview = (w) => existsSync(join(PREVIEWS, `${w.name}.png`));
|
||||
const previewRes = (w) => `${w.name.toLowerCase()}_preview`;
|
||||
|
||||
function providerXml(w) {
|
||||
const preview = hasPreview(w) ? `\n android:previewImage="@drawable/${previewRes(w)}"` : '';
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="${dp(w.cols)}dp"
|
||||
android:minHeight="${dp(w.rows)}dp"
|
||||
android:minResizeWidth="${dp(w.minCols ?? w.cols)}dp"
|
||||
android:minResizeHeight="${dp(w.minRows ?? w.rows)}dp"
|
||||
android:targetCellWidth="${w.cols}"
|
||||
android:targetCellHeight="${w.rows}"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/${resName(w.name)}_description"
|
||||
android:initialLayout="@layout/rn_widget"${preview}
|
||||
android:updatePeriodMillis="${w.static ? 0 : UPDATE_PERIOD_MS}"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
`;
|
||||
}
|
||||
|
||||
function kotlinFile(widgets) {
|
||||
const classes = widgets.map((w) => `class ${w.name} : RNWidgetProvider()`).join('\n');
|
||||
return `// Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json.
|
||||
// react-native-android-widget names a widget after its provider class, so each
|
||||
// widget needs its own subclass; the JS side (src/widgets/task-handler.tsx)
|
||||
// renders by that name.
|
||||
package ${PACKAGE}.widget
|
||||
|
||||
import com.reactnativeandroidwidget.RNWidgetProvider
|
||||
|
||||
${classes}
|
||||
`;
|
||||
}
|
||||
|
||||
function stringsFile(widgets) {
|
||||
const lines = widgets.flatMap((w) => [
|
||||
` <string name="${resName(w.name)}_label">${escapeXml(w.label)}</string>`,
|
||||
` <string name="${resName(w.name)}_description">${escapeXml(w.description)}</string>`,
|
||||
]);
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<resources>
|
||||
${lines.join('\n')}
|
||||
</resources>
|
||||
`;
|
||||
}
|
||||
|
||||
function manifestBlock(widgets) {
|
||||
const receivers = widgets.map(
|
||||
(w) => ` <receiver android:name=".widget.${w.name}" android:exported="false" android:label="@string/${resName(w.name)}_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="${PACKAGE}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/${resName(w.name)}"/>
|
||||
</receiver>`,
|
||||
);
|
||||
return ` <!-- BEGIN WIDGETS: generated by scripts/sync-android-widgets.mjs, do not edit by hand -->
|
||||
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
|
||||
${receivers.join('\n')}
|
||||
<!-- END WIDGETS -->`;
|
||||
}
|
||||
|
||||
function withManifestBlock(manifest, block) {
|
||||
const begin = manifest.indexOf(' <!-- BEGIN WIDGETS');
|
||||
const endMarker = '<!-- END WIDGETS -->';
|
||||
const end = manifest.indexOf(endMarker);
|
||||
if (begin !== -1 && end !== -1) {
|
||||
return manifest.slice(0, begin) + block + manifest.slice(end + endMarker.length);
|
||||
}
|
||||
const close = manifest.lastIndexOf(' </application>');
|
||||
if (close === -1) throw new Error('AndroidManifest.xml has no </application>');
|
||||
return manifest.slice(0, close) + block + '\n' + manifest.slice(close);
|
||||
}
|
||||
|
||||
const { widgets } = JSON.parse(await readFile(CATALOG, 'utf8'));
|
||||
const names = new Set();
|
||||
for (const w of widgets) {
|
||||
if (!/^[A-Z][A-Za-z0-9]*Widget$/.test(w.name)) throw new Error(`Bad widget name ${w.name}`);
|
||||
if (names.has(w.name)) throw new Error(`Duplicate widget name ${w.name}`);
|
||||
names.add(w.name);
|
||||
}
|
||||
|
||||
const outputs = new Map();
|
||||
outputs.set(KOTLIN, kotlinFile(widgets));
|
||||
outputs.set(STRINGS, stringsFile(widgets));
|
||||
for (const w of widgets) outputs.set(join(XML_DIR, `${resName(w.name)}.xml`), providerXml(w));
|
||||
outputs.set(MANIFEST, withManifestBlock(await readFile(MANIFEST, 'utf8'), manifestBlock(widgets)));
|
||||
|
||||
// Provider XML for widgets that were removed from the catalog.
|
||||
const stale = existsSync(XML_DIR)
|
||||
? (await readdir(XML_DIR))
|
||||
.filter((f) => f.startsWith('widgetprovider_'))
|
||||
.map((f) => join(XML_DIR, f))
|
||||
.filter((f) => !outputs.has(f))
|
||||
: [];
|
||||
|
||||
// Preview images are binary; compare them byte for byte.
|
||||
const binaryOutputs = new Map();
|
||||
for (const w of widgets) {
|
||||
if (hasPreview(w)) binaryOutputs.set(join(PREVIEW_DIR, `${previewRes(w)}.png`), await readFile(join(PREVIEWS, `${w.name}.png`)));
|
||||
}
|
||||
const stalePreviews = existsSync(PREVIEW_DIR)
|
||||
? (await readdir(PREVIEW_DIR))
|
||||
.filter((f) => /widget_preview\.png$/.test(f))
|
||||
.map((f) => join(PREVIEW_DIR, f))
|
||||
.filter((f) => !binaryOutputs.has(f))
|
||||
: [];
|
||||
stale.push(...stalePreviews);
|
||||
|
||||
let drift = stale.length;
|
||||
for (const [path, bytes] of binaryOutputs) {
|
||||
const current = existsSync(path) ? await readFile(path) : null;
|
||||
if (current && current.equals(bytes)) continue;
|
||||
drift++;
|
||||
if (CHECK_ONLY) {
|
||||
console.log(`out of date: ${path}`);
|
||||
} else {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, bytes);
|
||||
console.log(`wrote ${path}`);
|
||||
}
|
||||
}
|
||||
// Checkouts with core.autocrlf have CRLF files: compare without line endings
|
||||
// and write back in whatever the file already uses.
|
||||
const lf = (text) => text.replace(/\r\n/g, '\n');
|
||||
for (const [path, content] of outputs) {
|
||||
const current = existsSync(path) ? await readFile(path, 'utf8') : null;
|
||||
if (current !== null && lf(current) === lf(content)) continue;
|
||||
drift++;
|
||||
if (CHECK_ONLY) {
|
||||
console.log(`out of date: ${path}`);
|
||||
} else {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, current?.includes('\r\n') ? lf(content).replace(/\n/g, '\r\n') : lf(content));
|
||||
console.log(`wrote ${path}`);
|
||||
}
|
||||
}
|
||||
for (const path of stale) {
|
||||
if (CHECK_ONLY) console.log(`stale: ${path}`);
|
||||
else {
|
||||
await unlink(path);
|
||||
console.log(`removed ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (CHECK_ONLY && drift > 0) {
|
||||
console.error(`${drift} widget file(s) out of date; run node scripts/sync-android-widgets.mjs`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(drift === 0 ? 'Android widget files are up to date.' : `${widgets.length} widgets synced.`);
|
||||
@@ -0,0 +1,171 @@
|
||||
// Put an uploaded build on the public TestFlight link: wait until App Store
|
||||
// Connect has processed it, add it to the external beta group, set its
|
||||
// "What to Test" text and submit it for beta review. Builds only reach
|
||||
// external testers (and so the public link) once Apple approves them.
|
||||
//
|
||||
// node scripts/testflight-public.mjs <marketing version> <build number>
|
||||
//
|
||||
// Environment:
|
||||
// ASC_KEY_ID, ASC_ISSUER_ID App Store Connect API key
|
||||
// ASC_KEY_PATH the .p8 file (default ~/private_keys/AuthKey_<id>.p8)
|
||||
// BUNDLE_ID default org.bulwarkmail.mobile
|
||||
// TESTFLIGHT_GROUP external group name (default "external")
|
||||
// WHATS_NEW "What to Test" text (default: link to the GitHub release)
|
||||
//
|
||||
// Every step can be repeated: a build already in the group, already described
|
||||
// or already submitted is left as it is, so a re-run picks up where the last
|
||||
// one stopped.
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const [version, buildNumber] = process.argv.slice(2);
|
||||
if (!version || !buildNumber) {
|
||||
console.error('usage: node scripts/testflight-public.mjs <marketing version> <build number>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const keyId = process.env.ASC_KEY_ID;
|
||||
const issuer = process.env.ASC_ISSUER_ID;
|
||||
if (!keyId || !issuer) {
|
||||
console.error('ASC_KEY_ID and ASC_ISSUER_ID are required.');
|
||||
process.exit(2);
|
||||
}
|
||||
const keyPath = process.env.ASC_KEY_PATH || path.join(os.homedir(), 'private_keys', `AuthKey_${keyId}.p8`);
|
||||
const privateKey = fs.readFileSync(keyPath, 'utf8');
|
||||
const bundleId = process.env.BUNDLE_ID || 'org.bulwarkmail.mobile';
|
||||
const groupName = process.env.TESTFLIGHT_GROUP || 'external';
|
||||
const repo = process.env.GITHUB_REPOSITORY || 'bulwarkmail/native';
|
||||
const whatsNew = process.env.WHATS_NEW || `Release notes: https://github.com/${repo}/releases/tag/${version}`;
|
||||
|
||||
// Processing usually takes 5-30 minutes; give up well inside the job timeout.
|
||||
const PROCESSING_TIMEOUT_MS = 60 * 60 * 1000;
|
||||
const POLL_MS = 30 * 1000;
|
||||
|
||||
function token() {
|
||||
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const head = b64({ alg: 'ES256', kid: keyId, typ: 'JWT' });
|
||||
const body = b64({ iss: issuer, iat: now, exp: now + 15 * 60, aud: 'appstoreconnect-v1' });
|
||||
const sig = crypto
|
||||
.sign('sha256', Buffer.from(`${head}.${body}`), { key: privateKey, dsaEncoding: 'ieee-p1363' })
|
||||
.toString('base64url');
|
||||
return `${head}.${body}.${sig}`;
|
||||
}
|
||||
|
||||
async function api(method, p, body) {
|
||||
const res = await fetch(`https://api.appstoreconnect.apple.com${p}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
const json = text ? JSON.parse(text) : null;
|
||||
return { status: res.status, ok: res.ok, json };
|
||||
}
|
||||
|
||||
function fail(step, r) {
|
||||
const e = r.json?.errors?.[0];
|
||||
console.error(`${step} failed: ${r.status} ${e?.code ?? ''} ${e?.detail ?? e?.title ?? ''}`);
|
||||
if (e?.code === 'FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED') {
|
||||
console.error('An Apple agreement needs accepting in App Store Connect > Business.');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ── The app and the group ──────────────────────────────────────
|
||||
const apps = await api('GET', `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&fields[apps]=name`);
|
||||
if (!apps.ok) fail('Looking up the app', apps);
|
||||
const app = apps.json.data[0];
|
||||
if (!app) {
|
||||
console.error(`No app with bundle ID ${bundleId} in App Store Connect.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const groups = await api(
|
||||
'GET',
|
||||
`/v1/betaGroups?filter[app]=${app.id}&filter[name]=${encodeURIComponent(groupName)}&fields[betaGroups]=name,isInternalGroup,publicLink,publicLinkEnabled`,
|
||||
);
|
||||
if (!groups.ok) fail('Looking up the beta group', groups);
|
||||
const group = groups.json.data.find((g) => !g.attributes.isInternalGroup && g.attributes.name === groupName);
|
||||
if (!group) {
|
||||
console.error(`No external beta group named "${groupName}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Wait for the build ─────────────────────────────────────────
|
||||
const query =
|
||||
`/v1/builds?filter[app]=${app.id}&filter[version]=${encodeURIComponent(buildNumber)}` +
|
||||
`&filter[preReleaseVersion.version]=${encodeURIComponent(version)}&fields[builds]=version,processingState`;
|
||||
const started = Date.now();
|
||||
let build;
|
||||
for (;;) {
|
||||
const r = await api('GET', query);
|
||||
if (!r.ok) fail('Looking up the build', r);
|
||||
build = r.json.data[0];
|
||||
const state = build?.attributes.processingState;
|
||||
if (state === 'VALID') break;
|
||||
if (state === 'FAILED' || state === 'INVALID') {
|
||||
console.error(`Build ${version} (${buildNumber}) is ${state} in App Store Connect.`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (Date.now() - started > PROCESSING_TIMEOUT_MS) {
|
||||
console.error(`Build ${version} (${buildNumber}) was not processed within an hour (state: ${state ?? 'not found'}).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Build ${version} (${buildNumber}): ${state ?? 'not visible yet'}, waiting…`);
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
console.log(`Build ${version} (${buildNumber}) is processed.`);
|
||||
|
||||
// ── Add it to the group ────────────────────────────────────────
|
||||
const add = await api('POST', `/v1/betaGroups/${group.id}/relationships/builds`, {
|
||||
data: [{ type: 'builds', id: build.id }],
|
||||
});
|
||||
if (!add.ok) fail(`Adding the build to "${groupName}"`, add);
|
||||
console.log(`Added to the "${groupName}" group.`);
|
||||
|
||||
// ── "What to Test" ─────────────────────────────────────────────
|
||||
const locs = await api('GET', `/v1/builds/${build.id}/betaBuildLocalizations`);
|
||||
if (!locs.ok) fail('Reading the build notes', locs);
|
||||
const en = locs.json.data.find((l) => l.attributes.locale === 'en-US');
|
||||
const notes = en
|
||||
? await api('PATCH', `/v1/betaBuildLocalizations/${en.id}`, {
|
||||
data: { type: 'betaBuildLocalizations', id: en.id, attributes: { whatsNew } },
|
||||
})
|
||||
: await api('POST', '/v1/betaBuildLocalizations', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
attributes: { locale: 'en-US', whatsNew },
|
||||
relationships: { build: { data: { type: 'builds', id: build.id } } },
|
||||
},
|
||||
});
|
||||
if (!notes.ok) fail('Setting "What to Test"', notes);
|
||||
|
||||
// ── Beta review ────────────────────────────────────────────────
|
||||
const detail = await api('GET', `/v1/builds/${build.id}/buildBetaDetail`);
|
||||
if (!detail.ok) fail('Reading the build state', detail);
|
||||
const external = detail.json.data.attributes.externalBuildState;
|
||||
if (external === 'MISSING_EXPORT_COMPLIANCE') {
|
||||
console.error('The build has no export compliance answer; set usesNonExemptEncryption in app.config.js.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (external === 'READY_FOR_BETA_SUBMISSION') {
|
||||
const submit = await api('POST', '/v1/betaAppReviewSubmissions', {
|
||||
data: { type: 'betaAppReviewSubmissions', relationships: { build: { data: { type: 'builds', id: build.id } } } },
|
||||
});
|
||||
if (!submit.ok) fail('Submitting for beta review', submit);
|
||||
console.log('Submitted for beta review.');
|
||||
} else {
|
||||
console.log(`Not submitted again: the build is ${external}.`);
|
||||
}
|
||||
|
||||
const link = group.attributes.publicLinkEnabled ? group.attributes.publicLink : null;
|
||||
console.log(
|
||||
link
|
||||
? `Testers with ${link} get it once Apple approves the build.`
|
||||
: `The "${groupName}" group has no public link enabled.`,
|
||||
);
|
||||
Reference in New Issue
Block a user