Merge branch 'main' into feat/stalwart-1.0

# Conflicts:
#	CONTRIBUTING.md
#	LICENSE
#	README.md
#	app/api/calendar-agenda/route.ts
#	app/api/wopi/files/[fileId]/contents/route.ts
#	app/api/wopi/launch/route.ts
#	components/calendar/calendar-app.tsx
#	components/calendar/calendar-day-view.tsx
#	components/calendar/calendar-month-view.tsx
#	components/calendar/calendar-week-view.tsx
#	components/calendar/event-modal.tsx
#	components/contacts/contact-detail.tsx
#	components/email/email-composer.tsx
#	components/email/email-list.tsx
#	components/files/file-browser.tsx
#	components/files/files-app.tsx
#	components/files/folder-tree-sidebar.tsx
#	components/filters/filter-rule-modal.tsx
#	components/mail/mail-app.tsx
#	hooks/use-mailbox-drop.ts
#	integration/run-tests.sh
#	lib/__tests__/calendar-agenda-route.test.ts
#	lib/__tests__/jmap-draft-lifecycle.test.ts
#	lib/__tests__/web-push.test.ts
#	lib/account-state-manager.ts
#	lib/jmap/client-interface.ts
#	lib/jmap/client.ts
#	lib/jmap/types.ts
#	lib/message-list-order.ts
#	lib/sieve/generator.ts
#	lib/sieve/parser.ts
#	lib/telemetry/payload.ts
#	lib/thread-utils.ts
#	lib/unified-mailbox.ts
#	lib/web-push.ts
#	lib/wopi/token.ts
#	locales/ar/common.json
#	locales/ca/common.json
#	locales/cs/common.json
#	locales/da/common.json
#	locales/de/common.json
#	locales/en/common.json
#	locales/es/common.json
#	locales/fa/common.json
#	locales/fr/common.json
#	locales/he/common.json
#	locales/hu/common.json
#	locales/it/common.json
#	locales/ja/common.json
#	locales/ko/common.json
#	locales/lv/common.json
#	locales/mn/common.json
#	locales/nb/common.json
#	locales/nl/common.json
#	locales/pl/common.json
#	locales/pt/common.json
#	locales/ro/common.json
#	locales/ru/common.json
#	locales/sk/common.json
#	locales/tr/common.json
#	locales/uk/common.json
#	locales/zh-TW/common.json
#	locales/zh/common.json
#	scripts/lite/lib.mjs
#	stores/__tests__/calendar-store-synthetic-ids.test.ts
#	stores/__tests__/email-store-shared-folder-keywords.test.ts
#	stores/__tests__/email-store-unified-actions.test.ts
#	stores/__tests__/file-store.test.ts
#	stores/email-store.ts
#	stores/file-store.ts
#	stores/message-list-tabs-store.ts
This commit is contained in:
Linus Rath
2026-10-12 08:35:08 +02:00
674 changed files with 74850 additions and 11625 deletions
+99 -23
View File
@@ -1,6 +1,7 @@
// Shared, side-effect-free helpers for the Lite build scripts. Kept separate
// so lib/__tests__/lite-scripts.test.ts can exercise them without touching
// the filesystem or spawning `next build`.
import { createHash } from "node:crypto";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -93,6 +94,7 @@ export const LITE_API_STRING_ALLOWLIST = [
"/api/auth/sso/complete",
"/api/auth/reauth/sso/complete",
"/api/auth/pair/create",
"/api/auth/pair/status",
"/api/auth/totp-token-exchange",
"/api/auth/session",
"/api/auth/verify",
@@ -103,6 +105,8 @@ export const LITE_API_STRING_ALLOWLIST = [
// Settings/policy - replaced by config.json / policy.json.
"/api/config",
"/api/admin/policy",
// lib/account-archive-client.ts - the feature is pinned off in Lite.
"/api/account-archive",
// WOPI (#425) - use-wopi-status returns disabled in Lite.
"/api/wopi/status",
"/api/wopi/launch",
@@ -356,7 +360,11 @@ export function buildRedirects({ basePath = "", locales, surfaces = LITE_SURFACE
export function buildCsp({ connectSrc = "*" } = {}) {
return [
"default-src 'self'",
// The static export ships inline hydration scripts, so no nonce is possible.
// The static export ships inline hydration scripts, so no nonce is possible
// and one header cannot list every page's hashes. Each page of the static
// target carries its own <meta> policy that allows exactly its inline
// scripts (withScriptHashCsp); both policies apply, so this only matters
// where that meta is missing.
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
@@ -371,6 +379,59 @@ export function buildCsp({ connectSrc = "*" } = {}) {
].join("; ");
}
/** The `'sha256-...'` source for every inline script in an HTML document. */
export function inlineScriptHashes(html) {
const hashes = new Set();
for (const [, attrs, body] of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi)) {
if (/\bsrc\s*=/i.test(attrs)) continue;
// Data blocks (JSON, templates) never execute.
const type = /\btype\s*=\s*["']?([^"'\s>]+)/i.exec(attrs)?.[1]?.toLowerCase();
if (type && type !== "module" && !/^(text|application)\/(x-)?(java|ecma)script$/.test(type)) continue;
// Browsers normalise newlines before the script text is hashed.
const text = body.replace(/\r\n?/g, "\n");
hashes.add(`'sha256-${createHash("sha256").update(text, "utf8").digest("base64")}'`);
}
return [...hashes];
}
/**
* The page-level policy of a static-target HTML file: its own inline scripts
* and scripts from this origin, nothing else. Enforced together with the
* host's header policy, it takes away the header's 'unsafe-inline'.
*/
export function buildScriptHashCsp(hashes) {
return [`script-src 'self'${hashes.map((h) => ` ${h}`).join("")}`, "object-src 'none'", "base-uri 'self'"].join("; ");
}
/**
* `html` with a <meta> CSP naming its inline script hashes, ahead of every
* script: right after <meta charset> (which has to stay within the first
* 1024 bytes), else as early in the document as possible.
*/
export function withScriptHashCsp(html) {
const meta = `<meta http-equiv="Content-Security-Policy" content="${buildScriptHashCsp(inlineScriptHashes(html))}"/>`;
// Without a <head> the parser still places a leading <meta> in the implied one.
const anchor = /<meta\s+charset\s*=[^>]*>/i.exec(html) ?? /<head\b[^>]*>/i.exec(html)
?? /<html\b[^>]*>/i.exec(html) ?? /<!doctype[^>]*>/i.exec(html);
const at = anchor ? anchor.index + anchor[0].length : 0;
return html.slice(0, at) + meta + html.slice(at);
}
/**
* The policy of the Stalwart target's entry document. Stalwart sends no
* security headers for an Application, so this <meta> is all there is. The
* shells it writes in are rewritten for the mount at runtime, which rules
* out build-time hashes; it still forbids plugins, eval, foreign scripts and
* a moved <base>. frame-ancestors cannot be set from a <meta>: the entry
* refuses to run in a frame of another origin instead.
*/
export function buildStalwartEntryCsp() {
return buildCsp({ connectSrc: "*" })
.split("; ")
.filter((directive) => !directive.startsWith("frame-ancestors"))
.join("; ");
}
/** Security headers for hosts that read a `_headers` file. */
export function buildHeaders({ basePath = "", connectSrc = "*" }) {
const csp = buildCsp({ connectSrc });
@@ -398,8 +459,9 @@ export function exampleDocRoot(basePath = "") {
return { root: "/var/www/bulwark-lite", files: `/var/www/bulwark-lite${basePath}` };
}
export function buildNginxExample({ basePath = "", surfaces = LITE_SURFACES }) {
export function buildNginxExample({ basePath = "", surfaces = LITE_SURFACES, connectSrc = "*" }) {
const location = basePath ? `${basePath}/` : "/";
const csp = buildCsp({ connectSrc });
const { root, files } = exampleDocRoot(basePath);
const surfaceAlternation = surfaces.join("|");
return `# Bulwark Lite - nginx example. Unzip the archive into ${files}
@@ -413,7 +475,9 @@ server {
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# See _headers for a Content-Security-Policy that matches your JMAP server.
# connect-src names the JMAP server this bundle was built for. Every page
# also carries a <meta> policy that allows only its own inline scripts.
add_header Content-Security-Policy "${csp}" always;
# Hashed assets never change. (add_header inside a location replaces the
# inherited set, so the security headers are repeated here.)
@@ -422,6 +486,7 @@ server {
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Content-Security-Policy "${csp}" always;
}
# Older mime.types files do not know the PWA manifest extension.
@@ -525,8 +590,9 @@ ${securityHeaders(" ")}
`;
}
export function buildCaddyExample({ basePath = "", surfaces = LITE_SURFACES }) {
export function buildCaddyExample({ basePath = "", surfaces = LITE_SURFACES, connectSrc = "*" }) {
const { root, files } = exampleDocRoot(basePath);
const csp = buildCsp({ connectSrc });
const surfaceAlternation = surfaces.join("|");
return `# Bulwark Lite - Caddy example. Unzip the archive into ${files}
# so that ${basePath}/index.html is served at ${basePath || ""}/.
@@ -538,6 +604,8 @@ webmail.example.com {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
# connect-src names the JMAP server this bundle was built for.
Content-Security-Policy "${csp}"
}
@manifest path ${basePath}/manifest.webmanifest
@@ -722,7 +790,10 @@ export function parseLiteTarget(argv = [], env = {}) {
return target;
}
/** Release asset name; `releases/latest/download/<this>` is the stable resourceUrl. */
/**
* Release asset name. It carries no version, so `releases/latest/download/<this>`
* exists too, but the README points `resourceUrl` at a tagged release.
*/
export const STALWART_ZIP_NAME = "bulwark-lite-stalwart.zip";
/** The mount prefix the docs and the one-line install use. */
@@ -1016,6 +1087,12 @@ export function buildStalwartEntry({ locales, shells, defaultLocale = "en", buil
if (chosen === "dark" || chosen === "light") document.documentElement.className = chosen;
} catch (e) {}
// Stalwart sends no X-Frame-Options or frame-ancestors for an Application:
// inside another origin's frame the app would be open to clickjacking.
var foreignFrame = false;
try { foreignFrame = window.top !== window.self && window.top.location.origin !== location.origin; } catch (e) { foreignFrame = true; }
if (foreignFrame) return fail("Bulwark Webmail cannot be shown inside another site's page. Open it directly.");
function fail(message) {
function show() {
var box = document.getElementById("bulwark-lite-boot");
@@ -1099,6 +1176,7 @@ export function buildStalwartEntry({ locales, shells, defaultLocale = "en", buil
<html lang="en" ${STALWART_ENTRY_MARKER}="">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="${buildStalwartEntryCsp()}" />
${STALWART_BASE_HREF_LITERAL} />
${STALWART_OAUTH_META_LITERAL} />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
@@ -1298,7 +1376,8 @@ export function collectZipEntries(dir) {
/** LITE-README.md for the Stalwart bundle. */
export function buildStalwartReadme({ version, commit, locales, buildId, demoMode = false }) {
const url = `https://github.com/bulwarkmail/webmail/releases/latest/download/${STALWART_ZIP_NAME}`;
const url = `https://github.com/bulwarkmail/webmail/releases/download/v${version}/${STALWART_ZIP_NAME}`;
const latestUrl = `https://github.com/bulwarkmail/webmail/releases/latest/download/${STALWART_ZIP_NAME}`;
return `# Bulwark Lite ${version} (${commit}) for Stalwart
Bulwark Webmail as a Stalwart \`Application\`: Stalwart downloads this zip,
@@ -1365,21 +1444,21 @@ admin's own \`admin\` and \`account\`.
## Updates
Stalwart keeps the downloaded zip for \`autoUpdateFrequency\` (default 90
days) and downloads \`resourceUrl\` again at the first restart or
\`UpdateApps\` after that, so the \`latest/download\` URL above follows new
releases on that schedule. \`UpdateApps\` always downloads again: run it to
update right away.
The resource URL above names one release, so what Stalwart serves only
changes when you change it: to update, point \`resourceUrl\` at the new
release's tag and run \`UpdateApps\`, which always downloads again. Check
that the new URL downloads first: a failed download keeps the previous
bundle online only on 0.16.23 and later, while older 0.16 releases and the
1.0 pre-releases unmount the Application until a download works. Every
release also carries
\`${STALWART_ZIP_NAME}.sha256\`; check a download with
\`sha256sum -c ${STALWART_ZIP_NAME}.sha256\` before you switch.
What a failed download does depends on the Stalwart version. On 0.16.23 the
running bundle stays online. Older 0.16 releases and the 1.0 pre-releases
unmount the Application and drop the cached copy. The next restart,
\`UpdateApps\` or reload of the Applications (the other nodes of a cluster
reload them after \`UpdateApps\`) downloads \`resourceUrl\` again and mounts the
bundle once that works. On every version, a restart after the cached copy
has expired, with \`resourceUrl\` unreachable, leaves the Application
unmounted. So check that \`resourceUrl\` downloads before running
\`UpdateApps\` or restarting.
The bundle runs as script on your Stalwart origin, next to the web admin.
Stalwart checks no hash or signature when it downloads, so a floating URL
such as \`${latestUrl}\` would run whatever the newest release holds, at the
next restart or \`UpdateApps\` after \`autoUpdateFrequency\` (default 90
days). Use it only if you accept that in exchange for automatic updates.
Open tabs move to the new build on their next navigation (one full page
load); a freshly opened tab gets it immediately. Nothing stays stuck on old
@@ -1387,9 +1466,6 @@ files although Stalwart sends a year-long cache header for every file but
index.html: every file the app fetches without a content hash carries the
build id.
To pin a version, point \`resourceUrl\` at a tagged release instead:
\`https://github.com/bulwarkmail/webmail/releases/download/${version}/${STALWART_ZIP_NAME}\`.
## Sign-in
Passwords (and TOTP codes) go to Stalwart's \`/api/auth\` and are exchanged at
+28 -3
View File
@@ -7,12 +7,13 @@
// entry document Stalwart serves for every route, config/policy/manifest that
// follow the runtime mount, no host snippets, no 404.html - and packs out/
// into bulwark-lite-stalwart.zip.
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
STALWART_EXCLUDED_DIRS, STALWART_EXCLUDED_FILES, STALWART_ZIP_NAME,
buildCaddyExample, buildConnectorCapabilities, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildNotFoundShim, buildReadme, buildRedirects, buildRootRedirect,
buildStalwartEntry, buildStalwartManifest, buildStalwartReadme, buildZip, collectZipEntries, discoverBuiltLocales, discoverShellDirs, findSegmentPrefetchDirs, isMainModule, makeBuildId, normalizeBasePath, parseLiteTarget, resolveRepoRoot,
withScriptHashCsp,
} from "./lib.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
@@ -47,6 +48,12 @@ export function runPostbuild({ root = repoRoot, env = process.env, log = console
for (const [name, content] of Object.entries(files)) {
writeFileSync(join(outDir, name), content);
}
// The Stalwart target rewrites its shells at runtime, so their scripts
// cannot be hashed at build time (see buildStalwartEntryCsp).
if (target === "static") {
const pages = applyScriptHashCsp(outDir);
log(`[lite] pinned the inline scripts of ${pages} pages with a <meta> CSP`);
}
log(`[lite] wrote ${Object.keys(files).length} files into out/ (${target} target, ${locales.length} locales, base path ${target === "stalwart" ? "runtime" : basePath || "/"})`);
let zipPath = null;
@@ -74,8 +81,8 @@ function staticFiles({ config, basePath, locales, defaultLocale, version, commit
"404.html": buildNotFoundShim({ basePath, locales }),
"_redirects": buildRedirects({ basePath, locales }),
"_headers": buildHeaders({ basePath, connectSrc }),
"nginx.conf.example": buildNginxExample({ basePath }),
"Caddyfile.example": buildCaddyExample({ basePath }),
"nginx.conf.example": buildNginxExample({ basePath, connectSrc }),
"Caddyfile.example": buildCaddyExample({ basePath, connectSrc }),
"LITE-README.md": buildReadme({ version, commit, basePath, locales, jmapServerUrl: config.jmapServerUrl, demoMode: config.demoMode }),
};
}
@@ -102,6 +109,24 @@ function stalwartFiles({ outDir, config, locales, defaultLocale, version, commit
};
}
/** Add each HTML file's script-hash policy (withScriptHashCsp); returns how many. */
export function applyScriptHashCsp(outDir) {
let pages = 0;
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name !== "_next") walk(path);
} else if (entry.name.endsWith(".html")) {
writeFileSync(path, withScriptHashCsp(readFileSync(path, "utf8")));
pages++;
}
}
};
walk(outDir);
return pages;
}
if (isMainModule(import.meta.url)) {
try {
runPostbuild();
+15 -1
View File
@@ -10,7 +10,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import {
LITE_PENDING_PATH_KEY, STALWART_EXCLUDED_FILES, STALWART_MAX_BUNDLE_BYTES, STALWART_SAFE_BUNDLE_BYTES, STALWART_ZIP_NAME,
collectApiStrings, collectZipEntries, discoverBuiltLocales, flightTextRowsWithNeedles, isMainModule, listZipEntries, parseLiteTarget, resolveRepoRoot, stalwartEntryProblems, stalwartZipEntryProblems, unexpectedApiStrings,
collectApiStrings, collectZipEntries, discoverBuiltLocales, flightTextRowsWithNeedles, inlineScriptHashes, isMainModule, listZipEntries, parseLiteTarget, resolveRepoRoot, stalwartEntryProblems, stalwartZipEntryProblems, unexpectedApiStrings,
} from "./lib.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
@@ -63,6 +63,20 @@ export function verifyExport({ root = repoRoot, target, safeBundleBytes = STALWA
if (existsSync(notFound) && !readFileSync(notFound, "utf8").includes(LITE_PENDING_PATH_KEY)) {
problems.push("out/404.html is not the Lite shim (deep links on hosts without rewrites would dead-end); run postbuild");
}
// Every page pins its inline scripts (postbuild applyScriptHashCsp); a
// page changed afterwards would load with its hydration scripts blocked.
for (const locale of locales) {
for (const surface of ["mail", "calendar", "contacts", "files", "settings", "login"]) {
const page = join(outDir, locale, surface, "index.html");
if (!existsSync(page)) continue;
const html = readFileSync(page, "utf8");
const policy = /<meta http-equiv="Content-Security-Policy" content="([^"]*)"/.exec(html)?.[1];
if (!policy) problems.push(`out/${locale}/${surface}/index.html carries no script policy; run postbuild`);
else if (inlineScriptHashes(html).some((hash) => !policy.includes(hash))) {
problems.push(`out/${locale}/${surface}/index.html has an inline script its policy does not allow`);
}
}
}
}
const apiStrings = collectApiStrings(join(outDir, "_next", "static"));
@@ -0,0 +1,131 @@
#!/usr/bin/env node
/**
* Smoke test for Mountain View, run against a live instance.
*
* The integration suite (integration/) needs Docker for its Stalwart stack;
* this one only needs a running webmail and one mailbox with some mail in it:
*
* MV_URL=http://127.0.0.1:3000 MV_USER=me@example.com MV_PASS=... \
* node scripts/mountain-view-smoke/mountain-view-smoke.mjs
*
* MV_SERVER optionally picks the login form's server entry by id. The
* password is read from the environment only and never printed. It turns
* Mountain View on in a fresh browser profile, then checks the layout's own
* structure - not the mail in the box - on a desktop and a phone viewport.
* It exits non-zero if any check failed.
*/
/* global document */
import { chromium } from 'playwright';
const url = (process.env.MV_URL || '').replace(/\/+$/, '');
const user = process.env.MV_USER;
const pass = process.env.MV_PASS;
if (!url || !user || !pass) {
console.error('Set MV_URL, MV_USER and MV_PASS.');
process.exit(2);
}
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok });
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` - ${detail}` : ''}`);
};
async function signIn(context) {
// The layout is a stored setting: put it in place before the app reads its
// storage. The version matches the settings store's, so nothing migrates it.
await context.addInitScript(() => {
if (!localStorage.getItem('settings-storage')) {
localStorage.setItem('settings-storage', JSON.stringify({ state: { interfaceLayout: 'mountain-view' }, version: 7 }));
}
});
const page = await context.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto(`${url}/en/login`, { waitUntil: 'networkidle' });
if (process.env.MV_SERVER && await page.locator('#jmap-server-select').count()) {
await page.selectOption('#jmap-server-select', process.env.MV_SERVER);
}
await page.fill('input[type=email], input[name=username], #username', user);
await page.fill('input[type=password]', pass);
await page.click('button[type=submit]');
await page.waitForURL(/\/mail/, { timeout: 60_000 });
await page.locator('[data-email-id]').first().waitFor({ timeout: 60_000 });
return { page, errors };
}
async function desktop(browser) {
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const { page, errors } = await signIn(context);
check('desktop: Mountain View is applied', await page.evaluate(() => document.documentElement.dataset.interfaceLayout === 'mountain-view'));
check('desktop: Starred sits right under the Inbox', await page.evaluate(() => {
const rows = [...document.querySelectorAll('[data-folder-name], [data-folder-role]')]
.map((r) => r.getAttribute('data-folder-name') || r.getAttribute('data-folder-role'));
const inbox = rows.indexOf('inbox') >= 0 ? rows.indexOf('inbox') : rows.findIndex((r) => /inbox/i.test(r || ''));
return inbox >= 0 && rows[inbox + 1] === 'mv-starred';
}));
const range = await page.getByText(/^\s*1–\d/).first().textContent().catch(() => null);
check('desktop: list toolbar shows a 1–N range', !!range && /1–\d/.test(range), range?.trim() ?? '');
check('desktop: search options button sits inside the field', await page.evaluate(() => {
const field = document.querySelector('.mv-topbar input[data-search-input]');
const btn = document.querySelector('.mv-topbar-search > button');
if (!field || !btn) return false;
const f = field.getBoundingClientRect();
const b = btn.getBoundingClientRect();
return b.left >= f.left && b.right <= f.right + 1;
}));
await page.locator('[data-email-id]').first().click();
await page.locator('[data-mv-sender-date]').first().waitFor({ timeout: 30_000 }).catch(() => {});
check('desktop: message date sits on the sender line', await page.locator('[data-mv-sender-date]').count() > 0);
await page.locator('[data-mv-reply-row]').waitFor({ timeout: 30_000 }).catch(() => {});
check('desktop: Reply / Reply all / Forward under the message', await page.locator('[data-mv-reply-row] button').count() === 3);
await page.keyboard.press('Escape');
await page.locator('[data-mv-compose]').first().click();
const win = page.locator('[data-mv-compose-window]');
await win.waitFor({ timeout: 20_000 }).catch(() => {});
check('desktop: compose opens a docked window', await win.count() === 1);
if (await win.count()) {
const box = await win.boundingBox();
check('desktop: compose window is docked bottom-right', !!box && box.x > 600 && box.y + box.height >= 899 - 2);
await win.locator('button[aria-label]').first().click();
await page.waitForTimeout(400);
check('desktop: compose window folds to its title bar', (await win.getAttribute('data-state')) === 'minimized');
}
check('desktop: no page errors', errors.length === 0, errors[0] ?? '');
await context.close();
}
async function phone(browser) {
const context = await browser.newContext({
viewport: { width: 412, height: 915 }, isMobile: true, hasTouch: true, deviceScaleFactor: 2,
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36',
});
const { page, errors } = await signIn(context);
check('phone: rows are Mountain View phone rows', await page.locator('[data-mv-mobile-row]').count() > 0);
check('phone: one star per row, on the preview line', await page.evaluate(() => {
const row = document.querySelector('[data-mv-mobile-row]');
if (!row) return false;
const stars = row.querySelectorAll('button[aria-pressed]');
return stars.length === 1 && !!stars[0].parentElement?.querySelector('p');
}));
await page.locator('[data-email-id]').first().click();
await page.locator('[data-mv-reply-row]').waitFor({ timeout: 30_000 }).catch(() => {});
check('phone: the message ends with Reply / Reply all / Forward', await page.locator('[data-mv-reply-row] button').count() === 3);
check('phone: no bottom bar over the message', await page.locator('nav.fixed.bottom-0').count() === 0);
check('phone: no page errors', errors.length === 0, errors[0] ?? '');
await context.close();
}
const browser = await chromium.launch();
try {
await desktop(browser);
await phone(browser);
} finally {
await browser.close();
}
const failed = results.filter((r) => !r.ok).length;
console.log(`\n${results.length - failed}/${results.length} checks passed`);
process.exit(failed ? 1 : 0);