mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
+99
-23
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
Reference in New Issue
Block a user