From f0fd0d7e315cadda57a2756db7b938326c11fe17 Mon Sep 17 00:00:00 2001 From: CasLinden Date: Mon, 11 May 2026 14:12:42 +0900 Subject: [PATCH 1/7] chore(e2e): split SKIP_PERMISSIONS flag use to E2E_MODE --- .github/workflows/peekaboo-e2e.yml | 2 +- apps/native/src/lib/e2e-boot-diagnostics.ts | 2 +- apps/native/src/main.tsx | 17 +++++++++-------- tools/computer-use-e2e/README.md | 2 +- .../peekaboo-workflow-contract-self-test.mjs | 4 ++-- tools/computer-use-e2e/run-local.mjs | 2 +- 6 files changed, 15 insertions(+), 14 deletions(-) diff --git a/.github/workflows/peekaboo-e2e.yml b/.github/workflows/peekaboo-e2e.yml index 70e649af..8f9fbdc5 100644 --- a/.github/workflows/peekaboo-e2e.yml +++ b/.github/workflows/peekaboo-e2e.yml @@ -243,7 +243,7 @@ jobs: for ((attempt = 1; attempt <= BUILD_ATTEMPTS; attempt += 1)); do if bun install --frozen-lockfile && \ cd apps/native && \ - VITE_NIXMAC_SKIP_PERMISSIONS=true ./node_modules/.bin/tauri build \ + VITE_NIXMAC_SKIP_PERMISSIONS=true VITE_NIXMAC_E2E_MODE=true ./node_modules/.bin/tauri build \ --debug \ --bundles app \ --no-sign \ diff --git a/apps/native/src/lib/e2e-boot-diagnostics.ts b/apps/native/src/lib/e2e-boot-diagnostics.ts index b35aacb3..8e7d7d63 100644 --- a/apps/native/src/lib/e2e-boot-diagnostics.ts +++ b/apps/native/src/lib/e2e-boot-diagnostics.ts @@ -14,7 +14,7 @@ const HOME_DIR_PATH_PATTERN = /\/Users\/[^/\s'"`]+/g; const NIX_SECRET_ASSIGNMENT_PATTERN = /\b(password|passwd|token|secret|api[-_]?key|private[-_]?key)\s*=\s*(".*?"|'.*?'|[^\s;]+)/gi; -const e2eBootDiagnosticsEnabled = import.meta.env.VITE_NIXMAC_SKIP_PERMISSIONS === "true"; +const e2eBootDiagnosticsEnabled = import.meta.env.VITE_NIXMAC_E2E_MODE === "true"; let bootStageCleared = false; function setStorageValue(key: string, value: string) { diff --git a/apps/native/src/main.tsx b/apps/native/src/main.tsx index c27a7ccf..6ebf913e 100644 --- a/apps/native/src/main.tsx +++ b/apps/native/src/main.tsx @@ -60,15 +60,16 @@ markBootStage("root-found"); const E2E_APP_MOUNT_RELOAD_TIMEOUT_MS = 12000; const E2E_APP_MOUNT_RELOAD_KEY = "nixmac:e2e-app-mount-reload-attempted"; -// This existing flag marks E2E/dev permission bypass builds. In that mode, boot must not -// introduce another preference IPC before the app shell has rendered. -const E2E_BOOT_PREFS_DISABLED = import.meta.env.VITE_NIXMAC_SKIP_PERMISSIONS === "true"; +// Build-time flag identifying an E2E build. In that mode, boot must not introduce +// another preference IPC before the app shell has rendered, and the harness-only +// instrumentation (heartbeat, watchdog, DOM snapshots) is active. +const E2E_MODE = import.meta.env.VITE_NIXMAC_E2E_MODE === "true"; let bootHeartbeatStopped = false; let bootHeartbeatTick = 0; let bootHeartbeat: number | null = null; -if (E2E_BOOT_PREFS_DISABLED) { +if (E2E_MODE) { bootHeartbeat = window.setInterval(() => { if (bootHeartbeatStopped) { if (bootHeartbeat !== null) { @@ -267,9 +268,9 @@ const loadPrefsForBoot = async (): Promise => { }; const initializeSentryAfterPostMountFrame = async () => { - if (E2E_BOOT_PREFS_DISABLED) { + if (E2E_MODE) { bootBreadcrumb("Sentry init skipped for E2E boot", { - viteSkipPermissions: true, + viteE2eMode: true, }); console.info("Sentry not enabled during E2E boot."); return; @@ -366,7 +367,7 @@ const captureRenderErrorAfterSentryInit = ( error: unknown, ) => { void startSentryInitOnce(reason).then(() => { - if (!E2E_BOOT_PREFS_DISABLED) { + if (!E2E_MODE) { Sentry.captureException(error); } }); @@ -384,7 +385,7 @@ window.setTimeout(() => { startSentryInitOnce("mount-timeout"); }, SENTRY_MOUNT_TIMEOUT_MS); -if (E2E_BOOT_PREFS_DISABLED) { +if (E2E_MODE) { window.setTimeout(() => { if (bootHeartbeatStopped) { return; diff --git a/tools/computer-use-e2e/README.md b/tools/computer-use-e2e/README.md index d3fb0593..f9667928 100644 --- a/tools/computer-use-e2e/README.md +++ b/tools/computer-use-e2e/README.md @@ -700,7 +700,7 @@ Then build and launch the real app as a debug `.app` bundle: ```bash cd apps/native -VITE_NIXMAC_SKIP_PERMISSIONS=true ./node_modules/.bin/tauri build \ +VITE_NIXMAC_SKIP_PERMISSIONS=true VITE_NIXMAC_E2E_MODE=true ./node_modules/.bin/tauri build \ --debug \ --bundles app \ --no-sign \ diff --git a/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs b/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs index 971b1af6..eaf0f054 100644 --- a/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs +++ b/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs @@ -233,8 +233,8 @@ assert.match(frontendApp, /markBootStage\("app-render"\)[\s\S]*markBootStage\("a assert.match(frontendWidget, /markBootStage\("darwin-widget-render"\)/, 'DarwinWidget must mark when the product widget render body is reached'); assert.match(frontendEditorPanel, /const LazyNixEditor = lazy\(async \(\) => \{[\s\S]*import\("@\/components\/kibo-ui\/nix-editor"\)[\s\S]*default: module\.NixEditor/, 'EditorPanel must lazy-load the Monaco-backed Nix editor only when a file is opened'); assert.doesNotMatch(frontendEditorPanel, /import \{ NixEditor \}/, 'EditorPanel must not import the Monaco-backed editor in the first app boot bundle'); -assert.match(frontendMain, /if \(E2E_BOOT_PREFS_DISABLED\) \{[\s\S]*setInterval\(\(\) => \{[\s\S]*boot heartbeat[\s\S]*boot heartbeat upper bound reached[\s\S]*stopBootHeartbeat[\s\S]*boot heartbeat stopped[\s\S]*nixmac:app-mounted/, 'Frontend boot must emit bounded E2E-only heartbeat breadcrumbs until App mounted and record when the bound is reached'); -assert.match(frontendMain, /E2E_BOOT_PREFS_DISABLED = import\.meta\.env\.VITE_NIXMAC_SKIP_PERMISSIONS === "true"[\s\S]*Sentry init skipped for E2E boot[\s\S]*return;/, 'Frontend boot must use the existing build-time E2E flag to skip boot-time Sentry prefs IPC without adding another IPC gate'); +assert.match(frontendMain, /if \(E2E_MODE\) \{[\s\S]*setInterval\(\(\) => \{[\s\S]*boot heartbeat[\s\S]*boot heartbeat upper bound reached[\s\S]*stopBootHeartbeat[\s\S]*boot heartbeat stopped[\s\S]*nixmac:app-mounted/, 'Frontend boot must emit bounded E2E-only heartbeat breadcrumbs until App mounted and record when the bound is reached'); +assert.match(frontendMain, /E2E_MODE = import\.meta\.env\.VITE_NIXMAC_E2E_MODE === "true"[\s\S]*Sentry init skipped for E2E boot[\s\S]*return;/, 'Frontend boot must use the build-time E2E mode flag to skip boot-time Sentry prefs IPC without adding another IPC gate'); assert.match(frontendMain, /E2E_APP_MOUNT_RELOAD_TIMEOUT_MS = 12000[\s\S]*E2E_APP_MOUNT_RELOAD_KEY[\s\S]*E2E app-mounted watchdog reloading[\s\S]*window\.location\.reload\(\)/, 'Frontend E2E boot must request one reload when the page loads but App never mounts'); assert.match(frontendMain, /scheduleE2eDomSnapshots\("post-mount"\)[\s\S]*recordE2eDomSnapshot\("app-mounted-watchdog-before-reload"[\s\S]*nixmac:e2e-dom-snapshot:watchdog-pre-reload[\s\S]*window\.setTimeout\(\(\) => \{[\s\S]*window\.location\.reload\(\)[\s\S]*250/, 'Frontend E2E boot must capture post-mount DOM snapshots and a durable watchdog snapshot before forced reload'); assert.match(frontendMain, /const renderApp = \(\) => \{[\s\S]*React render start[\s\S]*Sentry\.ErrorBoundary[\s\S]*[\s\S]*React render scheduled/, 'Frontend boot must render the app immediately with an error boundary'); diff --git a/tools/computer-use-e2e/run-local.mjs b/tools/computer-use-e2e/run-local.mjs index 131b919a..d5f73130 100644 --- a/tools/computer-use-e2e/run-local.mjs +++ b/tools/computer-use-e2e/run-local.mjs @@ -67,7 +67,7 @@ const TEST_DATA_DIR = path.join(REPO_ROOT, 'apps/native/e2e-tauri/tests/data'); const DEFAULT_FIXTURE = 'add-font.jsonl'; const DETERMINISTIC_APP_COMMAND = [ 'cd apps/native', - 'VITE_NIXMAC_SKIP_PERMISSIONS=true ./node_modules/.bin/tauri build --debug --bundles app --no-sign --config src-tauri/tauri.conf.dev.json', + 'VITE_NIXMAC_SKIP_PERMISSIONS=true VITE_NIXMAC_E2E_MODE=true ./node_modules/.bin/tauri build --debug --bundles app --no-sign --config src-tauri/tauri.conf.dev.json', 'open -n ../../target/debug/bundle/macos/nixmac.app', ].join(' && '); const REAL_APP_PATH = process.env.NIXMAC_COMPUTER_USE_APP ?? '/Applications/nixmac.app'; From f4cdbc09e3624a40193fa97d781dc8615d8ec062 Mon Sep 17 00:00:00 2001 From: CasLinden Date: Mon, 11 May 2026 14:35:22 +0900 Subject: [PATCH 2/7] refactor(sentry): extract sanitization from main.tsx and dedupe similar e2e code --- .../native/src/components/StartupFallback.tsx | 33 ++ apps/native/src/lib/e2e-boot-diagnostics.ts | 69 ++-- apps/native/src/lib/sentry/init.ts | 167 ++++++++++ apps/native/src/lib/sentry/sanitize.ts | 119 +++++++ apps/native/src/main.tsx | 315 +----------------- .../peekaboo-workflow-contract-self-test.mjs | 28 +- 6 files changed, 368 insertions(+), 363 deletions(-) create mode 100644 apps/native/src/components/StartupFallback.tsx create mode 100644 apps/native/src/lib/sentry/init.ts create mode 100644 apps/native/src/lib/sentry/sanitize.ts diff --git a/apps/native/src/components/StartupFallback.tsx b/apps/native/src/components/StartupFallback.tsx new file mode 100644 index 00000000..d3560f24 --- /dev/null +++ b/apps/native/src/components/StartupFallback.tsx @@ -0,0 +1,33 @@ +export function StartupFallback() { + return ( +
+
+
nixmac could not render
+
+ The app shell hit a startup error. Diagnostic breadcrumbs were recorded for this run. +
+
+
+ ); +} diff --git a/apps/native/src/lib/e2e-boot-diagnostics.ts b/apps/native/src/lib/e2e-boot-diagnostics.ts index 8e7d7d63..3328786e 100644 --- a/apps/native/src/lib/e2e-boot-diagnostics.ts +++ b/apps/native/src/lib/e2e-boot-diagnostics.ts @@ -1,20 +1,11 @@ +import { sanitizeDiagnosticText } from "@/lib/sentry/sanitize"; import { darwinAPI } from "@/tauri-api"; const MAX_DETAIL_LENGTH = 1_000; const APP_TITLE = "nixmac"; -const REDACTED = "[REDACTED]"; -const EMAIL_PATTERN = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi; -const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9\-._~+/]+=*/gi; -const GITHUB_TOKEN_PATTERN = /\bgh[pousr]_[A-Za-z0-9]{20,}\b/gi; -const OPENAI_TOKEN_PATTERN = /\bsk-[A-Za-z0-9]{20,}\b/g; -const ANTHROPIC_TOKEN_PATTERN = /\bsk-ant-[A-Za-z0-9_-]{20,}\b/gi; -const PRIVATE_KEY_BLOCK_PATTERN = - /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g; -const HOME_DIR_PATH_PATTERN = /\/Users\/[^/\s'"`]+/g; -const NIX_SECRET_ASSIGNMENT_PATTERN = - /\b(password|passwd|token|secret|api[-_]?key|private[-_]?key)\s*=\s*(".*?"|'.*?'|[^\s;]+)/gi; - -const e2eBootDiagnosticsEnabled = import.meta.env.VITE_NIXMAC_E2E_MODE === "true"; + +const e2eBootDiagnosticsEnabled = + import.meta.env.VITE_NIXMAC_E2E_MODE === "true"; let bootStageCleared = false; function setStorageValue(key: string, value: string) { @@ -38,37 +29,8 @@ function simpleHash(value: string) { return (hash >>> 0).toString(16).padStart(8, "0"); } -function sanitizeUrl(value: string): string { - if (!(value.startsWith("http://") || value.startsWith("https://"))) { - return value; - } - - try { - const url = new URL(value); - url.search = ""; - return url.toString(); - } catch { - return value; - } -} - -export function sanitizeE2eDiagnosticText(value: string): string { - let sanitized = value; - sanitized = sanitized.replace(EMAIL_PATTERN, REDACTED); - sanitized = sanitized.replace(BEARER_TOKEN_PATTERN, REDACTED); - sanitized = sanitized.replace(GITHUB_TOKEN_PATTERN, REDACTED); - sanitized = sanitized.replace(OPENAI_TOKEN_PATTERN, REDACTED); - sanitized = sanitized.replace(ANTHROPIC_TOKEN_PATTERN, REDACTED); - sanitized = sanitized.replace(PRIVATE_KEY_BLOCK_PATTERN, REDACTED); - sanitized = sanitized.replace(HOME_DIR_PATH_PATTERN, "/Users/[REDACTED_USER]"); - sanitized = sanitized.replace(NIX_SECRET_ASSIGNMENT_PATTERN, (_, key: string) => { - return `${key} = ${REDACTED}`; - }); - return sanitizeUrl(sanitized).replace(/[^\t\x20-\x7e]/g, ""); -} - function excerpt(value: string, maxLength: number) { - const sanitized = sanitizeE2eDiagnosticText(value).replace(/\s+/g, " ").trim(); + const sanitized = sanitizeDiagnosticText(value).replace(/\s+/g, " ").trim(); if (sanitized.length <= maxLength) return sanitized; return `${sanitized.slice(0, maxLength)}...`; } @@ -118,14 +80,19 @@ export function bootBreadcrumb(label: string, detail?: unknown) { const clientTimestampUnixMs = Date.now(); const summarized = summarizeDetail(detail); console.info(`[nixmac boot] ${label}`, summarized ?? ""); - void darwinAPI.debug.logBreadcrumb(label, summarized, clientTimestampUnixMs).catch(() => {}); + void darwinAPI.debug + .logBreadcrumb(label, summarized, clientTimestampUnixMs) + .catch(() => {}); } type DomSnapshotOptions = { storagePrefix?: string; }; -export function recordE2eDomSnapshot(label: string, options: DomSnapshotOptions = {}) { +export function recordE2eDomSnapshot( + label: string, + options: DomSnapshotOptions = {}, +) { if (!e2eBootDiagnosticsEnabled) return; const root = document.getElementById("root"); @@ -133,8 +100,10 @@ export function recordE2eDomSnapshot(label: string, options: DomSnapshotOptions const rawRootHtml = root?.innerHTML ?? ""; const snapshot = { label, - title: sanitizeE2eDiagnosticText(document.title || ""), - bootStage: sanitizeE2eDiagnosticText(document.documentElement.dataset.nixmacBootStage ?? ""), + title: sanitizeDiagnosticText(document.title || ""), + bootStage: sanitizeDiagnosticText( + document.documentElement.dataset.nixmacBootStage ?? "", + ), rootChildCount: root?.childElementCount ?? null, bodyTextLength: rawBodyText.length, rootHtmlLength: rawRootHtml.length, @@ -169,7 +138,11 @@ export function recordE2eDomSnapshot(label: string, options: DomSnapshotOptions bootBreadcrumb(`E2E DOM snapshot ${label} html`, htmlExcerpt); } -export function scheduleE2eDomSnapshots(prefix: string, count = 5, intervalMs = 2_000) { +export function scheduleE2eDomSnapshots( + prefix: string, + count = 5, + intervalMs = 2_000, +) { if (!e2eBootDiagnosticsEnabled) return; let emitted = 0; diff --git a/apps/native/src/lib/sentry/init.ts b/apps/native/src/lib/sentry/init.ts new file mode 100644 index 00000000..dceb204a --- /dev/null +++ b/apps/native/src/lib/sentry/init.ts @@ -0,0 +1,167 @@ +import * as Sentry from "@sentry/react"; +import { bootBreadcrumb } from "@/lib/e2e-boot-diagnostics"; +import { darwinAPI } from "@/tauri-api"; +import type { UiPrefs as DarwinPrefs } from "@/types/shared"; +import { sanitizeSentryEvent } from "./sanitize"; + +const E2E_MODE = import.meta.env.VITE_NIXMAC_E2E_MODE === "true"; + +const PREFS_BOOT_TIMEOUT_MS = 8000; +const SENTRY_MOUNT_TIMEOUT_MS = 5000; + +const loadPrefsForBoot = async (): Promise => { + bootBreadcrumb("ui_get_prefs invoke start"); + let settled = false; + let timedOut = false; + + const prefsPromise = darwinAPI.ui + .getPrefs() + .then((prefs) => { + settled = true; + bootBreadcrumb( + timedOut ? "ui_get_prefs invoke success after timeout" : "ui_get_prefs invoke success", + { + sendDiagnostics: prefs.sendDiagnostics, + }, + ); + return prefs; + }) + .catch((error) => { + settled = true; + bootBreadcrumb( + timedOut ? "ui_get_prefs invoke error after timeout" : "ui_get_prefs invoke error", + error, + ); + return null; + }); + + const timeoutPromise = new Promise((resolve) => { + window.setTimeout(() => { + if (!settled) { + timedOut = true; + bootBreadcrumb("ui_get_prefs invoke timeout", `${PREFS_BOOT_TIMEOUT_MS}ms`); + } + resolve(null); + }, PREFS_BOOT_TIMEOUT_MS); + }); + + return Promise.race([prefsPromise, timeoutPromise]); +}; + +const initializeSentryAfterPostMountFrame = async () => { + if (E2E_MODE) { + bootBreadcrumb("Sentry init skipped for E2E boot", { + viteE2eMode: true, + }); + console.info("Sentry not enabled during E2E boot."); + return; + } + + const prefs = await loadPrefsForBoot(); + const sendDiagnostics = prefs?.sendDiagnostics ?? false; + // Vite exposes environment variables at build time, so read the Sentry DSN and other config from there. + const sentryDsn = (import.meta.env.VITE_SENTRY_DSN || "").toString().trim(); + const sentryEnabled = sendDiagnostics && sentryDsn.length > 0; + + const release = (import.meta.env.VITE_NIXMAC_VERSION || "unknown").toString(); + const environment = ( + import.meta.env.VITE_NIXMAC_ENV || + import.meta.env.MODE || + "prod" + ).toString(); + if (sentryEnabled) { + bootBreadcrumb("Sentry init enabled", { environment, release }); + Sentry.init({ + dsn: sentryDsn, + environment: environment, + release: release, + defaultIntegrations: false, // Disable default integrations to avoid issues in tauri + integrations: [Sentry.browserTracingIntegration()], + // Disable all breadcrumbs by returning `null` + beforeBreadcrumb: () => null, + beforeSend: (event) => { + const sanitized = sanitizeSentryEvent(event); + return sanitized as typeof event; + }, + tracesSampleRate: 0.1, + }); + console.info("Sentry initialized.", { + environment: environment, + release: release, + }); + } else { + bootBreadcrumb("Sentry init skipped", { + sendDiagnostics, + hasDsn: sentryDsn.length > 0, + }); + console.info("Sentry not enabled."); + } +}; + +let sentryInitStarted = false; +let sentryInitPromise: Promise | null = null; + +const scheduleAfterPostMountFrame = (callback: () => void) => { + bootBreadcrumb("post-mount init scheduled"); + const run = () => { + bootBreadcrumb("post-mount first frame elapsed"); + callback(); + }; + + if (typeof window.requestAnimationFrame === "function") { + window.requestAnimationFrame(() => { + window.requestAnimationFrame(run); + }); + return; + } + + window.setTimeout(run, 0); +}; + +const startSentryInitOnce = ( + reason: "app-mounted" | "mount-timeout" | "render-error" | "render-fatal", +): Promise => { + if (sentryInitStarted) { + bootBreadcrumb("Sentry init start ignored", { reason }); + return sentryInitPromise ?? Promise.resolve(); + } + + sentryInitStarted = true; + bootBreadcrumb("Sentry init start requested", { reason }); + sentryInitPromise = new Promise((resolve) => { + scheduleAfterPostMountFrame(() => { + void initializeSentryAfterPostMountFrame() + .catch((error) => { + bootBreadcrumb("post-render Sentry init error", error); + }) + .finally(resolve); + }); + }); + + return sentryInitPromise; +}; + +export function captureRenderError( + reason: "render-error" | "render-fatal", + error: unknown, +): void { + void startSentryInitOnce(reason).then(() => { + if (!E2E_MODE) { + Sentry.captureException(error); + } + }); +} + +export function attachSentry(): void { + window.addEventListener( + "nixmac:app-mounted", + () => { + startSentryInitOnce("app-mounted"); + }, + { once: true }, + ); + + window.setTimeout(() => { + startSentryInitOnce("mount-timeout"); + }, SENTRY_MOUNT_TIMEOUT_MS); +} diff --git a/apps/native/src/lib/sentry/sanitize.ts b/apps/native/src/lib/sentry/sanitize.ts new file mode 100644 index 00000000..81a93215 --- /dev/null +++ b/apps/native/src/lib/sentry/sanitize.ts @@ -0,0 +1,119 @@ +const REDACTED = "[REDACTED]"; +const REDACTED_APP_CONTENT = "[REDACTED_APP_CONTENT]"; + +// These are really more common to web apps, but we'll include them just in case. +const SENSITIVE_KEY_PATTERN = + /password|passwd|pwd|secret|token|api[-_]?key|authorization|cookie|session|bearer|email|phone|ssn|credit|card|cvv|cvc|iban|account|address|ip|private[-_]?key|ssh|gpg/i; + +// These are the kinds of things we work with in nixmac, although it's extremely hard +// to guess exactly how they might appear in the data - for example, a diff or config +// file might be embedded in a larger string, or split across multiple fields, etc. +// So we'll just broadly look for any keys that might indicate the value contains app +// content and redact it wholesale, rather than trying to apply regexes to extract specific +// secrets from within them. +const APP_CONTENT_KEY_PATTERN = + /prompt|messages|conversation|completion|response|input|output|diff|patch|nix(?:[-_]?darwin)?(?:[-_]?config)?|configuration|config[-_]?text|file[-_]?content|command|args|stderr|stdout|path|cwd|home/i; + +const EMAIL_PATTERN = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi; +const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9\-._~+/]+=*/gi; +const GITHUB_TOKEN_PATTERN = /\bgh[pousr]_[A-Za-z0-9]{20,}\b/gi; +const OPENAI_TOKEN_PATTERN = /\bsk-[A-Za-z0-9]{20,}\b/g; +const ANTHROPIC_TOKEN_PATTERN = /\bsk-ant-[A-Za-z0-9_-]{20,}\b/gi; +const PRIVATE_KEY_BLOCK_PATTERN = + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g; +const HOME_DIR_PATH_PATTERN = /\/Users\/[^/\s'"`]+/g; +const NIX_SECRET_ASSIGNMENT_PATTERN = + /\b(password|passwd|token|secret|api[-_]?key|private[-_]?key)\s*=\s*(".*?"|'.*?'|[^\s;]+)/gi; + +const sanitizeUrl = (value: string): string => { + if (!(value.startsWith("http://") || value.startsWith("https://"))) { + return value; + } + + try { + const url = new URL(value); + if (url.search) { + url.search = ""; + } + return url.toString(); + } catch { + return value; + } +}; + +const sanitizeString = (value: string): string => { + let sanitized = value; + sanitized = sanitized.replace(EMAIL_PATTERN, REDACTED); + sanitized = sanitized.replace(BEARER_TOKEN_PATTERN, REDACTED); + sanitized = sanitized.replace(GITHUB_TOKEN_PATTERN, REDACTED); + sanitized = sanitized.replace(OPENAI_TOKEN_PATTERN, REDACTED); + sanitized = sanitized.replace(ANTHROPIC_TOKEN_PATTERN, REDACTED); + sanitized = sanitized.replace(PRIVATE_KEY_BLOCK_PATTERN, REDACTED); + sanitized = sanitized.replace( + HOME_DIR_PATH_PATTERN, + "/Users/[REDACTED_USER]", + ); + sanitized = sanitized.replace( + NIX_SECRET_ASSIGNMENT_PATTERN, + (_, key: string) => { + return `${key} = ${REDACTED}`; + }, + ); + + return sanitizeUrl(sanitized); +}; + +const sanitizeSentryValue = (value: unknown, keyName = ""): unknown => { + if (SENSITIVE_KEY_PATTERN.test(keyName)) { + return REDACTED; + } + + if (APP_CONTENT_KEY_PATTERN.test(keyName)) { + if (typeof value === "string" && value.length > 0) { + return REDACTED_APP_CONTENT; + } + if (Array.isArray(value)) { + return REDACTED_APP_CONTENT; + } + if (value && typeof value === "object") { + return REDACTED_APP_CONTENT; + } + } + + if (typeof value === "string") { + return sanitizeString(value); + } + + if (Array.isArray(value)) { + return value.map((entry) => sanitizeSentryValue(entry)); + } + + if (value && typeof value === "object") { + const sanitizedObject: Record = {}; + for (const [childKey, childValue] of Object.entries(value)) { + sanitizedObject[childKey] = sanitizeSentryValue(childValue, childKey); + } + return sanitizedObject; + } + + return value; +}; + +export function sanitizeSentryEvent(event: unknown): unknown { + const sanitized = sanitizeSentryValue(event); + if (!sanitized || typeof sanitized !== "object") { + return sanitized; + } + + const sanitizedRecord = sanitized as Record; + delete sanitizedRecord.user; + delete sanitizedRecord.server_name; + + return sanitizedRecord; +} + +// Used by boot diagnostics for text snapshots; same regex pipeline as the Sentry path +// plus a non-printable strip suitable for embedding in JSON/HTML diagnostics output. +export function sanitizeDiagnosticText(value: string): string { + return sanitizeString(value).replace(/[^\t\x20-\x7e]/g, ""); +} diff --git a/apps/native/src/main.tsx b/apps/native/src/main.tsx index 6ebf913e..b10c06dc 100644 --- a/apps/native/src/main.tsx +++ b/apps/native/src/main.tsx @@ -3,48 +3,14 @@ import ReactDOM from "react-dom/client"; import * as Sentry from "@sentry/react"; import App from "./App"; import "./index.css"; -import { darwinAPI } from "@/tauri-api"; -import type { UiPrefs as DarwinPrefs } from "@/types/shared"; import { bootBreadcrumb, markBootStage, recordE2eDomSnapshot, scheduleE2eDomSnapshots, } from "@/lib/e2e-boot-diagnostics"; - -function FallbackComponent() { - return ( -
-
-
nixmac could not render
-
- The app shell hit a startup error. Diagnostic breadcrumbs were recorded for this run. -
-
-
- ); -} +import { attachSentry, captureRenderError } from "@/lib/sentry/init"; +import { StartupFallback } from "@/components/StartupFallback"; const rootElement = document.getElementById("root"); @@ -60,8 +26,7 @@ markBootStage("root-found"); const E2E_APP_MOUNT_RELOAD_TIMEOUT_MS = 12000; const E2E_APP_MOUNT_RELOAD_KEY = "nixmac:e2e-app-mount-reload-attempted"; -// Build-time flag identifying an E2E build. In that mode, boot must not introduce -// another preference IPC before the app shell has rendered, and the harness-only +// Build-time flag identifying an E2E build. In that mode, the harness-only // instrumentation (heartbeat, watchdog, DOM snapshots) is active. const E2E_MODE = import.meta.env.VITE_NIXMAC_E2E_MODE === "true"; @@ -117,273 +82,9 @@ window.addEventListener("unhandledrejection", (event) => { bootBreadcrumb("window unhandled rejection", event.reason); }); -const REDACTED = "[REDACTED]"; -const REDACTED_APP_CONTENT = "[REDACTED_APP_CONTENT]"; - -// These are really more common to web apps, but we'll include them just in case. -const SENSITIVE_KEY_PATTERN = - /password|passwd|pwd|secret|token|api[-_]?key|authorization|cookie|session|bearer|email|phone|ssn|credit|card|cvv|cvc|iban|account|address|ip|private[-_]?key|ssh|gpg/i; - -// These are the kinds of things we work with in nixmac, although it's extremely hard -// to guess exactly how they might appear in the data - for example, a diff or config -// file might be embedded in a larger string, or split across multiple fields, etc. -// So we'll just broadly look for any keys that might indicate the value contains app -// content and redact it wholesale, rather than trying to apply regexes to extract specific -// secrets from within them. -const APP_CONTENT_KEY_PATTERN = - /prompt|messages|conversation|completion|response|input|output|diff|patch|nix(?:[-_]?darwin)?(?:[-_]?config)?|configuration|config[-_]?text|file[-_]?content|command|args|stderr|stdout|path|cwd|home/i; - -const EMAIL_PATTERN = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi; -const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9\-._~+/]+=*/gi; -const GITHUB_TOKEN_PATTERN = /\bgh[pousr]_[A-Za-z0-9]{20,}\b/gi; -const OPENAI_TOKEN_PATTERN = /\bsk-[A-Za-z0-9]{20,}\b/g; -const ANTHROPIC_TOKEN_PATTERN = /\bsk-ant-[A-Za-z0-9_-]{20,}\b/gi; -const PRIVATE_KEY_BLOCK_PATTERN = - /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g; -const HOME_DIR_PATH_PATTERN = /\/Users\/[^/\s'"`]+/g; -const NIX_SECRET_ASSIGNMENT_PATTERN = - /\b(password|passwd|token|secret|api[-_]?key|private[-_]?key)\s*=\s*(".*?"|'.*?'|[^\s;]+)/gi; - -const sanitizeUrl = (value: string): string => { - if (!(value.startsWith("http://") || value.startsWith("https://"))) { - return value; - } - - try { - const url = new URL(value); - if (url.search) { - url.search = ""; - } - return url.toString(); - } catch { - return value; - } -}; - -const sanitizeString = (value: string): string => { - let sanitized = value; - sanitized = sanitized.replace(EMAIL_PATTERN, REDACTED); - sanitized = sanitized.replace(BEARER_TOKEN_PATTERN, REDACTED); - sanitized = sanitized.replace(GITHUB_TOKEN_PATTERN, REDACTED); - sanitized = sanitized.replace(OPENAI_TOKEN_PATTERN, REDACTED); - sanitized = sanitized.replace(ANTHROPIC_TOKEN_PATTERN, REDACTED); - sanitized = sanitized.replace(PRIVATE_KEY_BLOCK_PATTERN, REDACTED); - sanitized = sanitized.replace(HOME_DIR_PATH_PATTERN, "/Users/[REDACTED_USER]"); - sanitized = sanitized.replace(NIX_SECRET_ASSIGNMENT_PATTERN, (_, key: string) => { - return `${key} = ${REDACTED}`; - }); - - return sanitizeUrl(sanitized); -}; - -const sanitizeSentryValue = (value: unknown, keyName = ""): unknown => { - if (SENSITIVE_KEY_PATTERN.test(keyName)) { - return REDACTED; - } - - if (APP_CONTENT_KEY_PATTERN.test(keyName)) { - if (typeof value === "string" && value.length > 0) { - return REDACTED_APP_CONTENT; - } - if (Array.isArray(value)) { - return REDACTED_APP_CONTENT; - } - if (value && typeof value === "object") { - return REDACTED_APP_CONTENT; - } - } - - if (typeof value === "string") { - return sanitizeString(value); - } - - if (Array.isArray(value)) { - return value.map((entry) => sanitizeSentryValue(entry)); - } - - if (value && typeof value === "object") { - const sanitizedObject: Record = {}; - for (const [childKey, childValue] of Object.entries(value)) { - sanitizedObject[childKey] = sanitizeSentryValue(childValue, childKey); - } - return sanitizedObject; - } - - return value; -}; - -const sanitizeSentryEvent = (event: unknown): unknown => { - const sanitized = sanitizeSentryValue(event); - if (!sanitized || typeof sanitized !== "object") { - return sanitized; - } - - const sanitizedRecord = sanitized as Record; - delete sanitizedRecord.user; - delete sanitizedRecord.server_name; - - return sanitizedRecord; -}; - -const PREFS_BOOT_TIMEOUT_MS = 8000; -const SENTRY_MOUNT_TIMEOUT_MS = 5000; - -const loadPrefsForBoot = async (): Promise => { - bootBreadcrumb("ui_get_prefs invoke start"); - let settled = false; - let timedOut = false; - - const prefsPromise = darwinAPI.ui - .getPrefs() - .then((prefs) => { - settled = true; - bootBreadcrumb( - timedOut ? "ui_get_prefs invoke success after timeout" : "ui_get_prefs invoke success", - { - sendDiagnostics: prefs.sendDiagnostics, - }, - ); - return prefs; - }) - .catch((error) => { - settled = true; - bootBreadcrumb( - timedOut ? "ui_get_prefs invoke error after timeout" : "ui_get_prefs invoke error", - error, - ); - return null; - }); - - const timeoutPromise = new Promise((resolve) => { - window.setTimeout(() => { - if (!settled) { - timedOut = true; - bootBreadcrumb("ui_get_prefs invoke timeout", `${PREFS_BOOT_TIMEOUT_MS}ms`); - } - resolve(null); - }, PREFS_BOOT_TIMEOUT_MS); - }); - - return Promise.race([prefsPromise, timeoutPromise]); -}; - -const initializeSentryAfterPostMountFrame = async () => { - if (E2E_MODE) { - bootBreadcrumb("Sentry init skipped for E2E boot", { - viteE2eMode: true, - }); - console.info("Sentry not enabled during E2E boot."); - return; - } - - const prefs = await loadPrefsForBoot(); - const sendDiagnostics = prefs?.sendDiagnostics ?? false; - // Vite exposes environment variables at build time, so read the Sentry DSN and other config from there. - const sentryDsn = (import.meta.env.VITE_SENTRY_DSN || "").toString().trim(); - const sentryEnabled = sendDiagnostics && sentryDsn.length > 0; - - const release = (import.meta.env.VITE_NIXMAC_VERSION || "unknown").toString(); - const environment = ( - import.meta.env.VITE_NIXMAC_ENV || - import.meta.env.MODE || - "prod" - ).toString(); - if (sentryEnabled) { - bootBreadcrumb("Sentry init enabled", { environment, release }); - Sentry.init({ - dsn: sentryDsn, - environment: environment, - release: release, - defaultIntegrations: false, // Disable default integrations to avoid issues in tauri - integrations: [Sentry.browserTracingIntegration()], - // Disable all breadcrumbs by returning `null` - beforeBreadcrumb: () => null, - beforeSend: (event) => { - const sanitized = sanitizeSentryEvent(event); - // console.log("[Sentry beforeSend]", { original: event, sanitized }); - return sanitized as typeof event; - }, - tracesSampleRate: 0.1, - }); - console.info("Sentry initialized.", { - environment: environment, - release: release, - }); - } else { - bootBreadcrumb("Sentry init skipped", { - sendDiagnostics, - hasDsn: sentryDsn.length > 0, - }); - console.info("Sentry not enabled."); - } -}; +attachSentry(); const root = ReactDOM.createRoot(rootElement); -let sentryInitStarted = false; -let sentryInitPromise: Promise | null = null; - -const scheduleAfterPostMountFrame = (callback: () => void) => { - bootBreadcrumb("post-mount init scheduled"); - const run = () => { - bootBreadcrumb("post-mount first frame elapsed"); - callback(); - }; - - if (typeof window.requestAnimationFrame === "function") { - window.requestAnimationFrame(() => { - window.requestAnimationFrame(run); - }); - return; - } - - window.setTimeout(run, 0); -}; - -const startSentryInitOnce = ( - reason: "app-mounted" | "mount-timeout" | "render-error" | "render-fatal", -): Promise => { - if (sentryInitStarted) { - bootBreadcrumb("Sentry init start ignored", { reason }); - return sentryInitPromise ?? Promise.resolve(); - } - - sentryInitStarted = true; - bootBreadcrumb("Sentry init start requested", { reason }); - sentryInitPromise = new Promise((resolve) => { - scheduleAfterPostMountFrame(() => { - void initializeSentryAfterPostMountFrame() - .catch((error) => { - bootBreadcrumb("post-render Sentry init error", error); - }) - .finally(resolve); - }); - }); - - return sentryInitPromise; -}; - -const captureRenderErrorAfterSentryInit = ( - reason: "render-error" | "render-fatal", - error: unknown, -) => { - void startSentryInitOnce(reason).then(() => { - if (!E2E_MODE) { - Sentry.captureException(error); - } - }); -}; - -window.addEventListener( - "nixmac:app-mounted", - () => { - startSentryInitOnce("app-mounted"); - }, - { once: true }, -); - -window.setTimeout(() => { - startSentryInitOnce("mount-timeout"); -}, SENTRY_MOUNT_TIMEOUT_MS); if (E2E_MODE) { window.setTimeout(() => { @@ -418,11 +119,11 @@ const renderApp = () => { root.render( } + fallback={} onError={(error, componentStack) => { console.error("ErrorBoundary caught:", error, componentStack); bootBreadcrumb("ErrorBoundary caught render error", error); - captureRenderErrorAfterSentryInit("render-error", error); + captureRenderError("render-error", error); }} > @@ -438,8 +139,8 @@ try { } catch (error) { bootBreadcrumb("React render fatal error", error); markBootStage("react-render-fatal"); - captureRenderErrorAfterSentryInit("render-fatal", error); - root.render(); + captureRenderError("render-fatal", error); + root.render(); } window.setTimeout(() => { diff --git a/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs b/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs index eaf0f054..c50aca3a 100644 --- a/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs +++ b/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs @@ -23,6 +23,9 @@ const frontendAppPath = path.join(repoRoot, 'apps/native/src/App.tsx'); const frontendWidgetPath = path.join(repoRoot, 'apps/native/src/components/widget/widget.tsx'); const frontendEditorPanelPath = path.join(repoRoot, 'apps/native/src/components/widget/overlays/editor-panel.tsx'); const frontendBootDiagnosticsPath = path.join(repoRoot, 'apps/native/src/lib/e2e-boot-diagnostics.ts'); +const frontendSentryInitPath = path.join(repoRoot, 'apps/native/src/lib/sentry/init.ts'); +const frontendSentrySanitizePath = path.join(repoRoot, 'apps/native/src/lib/sentry/sanitize.ts'); +const frontendStartupFallbackPath = path.join(repoRoot, 'apps/native/src/components/StartupFallback.tsx'); const tauriApiPath = path.join(repoRoot, 'apps/native/src/tauri-api.ts'); const workflow = readFileSync(workflowPath, 'utf8'); const productProof = readFileSync(productProofPath, 'utf8'); @@ -41,6 +44,9 @@ const frontendApp = readFileSync(frontendAppPath, 'utf8'); const frontendWidget = readFileSync(frontendWidgetPath, 'utf8'); const frontendEditorPanel = readFileSync(frontendEditorPanelPath, 'utf8'); const frontendBootDiagnostics = readFileSync(frontendBootDiagnosticsPath, 'utf8'); +const frontendSentryInit = readFileSync(frontendSentryInitPath, 'utf8'); +const frontendSentrySanitize = readFileSync(frontendSentrySanitizePath, 'utf8'); +const frontendStartupFallback = readFileSync(frontendStartupFallbackPath, 'utf8'); const tauriApi = readFileSync(tauriApiPath, 'utf8'); function section(startPattern, endPattern = null) { @@ -224,27 +230,33 @@ assert.match(frontendBootDiagnostics, /let bootStageCleared = false[\s\S]*export assert.match(frontendBootDiagnostics, /function markNativeBootStage\(stage: string\)[\s\S]*darwinAPI\.debug\.markBootStage\(stage, Date\.now\(\)\)[\s\S]*markNativeBootStage\(normalizedStage\)/, 'Frontend boot diagnostics must mirror boot stages to the native debug command before the event-loop stall boundary'); assert.match(frontendBootDiagnostics, /export function clearBootStage[\s\S]*document\.title = APP_TITLE[\s\S]*nixmac:e2e-boot-stage", "mounted"/, 'Frontend boot diagnostics must restore the normal window title after mount'); assert.match(frontendBootDiagnostics, /export function clearBootStage[\s\S]*markNativeBootStage\("mounted"\)/, 'Frontend boot diagnostics must clear the native title marker after App mount'); -assert.match(frontendBootDiagnostics, /export function sanitizeE2eDiagnosticText[\s\S]*EMAIL_PATTERN[\s\S]*BEARER_TOKEN_PATTERN[\s\S]*OPENAI_TOKEN_PATTERN[\s\S]*HOME_DIR_PATH_PATTERN/, 'E2E DOM diagnostics must sanitize secret-shaped text before persisting report artifacts'); +assert.match(frontendSentrySanitize, /export function sanitizeDiagnosticText[\s\S]*sanitizeString/, 'E2E DOM diagnostics text sanitizer must be exported from the shared sanitize module'); +assert.match(frontendSentrySanitize, /EMAIL_PATTERN[\s\S]*BEARER_TOKEN_PATTERN[\s\S]*OPENAI_TOKEN_PATTERN[\s\S]*HOME_DIR_PATH_PATTERN/, 'Shared sanitize module must apply the secret-shaped text patterns used by both Sentry events and E2E diagnostics'); +assert.match(frontendBootDiagnostics, /import \{ sanitizeDiagnosticText \} from "@\/lib\/sentry\/sanitize"/, 'E2E boot diagnostics must consume sanitization from the shared sentry/sanitize module rather than a duplicate regex set'); assert.match(frontendBootDiagnostics, /export function recordE2eDomSnapshot[\s\S]*storagePrefix[\s\S]*nixmac:e2e-dom-snapshot[\s\S]*document\.documentElement\.dataset\.nixmacE2eDomSnapshot[\s\S]*`\$\{storagePrefix\}:last`[\s\S]*E2E DOM snapshot \$\{label\} summary[\s\S]*E2E DOM snapshot \$\{label\} text[\s\S]*E2E DOM snapshot \$\{label\} html/, 'E2E DOM diagnostics must persist bounded snapshots through both out-of-band DOM/localStorage state and breadcrumb artifacts'); assert.match(frontendBootDiagnostics, /export function scheduleE2eDomSnapshots[\s\S]*count = 5[\s\S]*intervalMs = 2_000[\s\S]*emitted < count/, 'E2E DOM diagnostics must schedule a bounded post-mount snapshot series and self-stop'); -assert.match(frontendMain, /PREFS_BOOT_TIMEOUT_MS = 8000[\s\S]*ui_get_prefs invoke start[\s\S]*success after timeout[\s\S]*Promise\.race\(\[prefsPromise, timeoutPromise\]\)/, 'Frontend boot must log prefs IPC progress with clear after-timeout labels'); +assert.match(frontendSentryInit, /PREFS_BOOT_TIMEOUT_MS = 8000[\s\S]*ui_get_prefs invoke start[\s\S]*success after timeout[\s\S]*Promise\.race\(\[prefsPromise, timeoutPromise\]\)/, 'Sentry init module must log prefs IPC progress with clear after-timeout labels'); assert.match(frontendMain, /markBootStage\("main-loaded"\)[\s\S]*markBootStage\("root-found"\)[\s\S]*markBootStage\("react-render-start"\)[\s\S]*markBootStage\("react-render-scheduled"\)/, 'Frontend boot must synchronously mark module, root, and render-scheduling stages'); assert.match(frontendApp, /markBootStage\("app-render"\)[\s\S]*markBootStage\("app-effect"\)[\s\S]*clearBootStage\(\)/, 'App must synchronously mark render/effect stages and clear the E2E title marker after mount'); assert.match(frontendWidget, /markBootStage\("darwin-widget-render"\)/, 'DarwinWidget must mark when the product widget render body is reached'); assert.match(frontendEditorPanel, /const LazyNixEditor = lazy\(async \(\) => \{[\s\S]*import\("@\/components\/kibo-ui\/nix-editor"\)[\s\S]*default: module\.NixEditor/, 'EditorPanel must lazy-load the Monaco-backed Nix editor only when a file is opened'); assert.doesNotMatch(frontendEditorPanel, /import \{ NixEditor \}/, 'EditorPanel must not import the Monaco-backed editor in the first app boot bundle'); assert.match(frontendMain, /if \(E2E_MODE\) \{[\s\S]*setInterval\(\(\) => \{[\s\S]*boot heartbeat[\s\S]*boot heartbeat upper bound reached[\s\S]*stopBootHeartbeat[\s\S]*boot heartbeat stopped[\s\S]*nixmac:app-mounted/, 'Frontend boot must emit bounded E2E-only heartbeat breadcrumbs until App mounted and record when the bound is reached'); -assert.match(frontendMain, /E2E_MODE = import\.meta\.env\.VITE_NIXMAC_E2E_MODE === "true"[\s\S]*Sentry init skipped for E2E boot[\s\S]*return;/, 'Frontend boot must use the build-time E2E mode flag to skip boot-time Sentry prefs IPC without adding another IPC gate'); +assert.match(frontendMain, /E2E_MODE = import\.meta\.env\.VITE_NIXMAC_E2E_MODE === "true"/, 'Frontend main must derive E2E_MODE from the build-time Vite flag'); +assert.match(frontendSentryInit, /E2E_MODE = import\.meta\.env\.VITE_NIXMAC_E2E_MODE === "true"[\s\S]*Sentry init skipped for E2E boot[\s\S]*return;/, 'Sentry init module must use the build-time E2E mode flag to skip boot-time Sentry prefs IPC without adding another IPC gate'); assert.match(frontendMain, /E2E_APP_MOUNT_RELOAD_TIMEOUT_MS = 12000[\s\S]*E2E_APP_MOUNT_RELOAD_KEY[\s\S]*E2E app-mounted watchdog reloading[\s\S]*window\.location\.reload\(\)/, 'Frontend E2E boot must request one reload when the page loads but App never mounts'); assert.match(frontendMain, /scheduleE2eDomSnapshots\("post-mount"\)[\s\S]*recordE2eDomSnapshot\("app-mounted-watchdog-before-reload"[\s\S]*nixmac:e2e-dom-snapshot:watchdog-pre-reload[\s\S]*window\.setTimeout\(\(\) => \{[\s\S]*window\.location\.reload\(\)[\s\S]*250/, 'Frontend E2E boot must capture post-mount DOM snapshots and a durable watchdog snapshot before forced reload'); assert.match(frontendMain, /const renderApp = \(\) => \{[\s\S]*React render start[\s\S]*Sentry\.ErrorBoundary[\s\S]*[\s\S]*React render scheduled/, 'Frontend boot must render the app immediately with an error boundary'); -assert.match(frontendMain, /startSentryInitOnce[\s\S]*render-error[\s\S]*render-fatal[\s\S]*Sentry init start requested[\s\S]*scheduleAfterPostMountFrame[\s\S]*initializeSentryAfterPostMountFrame\(\)/, 'Frontend boot must start preference-backed Sentry initialization only after App mounted, render error, render fatal, or the mount-timeout fallback requests it'); -assert.match(frontendMain, /captureRenderErrorAfterSentryInit[\s\S]*startSentryInitOnce\(reason\)[\s\S]*Sentry\.captureException\(error\)/, 'Frontend boot must explicitly capture render errors after Sentry init is requested'); -assert.match(frontendMain, /SENTRY_MOUNT_TIMEOUT_MS = 5000[\s\S]*window\.setTimeout\(\(\) => \{[\s\S]*startSentryInitOnce\("mount-timeout"\)[\s\S]*SENTRY_MOUNT_TIMEOUT_MS/, 'Frontend boot must retain a production mount-timeout Sentry fallback for failed-render sessions without a long early-boot observability gap'); -assertOrder(frontendMain, 'window.addEventListener(\n "nixmac:app-mounted"', 'renderApp();', 'Frontend boot must register the app-mounted listener before rendering the app'); +assert.match(frontendSentryInit, /startSentryInitOnce[\s\S]*render-error[\s\S]*render-fatal[\s\S]*Sentry init start requested[\s\S]*scheduleAfterPostMountFrame[\s\S]*initializeSentryAfterPostMountFrame\(\)/, 'Sentry init module must start preference-backed Sentry initialization only after App mounted, render error, render fatal, or the mount-timeout fallback requests it'); +assert.match(frontendSentryInit, /export function captureRenderError[\s\S]*startSentryInitOnce\(reason\)[\s\S]*Sentry\.captureException\(error\)/, 'Sentry init module must export captureRenderError that lazily initializes Sentry and then captures the render error'); +assert.match(frontendSentryInit, /SENTRY_MOUNT_TIMEOUT_MS = 5000[\s\S]*window\.setTimeout\(\(\) => \{[\s\S]*startSentryInitOnce\("mount-timeout"\)[\s\S]*SENTRY_MOUNT_TIMEOUT_MS/, 'Sentry init module must retain a production mount-timeout Sentry fallback for failed-render sessions without a long early-boot observability gap'); +assert.match(frontendSentryInit, /export function attachSentry[\s\S]*window\.addEventListener\(\s*"nixmac:app-mounted"[\s\S]*startSentryInitOnce\("app-mounted"\)/, 'Sentry init module must register the app-mounted Sentry init trigger inside attachSentry'); +assert.match(frontendMain, /import \{ attachSentry, captureRenderError \} from "@\/lib\/sentry\/init"/, 'Frontend main must consume Sentry attach + capture from the extracted module'); +assertOrder(frontendMain, 'attachSentry();', 'renderApp();', 'Frontend boot must call attachSentry before rendering so the app-mounted Sentry trigger is registered'); +assertOrder(frontendMain, 'window.addEventListener(\n "nixmac:app-mounted"', 'renderApp();', 'Frontend boot must register the heartbeat-stop app-mounted listener before rendering the app'); assert.doesNotMatch(frontendMain, /renderApp\(\);\s*void initializeSentry/, 'Frontend boot must not directly initialize preference-backed Sentry immediately after first render'); assert.doesNotMatch(frontendRenderApp, /\bawait\b/, 'Frontend renderApp must stay synchronous and never await prefs IPC before first render'); -assert.match(frontendMain, /role="alert"[\s\S]*background: "#27272a"[\s\S]*border: "1px solid #52525b"/, 'Frontend error fallback must include a visible central card with enough luminance range for screenshot signal diagnostics'); +assert.match(frontendStartupFallback, /role="alert"[\s\S]*background: "#27272a"[\s\S]*border: "1px solid #52525b"/, 'Startup fallback must include a visible central card with enough luminance range for screenshot signal diagnostics'); assert.match(frontendMain, /window\.addEventListener\("unhandledrejection"[\s\S]*window unhandled rejection/, 'Frontend boot diagnostics must capture top-level unhandled rejections'); assert.match(runnerShell, /E2E_TERMINAL_CLEANUP_MODE=kill recording_close_terminal_windows/, 'Runner preflight must kill stale recorder Terminal windows before each scenario'); assert.match(peekabooRunner, /for key in NIXMAC_E2E_MOCK_SYSTEM NIXMAC_E2E_SOLID_CAPTURE NIXMAC_E2E_OPAQUE_WINDOW NIXMAC_E2E_WEBVIEW_WATCHDOG NIXMAC_SKIP_PERMISSIONS/, 'Runner preflight must clear stale Peekaboo launchctl flags, including solid capture, opaque capture, and the independent WebView watchdog'); From 771b08d121f8edf02ac219430378b0225af806bb Mon Sep 17 00:00:00 2001 From: CasLinden Date: Mon, 11 May 2026 15:45:26 +0900 Subject: [PATCH 3/7] refactor(e2e): extract e2e harness and breadcrumbs from main.tsx and decouple e2e initiation from sentry init in production --- apps/native/src/App.tsx | 2 +- apps/native/src/components/widget/widget.tsx | 2 +- apps/native/src/e2e/boot-harness.ts | 89 ++++++++++ .../dom-snapshots.ts} | 78 +-------- apps/native/src/lib/boot-diagnostics.ts | 68 ++++++++ apps/native/src/lib/sentry/init.ts | 152 +++++++++++------- apps/native/src/main.tsx | 139 +++------------- .../peekaboo-workflow-contract-self-test.mjs | 29 ++-- 8 files changed, 299 insertions(+), 260 deletions(-) create mode 100644 apps/native/src/e2e/boot-harness.ts rename apps/native/src/{lib/e2e-boot-diagnostics.ts => e2e/dom-snapshots.ts} (54%) create mode 100644 apps/native/src/lib/boot-diagnostics.ts diff --git a/apps/native/src/App.tsx b/apps/native/src/App.tsx index c23011e2..1bd74d58 100644 --- a/apps/native/src/App.tsx +++ b/apps/native/src/App.tsx @@ -1,5 +1,5 @@ import { DarwinWidget } from "@/components/widget/widget"; -import { bootBreadcrumb, clearBootStage, markBootStage } from "@/lib/e2e-boot-diagnostics"; +import { bootBreadcrumb, clearBootStage, markBootStage } from "@/lib/boot-diagnostics"; import { useEffect } from "react"; import { Toaster } from "sonner"; diff --git a/apps/native/src/components/widget/widget.tsx b/apps/native/src/components/widget/widget.tsx index 6965fc88..3a84fe3a 100644 --- a/apps/native/src/components/widget/widget.tsx +++ b/apps/native/src/components/widget/widget.tsx @@ -35,7 +35,7 @@ import { useQueueSummarizer } from "@/hooks/use-queue-summarizer"; import { useWatcher } from "@/hooks/use-watcher"; import { loadConfig, loadHosts, loadEvolveState } from "@/hooks/use-widget-initialization"; import { useSummary } from "@/hooks/use-summary"; -import { markBootStage } from "@/lib/e2e-boot-diagnostics"; +import { markBootStage } from "@/lib/boot-diagnostics"; import { useCurrentStep, useWidgetStore } from "@/stores/widget-store"; import { UpdateBanner } from "@/components/widget/layout/update-banner"; import { setupErrorTestHelpers } from "@/utils/error-test-helpers"; diff --git a/apps/native/src/e2e/boot-harness.ts b/apps/native/src/e2e/boot-harness.ts new file mode 100644 index 00000000..9cb28df1 --- /dev/null +++ b/apps/native/src/e2e/boot-harness.ts @@ -0,0 +1,89 @@ +import { bootBreadcrumb } from "@/lib/boot-diagnostics"; +import { recordE2eDomSnapshot, scheduleE2eDomSnapshots } from "./dom-snapshots"; + +const APP_MOUNT_RELOAD_TIMEOUT_MS = 12000; +const APP_MOUNT_RELOAD_KEY = "nixmac:e2e-app-mount-reload-attempted"; + +// Window-level error capture for boot diagnostics. Registered at module load +// so it picks up errors that fire between import resolution and app mount. +window.addEventListener("error", (event) => { + bootBreadcrumb("window error", event.error ?? event.message); +}); +window.addEventListener("unhandledrejection", (event) => { + bootBreadcrumb("window unhandled rejection", event.reason); +}); + +bootBreadcrumb("boot-harness loaded"); + +type AttachBootHarnessOptions = { + rootElement: Element; +}; + +export function attachBootHarness({ rootElement }: AttachBootHarnessOptions): void { + let heartbeatStopped = false; + let heartbeatTick = 0; + const heartbeat = window.setInterval(() => { + if (heartbeatStopped) { + window.clearInterval(heartbeat); + return; + } + heartbeatTick += 1; + bootBreadcrumb("boot heartbeat", { tick: heartbeatTick }); + if (heartbeatTick >= 30) { + bootBreadcrumb("boot heartbeat upper bound reached", { tick: heartbeatTick }); + window.clearInterval(heartbeat); + } + }, 1000); + + const stopHeartbeat = () => { + if (!heartbeatStopped) { + heartbeatStopped = true; + window.clearInterval(heartbeat); + bootBreadcrumb("boot heartbeat stopped", { tick: heartbeatTick }); + } + }; + + window.addEventListener( + "nixmac:app-mounted", + () => { + bootBreadcrumb("app mounted event received"); + scheduleE2eDomSnapshots("post-mount"); + window.sessionStorage.removeItem(APP_MOUNT_RELOAD_KEY); + stopHeartbeat(); + }, + { once: true }, + ); + + window.setTimeout(() => { + if (heartbeatStopped) { + return; + } + + if (window.sessionStorage.getItem(APP_MOUNT_RELOAD_KEY) === "true") { + bootBreadcrumb("E2E app-mounted watchdog exhausted", { + timeoutMs: APP_MOUNT_RELOAD_TIMEOUT_MS, + }); + return; + } + + window.sessionStorage.setItem(APP_MOUNT_RELOAD_KEY, "true"); + recordE2eDomSnapshot("app-mounted-watchdog-before-reload", { + storagePrefix: "nixmac:e2e-dom-snapshot:watchdog-pre-reload", + }); + bootBreadcrumb("E2E app-mounted watchdog reloading", { + timeoutMs: APP_MOUNT_RELOAD_TIMEOUT_MS, + rootChildCount: rootElement.childElementCount, + }); + window.setTimeout(() => { + window.location.reload(); + }, 250); + }, APP_MOUNT_RELOAD_TIMEOUT_MS); + + window.setTimeout(() => { + bootBreadcrumb("post-render dom probe", { + rootChildCount: rootElement.childElementCount, + bodyWidth: document.body?.clientWidth ?? null, + bodyHeight: document.body?.clientHeight ?? null, + }); + }, 0); +} diff --git a/apps/native/src/lib/e2e-boot-diagnostics.ts b/apps/native/src/e2e/dom-snapshots.ts similarity index 54% rename from apps/native/src/lib/e2e-boot-diagnostics.ts rename to apps/native/src/e2e/dom-snapshots.ts index 3328786e..27c74d44 100644 --- a/apps/native/src/lib/e2e-boot-diagnostics.ts +++ b/apps/native/src/e2e/dom-snapshots.ts @@ -1,12 +1,7 @@ +import { bootBreadcrumb } from "@/lib/boot-diagnostics"; import { sanitizeDiagnosticText } from "@/lib/sentry/sanitize"; -import { darwinAPI } from "@/tauri-api"; -const MAX_DETAIL_LENGTH = 1_000; -const APP_TITLE = "nixmac"; - -const e2eBootDiagnosticsEnabled = - import.meta.env.VITE_NIXMAC_E2E_MODE === "true"; -let bootStageCleared = false; +const e2eBootDiagnosticsEnabled = import.meta.env.VITE_NIXMAC_E2E_MODE === "true"; function setStorageValue(key: string, value: string) { try { @@ -16,10 +11,6 @@ function setStorageValue(key: string, value: string) { } } -function markNativeBootStage(stage: string) { - void darwinAPI.debug.markBootStage(stage, Date.now()).catch(() => {}); -} - function simpleHash(value: string) { let hash = 2166136261; for (let index = 0; index < value.length; index += 1) { @@ -35,64 +26,11 @@ function excerpt(value: string, maxLength: number) { return `${sanitized.slice(0, maxLength)}...`; } -export function markBootStage(stage: string) { - if (!e2eBootDiagnosticsEnabled || bootStageCleared) return; - - // E2E-only: intentionally callable from render bodies to expose pre-effect hangs. - const normalizedStage = stage.replace(/[^\w:.-]/g, "-").slice(0, 80); - document.documentElement.dataset.nixmacBootStage = normalizedStage; - document.title = `nixmac boot:${normalizedStage}`; - setStorageValue("nixmac:e2e-boot-stage", normalizedStage); - markNativeBootStage(normalizedStage); - console.info(`[nixmac boot-stage] ${normalizedStage}`); -} - -export function clearBootStage() { - if (!e2eBootDiagnosticsEnabled) return; - - bootStageCleared = true; - document.documentElement.dataset.nixmacBootStage = "mounted"; - document.title = APP_TITLE; - setStorageValue("nixmac:e2e-boot-stage", "mounted"); - markNativeBootStage("mounted"); -} - -function summarizeDetail(detail: unknown): string | undefined { - if (detail == null) return undefined; - - let text: string; - if (detail instanceof Error) { - text = `${detail.name}: ${detail.message}`; - } else if (typeof detail === "string") { - text = detail; - } else { - try { - text = JSON.stringify(detail); - } catch { - text = String(detail); - } - } - - return text.replace(/[^\t\x20-\x7e]/g, "").slice(0, MAX_DETAIL_LENGTH); -} - -export function bootBreadcrumb(label: string, detail?: unknown) { - const clientTimestampUnixMs = Date.now(); - const summarized = summarizeDetail(detail); - console.info(`[nixmac boot] ${label}`, summarized ?? ""); - void darwinAPI.debug - .logBreadcrumb(label, summarized, clientTimestampUnixMs) - .catch(() => {}); -} - type DomSnapshotOptions = { storagePrefix?: string; }; -export function recordE2eDomSnapshot( - label: string, - options: DomSnapshotOptions = {}, -) { +export function recordE2eDomSnapshot(label: string, options: DomSnapshotOptions = {}) { if (!e2eBootDiagnosticsEnabled) return; const root = document.getElementById("root"); @@ -101,9 +39,7 @@ export function recordE2eDomSnapshot( const snapshot = { label, title: sanitizeDiagnosticText(document.title || ""), - bootStage: sanitizeDiagnosticText( - document.documentElement.dataset.nixmacBootStage ?? "", - ), + bootStage: sanitizeDiagnosticText(document.documentElement.dataset.nixmacBootStage ?? ""), rootChildCount: root?.childElementCount ?? null, bodyTextLength: rawBodyText.length, rootHtmlLength: rawRootHtml.length, @@ -138,11 +74,7 @@ export function recordE2eDomSnapshot( bootBreadcrumb(`E2E DOM snapshot ${label} html`, htmlExcerpt); } -export function scheduleE2eDomSnapshots( - prefix: string, - count = 5, - intervalMs = 2_000, -) { +export function scheduleE2eDomSnapshots(prefix: string, count = 5, intervalMs = 2_000) { if (!e2eBootDiagnosticsEnabled) return; let emitted = 0; diff --git a/apps/native/src/lib/boot-diagnostics.ts b/apps/native/src/lib/boot-diagnostics.ts new file mode 100644 index 00000000..d1fee798 --- /dev/null +++ b/apps/native/src/lib/boot-diagnostics.ts @@ -0,0 +1,68 @@ +import { darwinAPI } from "@/tauri-api"; + +const MAX_DETAIL_LENGTH = 1_000; +const APP_TITLE = "nixmac"; + +const e2eBootDiagnosticsEnabled = import.meta.env.VITE_NIXMAC_E2E_MODE === "true"; +let bootStageCleared = false; + +function setStorageValue(key: string, value: string) { + try { + window.localStorage.setItem(key, value); + } catch { + // localStorage can be unavailable in restricted WebView states; the title/data marker is enough. + } +} + +function markNativeBootStage(stage: string) { + void darwinAPI.debug.markBootStage(stage, Date.now()).catch(() => {}); +} + +export function markBootStage(stage: string) { + if (!e2eBootDiagnosticsEnabled || bootStageCleared) return; + + // E2E-only: intentionally callable from render bodies to expose pre-effect hangs. + const normalizedStage = stage.replace(/[^\w:.-]/g, "-").slice(0, 80); + document.documentElement.dataset.nixmacBootStage = normalizedStage; + document.title = `nixmac boot:${normalizedStage}`; + setStorageValue("nixmac:e2e-boot-stage", normalizedStage); + markNativeBootStage(normalizedStage); + console.info(`[nixmac boot-stage] ${normalizedStage}`); +} + +export function clearBootStage() { + if (!e2eBootDiagnosticsEnabled) return; + + bootStageCleared = true; + document.documentElement.dataset.nixmacBootStage = "mounted"; + document.title = APP_TITLE; + setStorageValue("nixmac:e2e-boot-stage", "mounted"); + markNativeBootStage("mounted"); +} + +function summarizeDetail(detail: unknown): string | undefined { + if (detail == null) return undefined; + + let text: string; + if (detail instanceof Error) { + text = `${detail.name}: ${detail.message}`; + } else if (typeof detail === "string") { + text = detail; + } else { + try { + text = JSON.stringify(detail); + } catch { + text = String(detail); + } + } + + return text.replace(/[^\t\x20-\x7e]/g, "").slice(0, MAX_DETAIL_LENGTH); +} + +export function bootBreadcrumb(label: string, detail?: unknown) { + if (!e2eBootDiagnosticsEnabled) return; + const clientTimestampUnixMs = Date.now(); + const summarized = summarizeDetail(detail); + console.info(`[nixmac boot] ${label}`, summarized ?? ""); + void darwinAPI.debug.logBreadcrumb(label, summarized, clientTimestampUnixMs).catch(() => {}); +} diff --git a/apps/native/src/lib/sentry/init.ts b/apps/native/src/lib/sentry/init.ts index dceb204a..3bbe7c50 100644 --- a/apps/native/src/lib/sentry/init.ts +++ b/apps/native/src/lib/sentry/init.ts @@ -1,5 +1,5 @@ import * as Sentry from "@sentry/react"; -import { bootBreadcrumb } from "@/lib/e2e-boot-diagnostics"; +import { bootBreadcrumb } from "@/lib/boot-diagnostics"; import { darwinAPI } from "@/tauri-api"; import type { UiPrefs as DarwinPrefs } from "@/types/shared"; import { sanitizeSentryEvent } from "./sanitize"; @@ -9,6 +9,8 @@ const E2E_MODE = import.meta.env.VITE_NIXMAC_E2E_MODE === "true"; const PREFS_BOOT_TIMEOUT_MS = 8000; const SENTRY_MOUNT_TIMEOUT_MS = 5000; +let sentryReady = false; + const loadPrefsForBoot = async (): Promise => { bootBreadcrumb("ui_get_prefs invoke start"); let settled = false; @@ -48,6 +50,59 @@ const loadPrefsForBoot = async (): Promise => { return Promise.race([prefsPromise, timeoutPromise]); }; +// Resolves Sentry config from prefs + env and calls Sentry.init when enabled. +// Wrapped in try/catch so the returned promise never rejects. +const performSentryInit = async (): Promise => { + try { + const prefs = await loadPrefsForBoot(); + const sendDiagnostics = prefs?.sendDiagnostics ?? false; + // Vite exposes environment variables at build time, so read the Sentry DSN and other config from there. + const sentryDsn = (import.meta.env.VITE_SENTRY_DSN || "").toString().trim(); + const sentryEnabled = sendDiagnostics && sentryDsn.length > 0; + + const release = (import.meta.env.VITE_NIXMAC_VERSION || "unknown").toString(); + const environment = ( + import.meta.env.VITE_NIXMAC_ENV || + import.meta.env.MODE || + "prod" + ).toString(); + if (sentryEnabled) { + bootBreadcrumb("Sentry init enabled", { environment, release }); + Sentry.init({ + dsn: sentryDsn, + environment: environment, + release: release, + defaultIntegrations: false, // Disable default integrations to avoid issues in tauri + integrations: [Sentry.browserTracingIntegration()], + // Disable all breadcrumbs by returning `null` + beforeBreadcrumb: () => null, + beforeSend: (event) => { + const sanitized = sanitizeSentryEvent(event); + return sanitized as typeof event; + }, + tracesSampleRate: 0.1, + }); + sentryReady = true; + console.info("Sentry initialized.", { + environment: environment, + release: release, + }); + } else { + bootBreadcrumb("Sentry init skipped", { + sendDiagnostics, + hasDsn: sentryDsn.length > 0, + }); + console.info("Sentry not enabled."); + } + } catch (error) { + bootBreadcrumb("Sentry init error", error); + console.error("Sentry init error", error); + } +}; + +// E2E-mode deferred-init path. Only reached via attachSentry's E2E branch and +// always early-returns because E2E builds do not generate Sentry events. The +// breadcrumbs record the init lifecycle in the proof-report timeline. const initializeSentryAfterPostMountFrame = async () => { if (E2E_MODE) { bootBreadcrumb("Sentry init skipped for E2E boot", { @@ -56,46 +111,7 @@ const initializeSentryAfterPostMountFrame = async () => { console.info("Sentry not enabled during E2E boot."); return; } - - const prefs = await loadPrefsForBoot(); - const sendDiagnostics = prefs?.sendDiagnostics ?? false; - // Vite exposes environment variables at build time, so read the Sentry DSN and other config from there. - const sentryDsn = (import.meta.env.VITE_SENTRY_DSN || "").toString().trim(); - const sentryEnabled = sendDiagnostics && sentryDsn.length > 0; - - const release = (import.meta.env.VITE_NIXMAC_VERSION || "unknown").toString(); - const environment = ( - import.meta.env.VITE_NIXMAC_ENV || - import.meta.env.MODE || - "prod" - ).toString(); - if (sentryEnabled) { - bootBreadcrumb("Sentry init enabled", { environment, release }); - Sentry.init({ - dsn: sentryDsn, - environment: environment, - release: release, - defaultIntegrations: false, // Disable default integrations to avoid issues in tauri - integrations: [Sentry.browserTracingIntegration()], - // Disable all breadcrumbs by returning `null` - beforeBreadcrumb: () => null, - beforeSend: (event) => { - const sanitized = sanitizeSentryEvent(event); - return sanitized as typeof event; - }, - tracesSampleRate: 0.1, - }); - console.info("Sentry initialized.", { - environment: environment, - release: release, - }); - } else { - bootBreadcrumb("Sentry init skipped", { - sendDiagnostics, - hasDsn: sentryDsn.length > 0, - }); - console.info("Sentry not enabled."); - } + await performSentryInit(); }; let sentryInitStarted = false; @@ -145,23 +161,43 @@ export function captureRenderError( reason: "render-error" | "render-fatal", error: unknown, ): void { - void startSentryInitOnce(reason).then(() => { - if (!E2E_MODE) { - Sentry.captureException(error); - } - }); + if (E2E_MODE) { + // E2E mode does not init Sentry; the .then is a no-op for capture but the + // startSentryInitOnce call records the render-error request in the + // proof-report breadcrumb timeline. + void startSentryInitOnce(reason).then(() => { + if (!E2E_MODE) { + Sentry.captureException(error); + } + }); + return; + } + // Production: attachSentry was awaited before render, so Sentry is either + // ready or known-skipped by the time this fires. + if (sentryReady) { + Sentry.captureException(error); + } } -export function attachSentry(): void { - window.addEventListener( - "nixmac:app-mounted", - () => { - startSentryInitOnce("app-mounted"); - }, - { once: true }, - ); - - window.setTimeout(() => { - startSentryInitOnce("mount-timeout"); - }, SENTRY_MOUNT_TIMEOUT_MS); +export function attachSentry(): Promise { + if (E2E_MODE) { + // E2E mode: defer init via the app-mounted listener with a mount-timeout + // fallback. Returns an already-resolved promise so the awaiting bootstrap + // in main.tsx behaves the same shape across build modes. + window.addEventListener( + "nixmac:app-mounted", + () => { + startSentryInitOnce("app-mounted"); + }, + { once: true }, + ); + window.setTimeout(() => { + startSentryInitOnce("mount-timeout"); + }, SENTRY_MOUNT_TIMEOUT_MS); + return Promise.resolve(); + } + // Production: eager init. main.tsx awaits this so React begins rendering + // only after prefs are read and Sentry has either initialized or been + // skipped — no window where an early render error could outrun init. + return performSentryInit(); } diff --git a/apps/native/src/main.tsx b/apps/native/src/main.tsx index b10c06dc..0fe6f5cb 100644 --- a/apps/native/src/main.tsx +++ b/apps/native/src/main.tsx @@ -3,126 +3,35 @@ import ReactDOM from "react-dom/client"; import * as Sentry from "@sentry/react"; import App from "./App"; import "./index.css"; -import { - bootBreadcrumb, - markBootStage, - recordE2eDomSnapshot, - scheduleE2eDomSnapshots, -} from "@/lib/e2e-boot-diagnostics"; +import { markBootStage } from "@/lib/boot-diagnostics"; import { attachSentry, captureRenderError } from "@/lib/sentry/init"; import { StartupFallback } from "@/components/StartupFallback"; const rootElement = document.getElementById("root"); - -bootBreadcrumb("main.tsx loaded"); markBootStage("main-loaded"); if (!rootElement) { - bootBreadcrumb("root element missing"); throw new Error("Root element not found"); } -bootBreadcrumb("root element found"); markBootStage("root-found"); -const E2E_APP_MOUNT_RELOAD_TIMEOUT_MS = 12000; -const E2E_APP_MOUNT_RELOAD_KEY = "nixmac:e2e-app-mount-reload-attempted"; -// Build-time flag identifying an E2E build. In that mode, the harness-only -// instrumentation (heartbeat, watchdog, DOM snapshots) is active. -const E2E_MODE = import.meta.env.VITE_NIXMAC_E2E_MODE === "true"; - -let bootHeartbeatStopped = false; -let bootHeartbeatTick = 0; -let bootHeartbeat: number | null = null; - -if (E2E_MODE) { - bootHeartbeat = window.setInterval(() => { - if (bootHeartbeatStopped) { - if (bootHeartbeat !== null) { - window.clearInterval(bootHeartbeat); - } - return; - } - bootHeartbeatTick += 1; - bootBreadcrumb("boot heartbeat", { tick: bootHeartbeatTick }); - if (bootHeartbeatTick >= 30) { - bootBreadcrumb("boot heartbeat upper bound reached", { tick: bootHeartbeatTick }); - if (bootHeartbeat !== null) { - window.clearInterval(bootHeartbeat); - } - } - }, 1000); +// E2E build-only diagnostic harness: heartbeat, watchdog, DOM snapshots, window +// error listeners, post-render DOM probe. Statically dead code in production +// builds, so Vite drops the chunk from the bundle. +if (import.meta.env.VITE_NIXMAC_E2E_MODE === "true") { + void import("@/e2e/boot-harness").then((m) => m.attachBootHarness({ rootElement })); } -const stopBootHeartbeat = () => { - if (!bootHeartbeatStopped) { - bootHeartbeatStopped = true; - if (bootHeartbeat !== null) { - window.clearInterval(bootHeartbeat); - } - bootBreadcrumb("boot heartbeat stopped", { tick: bootHeartbeatTick }); - } -}; - -window.addEventListener( - "nixmac:app-mounted", - () => { - bootBreadcrumb("app mounted event received"); - scheduleE2eDomSnapshots("post-mount"); - window.sessionStorage.removeItem(E2E_APP_MOUNT_RELOAD_KEY); - stopBootHeartbeat(); - }, - { once: true }, -); - -window.addEventListener("error", (event) => { - bootBreadcrumb("window error", event.error ?? event.message); -}); - -window.addEventListener("unhandledrejection", (event) => { - bootBreadcrumb("window unhandled rejection", event.reason); -}); - -attachSentry(); - const root = ReactDOM.createRoot(rootElement); -if (E2E_MODE) { - window.setTimeout(() => { - if (bootHeartbeatStopped) { - return; - } - - if (window.sessionStorage.getItem(E2E_APP_MOUNT_RELOAD_KEY) === "true") { - bootBreadcrumb("E2E app-mounted watchdog exhausted", { - timeoutMs: E2E_APP_MOUNT_RELOAD_TIMEOUT_MS, - }); - return; - } - - window.sessionStorage.setItem(E2E_APP_MOUNT_RELOAD_KEY, "true"); - recordE2eDomSnapshot("app-mounted-watchdog-before-reload", { - storagePrefix: "nixmac:e2e-dom-snapshot:watchdog-pre-reload", - }); - bootBreadcrumb("E2E app-mounted watchdog reloading", { - timeoutMs: E2E_APP_MOUNT_RELOAD_TIMEOUT_MS, - rootChildCount: rootElement.childElementCount, - }); - window.setTimeout(() => { - window.location.reload(); - }, 250); - }, E2E_APP_MOUNT_RELOAD_TIMEOUT_MS); -} - const renderApp = () => { - bootBreadcrumb("React render start"); markBootStage("react-render-start"); root.render( } - onError={(error, componentStack) => { - console.error("ErrorBoundary caught:", error, componentStack); - bootBreadcrumb("ErrorBoundary caught render error", error); + onError={(error, _componentStack) => { + console.error("ErrorBoundary caught:", error); captureRenderError("render-error", error); }} > @@ -130,23 +39,23 @@ const renderApp = () => { , ); - bootBreadcrumb("React render scheduled"); markBootStage("react-render-scheduled"); }; -try { - renderApp(); -} catch (error) { - bootBreadcrumb("React render fatal error", error); - markBootStage("react-render-fatal"); - captureRenderError("render-fatal", error); - root.render(); -} +const bootstrap = async () => { + // Awaiting attachSentry blocks render in production until prefs are read + // and Sentry has initialized or been skipped, so a render error can't fire + // before Sentry is ready to receive it. In E2E builds attachSentry resolves + // synchronously and the harness handles its own init lifecycle. + await attachSentry(); + + try { + renderApp(); + } catch (error) { + markBootStage("react-render-fatal"); + captureRenderError("render-fatal", error); + root.render(); + } +}; -window.setTimeout(() => { - bootBreadcrumb("post-render dom probe", { - rootChildCount: rootElement.childElementCount, - bodyWidth: document.body?.clientWidth ?? null, - bodyHeight: document.body?.clientHeight ?? null, - }); -}, 0); +void bootstrap(); diff --git a/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs b/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs index c50aca3a..b64dcbb9 100644 --- a/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs +++ b/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs @@ -22,7 +22,9 @@ const frontendMainPath = path.join(repoRoot, 'apps/native/src/main.tsx'); const frontendAppPath = path.join(repoRoot, 'apps/native/src/App.tsx'); const frontendWidgetPath = path.join(repoRoot, 'apps/native/src/components/widget/widget.tsx'); const frontendEditorPanelPath = path.join(repoRoot, 'apps/native/src/components/widget/overlays/editor-panel.tsx'); -const frontendBootDiagnosticsPath = path.join(repoRoot, 'apps/native/src/lib/e2e-boot-diagnostics.ts'); +const frontendBootDiagnosticsPath = path.join(repoRoot, 'apps/native/src/lib/boot-diagnostics.ts'); +const frontendDomSnapshotsPath = path.join(repoRoot, 'apps/native/src/e2e/dom-snapshots.ts'); +const frontendBootHarnessPath = path.join(repoRoot, 'apps/native/src/e2e/boot-harness.ts'); const frontendSentryInitPath = path.join(repoRoot, 'apps/native/src/lib/sentry/init.ts'); const frontendSentrySanitizePath = path.join(repoRoot, 'apps/native/src/lib/sentry/sanitize.ts'); const frontendStartupFallbackPath = path.join(repoRoot, 'apps/native/src/components/StartupFallback.tsx'); @@ -44,6 +46,8 @@ const frontendApp = readFileSync(frontendAppPath, 'utf8'); const frontendWidget = readFileSync(frontendWidgetPath, 'utf8'); const frontendEditorPanel = readFileSync(frontendEditorPanelPath, 'utf8'); const frontendBootDiagnostics = readFileSync(frontendBootDiagnosticsPath, 'utf8'); +const frontendDomSnapshots = readFileSync(frontendDomSnapshotsPath, 'utf8'); +const frontendBootHarness = readFileSync(frontendBootHarnessPath, 'utf8'); const frontendSentryInit = readFileSync(frontendSentryInitPath, 'utf8'); const frontendSentrySanitize = readFileSync(frontendSentrySanitizePath, 'utf8'); const frontendStartupFallback = readFileSync(frontendStartupFallbackPath, 'utf8'); @@ -232,32 +236,33 @@ assert.match(frontendBootDiagnostics, /export function clearBootStage[\s\S]*docu assert.match(frontendBootDiagnostics, /export function clearBootStage[\s\S]*markNativeBootStage\("mounted"\)/, 'Frontend boot diagnostics must clear the native title marker after App mount'); assert.match(frontendSentrySanitize, /export function sanitizeDiagnosticText[\s\S]*sanitizeString/, 'E2E DOM diagnostics text sanitizer must be exported from the shared sanitize module'); assert.match(frontendSentrySanitize, /EMAIL_PATTERN[\s\S]*BEARER_TOKEN_PATTERN[\s\S]*OPENAI_TOKEN_PATTERN[\s\S]*HOME_DIR_PATH_PATTERN/, 'Shared sanitize module must apply the secret-shaped text patterns used by both Sentry events and E2E diagnostics'); -assert.match(frontendBootDiagnostics, /import \{ sanitizeDiagnosticText \} from "@\/lib\/sentry\/sanitize"/, 'E2E boot diagnostics must consume sanitization from the shared sentry/sanitize module rather than a duplicate regex set'); -assert.match(frontendBootDiagnostics, /export function recordE2eDomSnapshot[\s\S]*storagePrefix[\s\S]*nixmac:e2e-dom-snapshot[\s\S]*document\.documentElement\.dataset\.nixmacE2eDomSnapshot[\s\S]*`\$\{storagePrefix\}:last`[\s\S]*E2E DOM snapshot \$\{label\} summary[\s\S]*E2E DOM snapshot \$\{label\} text[\s\S]*E2E DOM snapshot \$\{label\} html/, 'E2E DOM diagnostics must persist bounded snapshots through both out-of-band DOM/localStorage state and breadcrumb artifacts'); -assert.match(frontendBootDiagnostics, /export function scheduleE2eDomSnapshots[\s\S]*count = 5[\s\S]*intervalMs = 2_000[\s\S]*emitted < count/, 'E2E DOM diagnostics must schedule a bounded post-mount snapshot series and self-stop'); +assert.match(frontendDomSnapshots, /import \{ sanitizeDiagnosticText \} from "@\/lib\/sentry\/sanitize"/, 'E2E DOM snapshots must consume sanitization from the shared sentry/sanitize module rather than a duplicate regex set'); +assert.match(frontendDomSnapshots, /import \{ bootBreadcrumb \} from "@\/lib\/boot-diagnostics"/, 'E2E DOM snapshots must consume bootBreadcrumb from the split-out boot-diagnostics module'); +assert.match(frontendDomSnapshots, /export function recordE2eDomSnapshot[\s\S]*storagePrefix[\s\S]*nixmac:e2e-dom-snapshot[\s\S]*document\.documentElement\.dataset\.nixmacE2eDomSnapshot[\s\S]*`\$\{storagePrefix\}:last`[\s\S]*E2E DOM snapshot \$\{label\} summary[\s\S]*E2E DOM snapshot \$\{label\} text[\s\S]*E2E DOM snapshot \$\{label\} html/, 'E2E DOM snapshots must persist bounded snapshots through both out-of-band DOM/localStorage state and breadcrumb artifacts'); +assert.match(frontendDomSnapshots, /export function scheduleE2eDomSnapshots[\s\S]*count = 5[\s\S]*intervalMs = 2_000[\s\S]*emitted < count/, 'E2E DOM snapshots must schedule a bounded post-mount snapshot series and self-stop'); assert.match(frontendSentryInit, /PREFS_BOOT_TIMEOUT_MS = 8000[\s\S]*ui_get_prefs invoke start[\s\S]*success after timeout[\s\S]*Promise\.race\(\[prefsPromise, timeoutPromise\]\)/, 'Sentry init module must log prefs IPC progress with clear after-timeout labels'); assert.match(frontendMain, /markBootStage\("main-loaded"\)[\s\S]*markBootStage\("root-found"\)[\s\S]*markBootStage\("react-render-start"\)[\s\S]*markBootStage\("react-render-scheduled"\)/, 'Frontend boot must synchronously mark module, root, and render-scheduling stages'); assert.match(frontendApp, /markBootStage\("app-render"\)[\s\S]*markBootStage\("app-effect"\)[\s\S]*clearBootStage\(\)/, 'App must synchronously mark render/effect stages and clear the E2E title marker after mount'); assert.match(frontendWidget, /markBootStage\("darwin-widget-render"\)/, 'DarwinWidget must mark when the product widget render body is reached'); assert.match(frontendEditorPanel, /const LazyNixEditor = lazy\(async \(\) => \{[\s\S]*import\("@\/components\/kibo-ui\/nix-editor"\)[\s\S]*default: module\.NixEditor/, 'EditorPanel must lazy-load the Monaco-backed Nix editor only when a file is opened'); assert.doesNotMatch(frontendEditorPanel, /import \{ NixEditor \}/, 'EditorPanel must not import the Monaco-backed editor in the first app boot bundle'); -assert.match(frontendMain, /if \(E2E_MODE\) \{[\s\S]*setInterval\(\(\) => \{[\s\S]*boot heartbeat[\s\S]*boot heartbeat upper bound reached[\s\S]*stopBootHeartbeat[\s\S]*boot heartbeat stopped[\s\S]*nixmac:app-mounted/, 'Frontend boot must emit bounded E2E-only heartbeat breadcrumbs until App mounted and record when the bound is reached'); -assert.match(frontendMain, /E2E_MODE = import\.meta\.env\.VITE_NIXMAC_E2E_MODE === "true"/, 'Frontend main must derive E2E_MODE from the build-time Vite flag'); +assert.match(frontendBootHarness, /setInterval\(\(\) => \{[\s\S]*boot heartbeat[\s\S]*boot heartbeat upper bound reached[\s\S]*stopHeartbeat[\s\S]*boot heartbeat stopped[\s\S]*nixmac:app-mounted/, 'E2E boot harness must emit bounded heartbeat breadcrumbs until App mounted and record when the bound is reached'); assert.match(frontendSentryInit, /E2E_MODE = import\.meta\.env\.VITE_NIXMAC_E2E_MODE === "true"[\s\S]*Sentry init skipped for E2E boot[\s\S]*return;/, 'Sentry init module must use the build-time E2E mode flag to skip boot-time Sentry prefs IPC without adding another IPC gate'); -assert.match(frontendMain, /E2E_APP_MOUNT_RELOAD_TIMEOUT_MS = 12000[\s\S]*E2E_APP_MOUNT_RELOAD_KEY[\s\S]*E2E app-mounted watchdog reloading[\s\S]*window\.location\.reload\(\)/, 'Frontend E2E boot must request one reload when the page loads but App never mounts'); -assert.match(frontendMain, /scheduleE2eDomSnapshots\("post-mount"\)[\s\S]*recordE2eDomSnapshot\("app-mounted-watchdog-before-reload"[\s\S]*nixmac:e2e-dom-snapshot:watchdog-pre-reload[\s\S]*window\.setTimeout\(\(\) => \{[\s\S]*window\.location\.reload\(\)[\s\S]*250/, 'Frontend E2E boot must capture post-mount DOM snapshots and a durable watchdog snapshot before forced reload'); -assert.match(frontendMain, /const renderApp = \(\) => \{[\s\S]*React render start[\s\S]*Sentry\.ErrorBoundary[\s\S]*[\s\S]*React render scheduled/, 'Frontend boot must render the app immediately with an error boundary'); +assert.match(frontendMain, /import\.meta\.env\.VITE_NIXMAC_E2E_MODE === "true"[\s\S]*void import\("@\/e2e\/boot-harness"\)[\s\S]*attachBootHarness\(\{ rootElement \}\)/, 'Frontend main must conditionally dynamic-import the E2E boot harness so it is tree-shaken from production builds'); +assert.match(frontendBootHarness, /APP_MOUNT_RELOAD_TIMEOUT_MS = 12000[\s\S]*APP_MOUNT_RELOAD_KEY[\s\S]*E2E app-mounted watchdog reloading[\s\S]*window\.location\.reload\(\)/, 'E2E boot harness must request one reload when the page loads but App never mounts'); +assert.match(frontendBootHarness, /scheduleE2eDomSnapshots\("post-mount"\)[\s\S]*recordE2eDomSnapshot\("app-mounted-watchdog-before-reload"[\s\S]*nixmac:e2e-dom-snapshot:watchdog-pre-reload[\s\S]*window\.setTimeout\(\(\) => \{[\s\S]*window\.location\.reload\(\)[\s\S]*250/, 'E2E boot harness must capture post-mount DOM snapshots and a durable watchdog snapshot before forced reload'); +assert.match(frontendMain, /const renderApp = \(\) => \{[\s\S]*markBootStage\("react-render-start"\)[\s\S]*Sentry\.ErrorBoundary[\s\S]*[\s\S]*markBootStage\("react-render-scheduled"\)/, 'Frontend boot must render the app inside an error boundary, bracketed by the render-start and render-scheduled boot stages'); assert.match(frontendSentryInit, /startSentryInitOnce[\s\S]*render-error[\s\S]*render-fatal[\s\S]*Sentry init start requested[\s\S]*scheduleAfterPostMountFrame[\s\S]*initializeSentryAfterPostMountFrame\(\)/, 'Sentry init module must start preference-backed Sentry initialization only after App mounted, render error, render fatal, or the mount-timeout fallback requests it'); assert.match(frontendSentryInit, /export function captureRenderError[\s\S]*startSentryInitOnce\(reason\)[\s\S]*Sentry\.captureException\(error\)/, 'Sentry init module must export captureRenderError that lazily initializes Sentry and then captures the render error'); assert.match(frontendSentryInit, /SENTRY_MOUNT_TIMEOUT_MS = 5000[\s\S]*window\.setTimeout\(\(\) => \{[\s\S]*startSentryInitOnce\("mount-timeout"\)[\s\S]*SENTRY_MOUNT_TIMEOUT_MS/, 'Sentry init module must retain a production mount-timeout Sentry fallback for failed-render sessions without a long early-boot observability gap'); assert.match(frontendSentryInit, /export function attachSentry[\s\S]*window\.addEventListener\(\s*"nixmac:app-mounted"[\s\S]*startSentryInitOnce\("app-mounted"\)/, 'Sentry init module must register the app-mounted Sentry init trigger inside attachSentry'); assert.match(frontendMain, /import \{ attachSentry, captureRenderError \} from "@\/lib\/sentry\/init"/, 'Frontend main must consume Sentry attach + capture from the extracted module'); -assertOrder(frontendMain, 'attachSentry();', 'renderApp();', 'Frontend boot must call attachSentry before rendering so the app-mounted Sentry trigger is registered'); -assertOrder(frontendMain, 'window.addEventListener(\n "nixmac:app-mounted"', 'renderApp();', 'Frontend boot must register the heartbeat-stop app-mounted listener before rendering the app'); +assertOrder(frontendMain, 'await attachSentry();', 'renderApp();', 'Frontend boot must await attachSentry before rendering so production blocks render on prefs+Sentry init (closing the pre-init render-error window)'); +assertOrder(frontendMain, 'import("@/e2e/boot-harness")', 'renderApp();', 'Frontend boot must queue the harness dynamic import before rendering so the heartbeat-stop listener runs in time for the App mount event'); assert.doesNotMatch(frontendMain, /renderApp\(\);\s*void initializeSentry/, 'Frontend boot must not directly initialize preference-backed Sentry immediately after first render'); assert.doesNotMatch(frontendRenderApp, /\bawait\b/, 'Frontend renderApp must stay synchronous and never await prefs IPC before first render'); assert.match(frontendStartupFallback, /role="alert"[\s\S]*background: "#27272a"[\s\S]*border: "1px solid #52525b"/, 'Startup fallback must include a visible central card with enough luminance range for screenshot signal diagnostics'); -assert.match(frontendMain, /window\.addEventListener\("unhandledrejection"[\s\S]*window unhandled rejection/, 'Frontend boot diagnostics must capture top-level unhandled rejections'); +assert.match(frontendBootHarness, /window\.addEventListener\("unhandledrejection"[\s\S]*window unhandled rejection/, 'E2E boot harness must capture top-level unhandled rejections'); assert.match(runnerShell, /E2E_TERMINAL_CLEANUP_MODE=kill recording_close_terminal_windows/, 'Runner preflight must kill stale recorder Terminal windows before each scenario'); assert.match(peekabooRunner, /for key in NIXMAC_E2E_MOCK_SYSTEM NIXMAC_E2E_SOLID_CAPTURE NIXMAC_E2E_OPAQUE_WINDOW NIXMAC_E2E_WEBVIEW_WATCHDOG NIXMAC_SKIP_PERMISSIONS/, 'Runner preflight must clear stale Peekaboo launchctl flags, including solid capture, opaque capture, and the independent WebView watchdog'); assert.match(e2eRuntime, /#\[cfg\(debug_assertions\)\][\s\S]*fn file_value[\s\S]*runtime\.schema_version != 1[\s\S]*runtime\.session_id\.trim\(\)\.is_empty\(\)[\s\S]*now_unix\(\)\? > runtime\.expires_at_unix/, 'Rust E2E runtime file reader must be debug-only and reject stale, malformed, or expired runtime files'); From 65046b2b3ff32965ff32deb4a768f827a882e00a Mon Sep 17 00:00:00 2001 From: CasLinden Date: Mon, 11 May 2026 17:26:13 +0900 Subject: [PATCH 4/7] feat(errors): appropriate root fallback component with error on recovery --- .../native/src/components/StartupFallback.tsx | 33 --------- .../layout/AppFatalFallback.stories.tsx | 43 +++++++++++ .../widget/layout/AppFatalFallback.tsx | 44 ++++++++++++ .../AppFatalFallback.stories.tsx.snap | 9 +++ apps/native/src/components/widget/widget.tsx | 3 +- .../src/hooks/use-feedback-on-recovery.ts | 72 +++++++++++++++++++ apps/native/src/main.tsx | 30 ++++---- .../peekaboo-workflow-contract-self-test.mjs | 8 ++- 8 files changed, 191 insertions(+), 51 deletions(-) delete mode 100644 apps/native/src/components/StartupFallback.tsx create mode 100644 apps/native/src/components/widget/layout/AppFatalFallback.stories.tsx create mode 100644 apps/native/src/components/widget/layout/AppFatalFallback.tsx create mode 100644 apps/native/src/components/widget/layout/__snapshots__/AppFatalFallback.stories.tsx.snap create mode 100644 apps/native/src/hooks/use-feedback-on-recovery.ts diff --git a/apps/native/src/components/StartupFallback.tsx b/apps/native/src/components/StartupFallback.tsx deleted file mode 100644 index d3560f24..00000000 --- a/apps/native/src/components/StartupFallback.tsx +++ /dev/null @@ -1,33 +0,0 @@ -export function StartupFallback() { - return ( -
-
-
nixmac could not render
-
- The app shell hit a startup error. Diagnostic breadcrumbs were recorded for this run. -
-
-
- ); -} diff --git a/apps/native/src/components/widget/layout/AppFatalFallback.stories.tsx b/apps/native/src/components/widget/layout/AppFatalFallback.stories.tsx new file mode 100644 index 00000000..f137ef63 --- /dev/null +++ b/apps/native/src/components/widget/layout/AppFatalFallback.stories.tsx @@ -0,0 +1,43 @@ +// @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) +import preview from "#storybook/preview"; +import { AppFatalFallback } from "./AppFatalFallback"; + +const meta = preview.meta({ + title: "App/AppFatalFallback", + component: AppFatalFallback, + parameters: { layout: "fullscreen" }, + tags: ["autodocs"], +}); + +export default meta; + +export const Default = meta.story({ + render: () => , +}); + +export const LongMessage = meta.story({ + render: () => ( + + ), +}); + +export const WithStack = meta.story({ + render: () => { + const error = new Error("Render crashed in "); + error.stack = `Error: Render crashed in + at EvolveOverlayPanel (apps/native/src/components/widget/overlays/evolve-overlay-panel.tsx:42:11) + at DarwinWidget (apps/native/src/components/widget/widget.tsx:120:5) + at App (apps/native/src/App.tsx:14:3)`; + return ; + }, +}); + +export const NoErrorObject = meta.story({ + render: () => , +}); diff --git a/apps/native/src/components/widget/layout/AppFatalFallback.tsx b/apps/native/src/components/widget/layout/AppFatalFallback.tsx new file mode 100644 index 00000000..06a8c5f9 --- /dev/null +++ b/apps/native/src/components/widget/layout/AppFatalFallback.tsx @@ -0,0 +1,44 @@ +import { RotateCcw } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +type AppFatalFallbackProps = { + error?: Error | null; +}; + +const RECOVERY_STORAGE_KEY = "nixmac:pending-error-report"; + +function stashErrorForRecovery(error: Error | null | undefined): void { + try { + window.localStorage.setItem( + RECOVERY_STORAGE_KEY, + JSON.stringify({ + name: error?.name ?? "Error", + message: error?.message ?? "Unknown error", + stack: error?.stack ?? "", + timestamp: new Date().toISOString(), + }), + ); + } catch { + } +} + +export function AppFatalFallback({ error }: AppFatalFallbackProps) { + const handleReload = () => { + stashErrorForRecovery(error); + window.location.reload(); + }; + + return ( +
+ +

Something went wrong

+ +
+ ); +} diff --git a/apps/native/src/components/widget/layout/__snapshots__/AppFatalFallback.stories.tsx.snap b/apps/native/src/components/widget/layout/__snapshots__/AppFatalFallback.stories.tsx.snap new file mode 100644 index 00000000..cf97bbe4 --- /dev/null +++ b/apps/native/src/components/widget/layout/__snapshots__/AppFatalFallback.stories.tsx.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Default 1`] = `"
"`; + +exports[`Long Message 1`] = `"
"`; + +exports[`No Error Object 1`] = `"
"`; + +exports[`With Stack 1`] = `"
"`; diff --git a/apps/native/src/components/widget/widget.tsx b/apps/native/src/components/widget/widget.tsx index 3a84fe3a..3cb66a31 100644 --- a/apps/native/src/components/widget/widget.tsx +++ b/apps/native/src/components/widget/widget.tsx @@ -24,6 +24,7 @@ import { PermissionsStep, SetupStep, } from "@/components/widget/steps"; +import { surfaceRecoveryReport } from "@/hooks/use-feedback-on-recovery"; import { useGitOperations } from "@/hooks/use-git-operations"; import { useNixInstall } from "@/hooks/use-nix-install"; import { usePanicHandler } from "@/hooks/use-panic-handler"; @@ -121,7 +122,7 @@ export function DarwinWidget() { useWidgetStore.getState().setError((e as Error)?.message || String(e)); } - // Start watching for git changes and summarizer updates after initial load + surfaceRecoveryReport(); startWatching(); queueForSummaries(); })(); diff --git a/apps/native/src/hooks/use-feedback-on-recovery.ts b/apps/native/src/hooks/use-feedback-on-recovery.ts new file mode 100644 index 00000000..0a91de03 --- /dev/null +++ b/apps/native/src/hooks/use-feedback-on-recovery.ts @@ -0,0 +1,72 @@ +import { useWidgetStore } from "@/stores/widget-store"; + +const RECOVERY_STORAGE_KEY = "nixmac:pending-error-report"; + +type StoredErrorReport = { + name: string; + message: string; + stack: string; + timestamp: string; +}; + +function readStoredReport(): StoredErrorReport | null { + let raw: string | null; + try { + raw = window.localStorage.getItem(RECOVERY_STORAGE_KEY); + } catch { + return null; + } + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.message !== "string" || parsed.message.length === 0) return null; + return { + name: typeof parsed.name === "string" && parsed.name.length > 0 ? parsed.name : "Error", + message: parsed.message, + stack: typeof parsed.stack === "string" ? parsed.stack : "", + timestamp: + typeof parsed.timestamp === "string" ? parsed.timestamp : new Date().toISOString(), + }; + } catch { + return null; + } +} + +function clearStoredReport(): void { + try { + window.localStorage.removeItem(RECOVERY_STORAGE_KEY); + } catch { + // localStorage may be unavailable in restricted webviews; the next boot + // will overwrite or replace the key anyway. + } +} + +/** + * Picks up an error report stashed by `AppFatalFallback` before a recovery + * reload and surfaces it through the widget's normal error UI: the + * ErrorMessage panel shows the recovery notice with the existing Report and + * Dismiss actions, and the Header's feedback icon flashes via its standard + * error-change subscription. The Report button then opens the FeedbackDialog + * with the captured panic details pre-filled. + * + * Called inside the widget's initialization chain after the widget has + * reached a state where the ErrorMessage panel is visible. + */ +export function surfaceRecoveryReport(): void { + const report = readStoredReport(); + if (!report) return; + clearStoredReport(); + + const { setError, setPanicDetails } = useWidgetStore.getState(); + + setPanicDetails({ + message: report.message, + location: undefined, + backtrace: report.stack.length > 0 ? report.stack : undefined, + timestamp: report.timestamp, + }); + + setError( + `Recovered from an unexpected error: ${report.message}\n\nClick Report Error to share details, or Dismiss to continue.`, + ); +} diff --git a/apps/native/src/main.tsx b/apps/native/src/main.tsx index 0fe6f5cb..63e541a9 100644 --- a/apps/native/src/main.tsx +++ b/apps/native/src/main.tsx @@ -1,11 +1,11 @@ +import { AppFatalFallback } from "@/components/widget/layout/AppFatalFallback"; +import { markBootStage } from "@/lib/boot-diagnostics"; +import { attachSentry, captureRenderError } from "@/lib/sentry/init"; +import * as Sentry from "@sentry/react"; import React from "react"; import ReactDOM from "react-dom/client"; -import * as Sentry from "@sentry/react"; import App from "./App"; import "./index.css"; -import { markBootStage } from "@/lib/boot-diagnostics"; -import { attachSentry, captureRenderError } from "@/lib/sentry/init"; -import { StartupFallback } from "@/components/StartupFallback"; const rootElement = document.getElementById("root"); markBootStage("main-loaded"); @@ -15,11 +15,11 @@ if (!rootElement) { } markBootStage("root-found"); -// E2E build-only diagnostic harness: heartbeat, watchdog, DOM snapshots, window -// error listeners, post-render DOM probe. Statically dead code in production -// builds, so Vite drops the chunk from the bundle. +// Dropped from production, e2e harness if (import.meta.env.VITE_NIXMAC_E2E_MODE === "true") { - void import("@/e2e/boot-harness").then((m) => m.attachBootHarness({ rootElement })); + void import("@/e2e/boot-harness").then((m) => + m.attachBootHarness({ rootElement }), + ); } const root = ReactDOM.createRoot(rootElement); @@ -29,7 +29,9 @@ const renderApp = () => { root.render( } + fallback={({ error }) => ( + + )} onError={(error, _componentStack) => { console.error("ErrorBoundary caught:", error); captureRenderError("render-error", error); @@ -43,10 +45,8 @@ const renderApp = () => { }; const bootstrap = async () => { - // Awaiting attachSentry blocks render in production until prefs are read - // and Sentry has initialized or been skipped, so a render error can't fire - // before Sentry is ready to receive it. In E2E builds attachSentry resolves - // synchronously and the harness handles its own init lifecycle. + // Awaiting attachSentry blocks render in production, + // in E2E_MODE resolves synchronously and the harness handles its own init lifecycle. await attachSentry(); try { @@ -54,7 +54,9 @@ const bootstrap = async () => { } catch (error) { markBootStage("react-render-fatal"); captureRenderError("render-fatal", error); - root.render(); + root.render( + , + ); } }; diff --git a/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs b/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs index b64dcbb9..dab35b63 100644 --- a/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs +++ b/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs @@ -27,7 +27,7 @@ const frontendDomSnapshotsPath = path.join(repoRoot, 'apps/native/src/e2e/dom-sn const frontendBootHarnessPath = path.join(repoRoot, 'apps/native/src/e2e/boot-harness.ts'); const frontendSentryInitPath = path.join(repoRoot, 'apps/native/src/lib/sentry/init.ts'); const frontendSentrySanitizePath = path.join(repoRoot, 'apps/native/src/lib/sentry/sanitize.ts'); -const frontendStartupFallbackPath = path.join(repoRoot, 'apps/native/src/components/StartupFallback.tsx'); +const frontendAppFatalFallbackPath = path.join(repoRoot, 'apps/native/src/components/widget/layout/AppFatalFallback.tsx'); const tauriApiPath = path.join(repoRoot, 'apps/native/src/tauri-api.ts'); const workflow = readFileSync(workflowPath, 'utf8'); const productProof = readFileSync(productProofPath, 'utf8'); @@ -50,7 +50,7 @@ const frontendDomSnapshots = readFileSync(frontendDomSnapshotsPath, 'utf8'); const frontendBootHarness = readFileSync(frontendBootHarnessPath, 'utf8'); const frontendSentryInit = readFileSync(frontendSentryInitPath, 'utf8'); const frontendSentrySanitize = readFileSync(frontendSentrySanitizePath, 'utf8'); -const frontendStartupFallback = readFileSync(frontendStartupFallbackPath, 'utf8'); +const frontendAppFatalFallback = readFileSync(frontendAppFatalFallbackPath, 'utf8'); const tauriApi = readFileSync(tauriApiPath, 'utf8'); function section(startPattern, endPattern = null) { @@ -261,7 +261,9 @@ assertOrder(frontendMain, 'await attachSentry();', 'renderApp();', 'Frontend boo assertOrder(frontendMain, 'import("@/e2e/boot-harness")', 'renderApp();', 'Frontend boot must queue the harness dynamic import before rendering so the heartbeat-stop listener runs in time for the App mount event'); assert.doesNotMatch(frontendMain, /renderApp\(\);\s*void initializeSentry/, 'Frontend boot must not directly initialize preference-backed Sentry immediately after first render'); assert.doesNotMatch(frontendRenderApp, /\bawait\b/, 'Frontend renderApp must stay synchronous and never await prefs IPC before first render'); -assert.match(frontendStartupFallback, /role="alert"[\s\S]*background: "#27272a"[\s\S]*border: "1px solid #52525b"/, 'Startup fallback must include a visible central card with enough luminance range for screenshot signal diagnostics'); +assert.match(frontendAppFatalFallback, /role="alert"/, 'App fatal fallback must use role="alert" for accessibility'); +assert.match(frontendAppFatalFallback, /window\.location\.reload\(\)/, 'App fatal fallback must offer a Reload affordance'); +assert.match(frontendAppFatalFallback, /window\.localStorage\.setItem\(\s*RECOVERY_STORAGE_KEY/, 'App fatal fallback must stash error details to localStorage for the post-reload recovery handoff'); assert.match(frontendBootHarness, /window\.addEventListener\("unhandledrejection"[\s\S]*window unhandled rejection/, 'E2E boot harness must capture top-level unhandled rejections'); assert.match(runnerShell, /E2E_TERMINAL_CLEANUP_MODE=kill recording_close_terminal_windows/, 'Runner preflight must kill stale recorder Terminal windows before each scenario'); assert.match(peekabooRunner, /for key in NIXMAC_E2E_MOCK_SYSTEM NIXMAC_E2E_SOLID_CAPTURE NIXMAC_E2E_OPAQUE_WINDOW NIXMAC_E2E_WEBVIEW_WATCHDOG NIXMAC_SKIP_PERMISSIONS/, 'Runner preflight must clear stale Peekaboo launchctl flags, including solid capture, opaque capture, and the independent WebView watchdog'); From 1a40f58e98732034752a8b4c898600d246f8a1a5 Mon Sep 17 00:00:00 2001 From: CasLinden Date: Mon, 11 May 2026 17:54:06 +0900 Subject: [PATCH 5/7] fix(tailwind): apply styles for classes in ui package --- apps/native/tailwind.config.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/native/tailwind.config.js b/apps/native/tailwind.config.js index a79e0f03..32d512f0 100644 --- a/apps/native/tailwind.config.js +++ b/apps/native/tailwind.config.js @@ -1,7 +1,11 @@ /** @type {import('tailwindcss').Config} */ module.exports = { darkMode: ["class"], - content: ["./index.html", "./src/**/*.{ts,tsx}"], + content: [ + "./index.html", + "./src/**/*.{ts,tsx}", + "../../packages/ui/src/**/*.{ts,tsx}", + ], theme: { extend: { borderRadius: { From 75ab5682267ec3197119a76f24117ef95f77fcb5 Mon Sep 17 00:00:00 2001 From: CasLinden Date: Mon, 11 May 2026 18:04:46 +0900 Subject: [PATCH 6/7] chore(error): shorten recovery error message --- apps/native/src/hooks/use-feedback-on-recovery.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/native/src/hooks/use-feedback-on-recovery.ts b/apps/native/src/hooks/use-feedback-on-recovery.ts index 0a91de03..967058ce 100644 --- a/apps/native/src/hooks/use-feedback-on-recovery.ts +++ b/apps/native/src/hooks/use-feedback-on-recovery.ts @@ -66,7 +66,5 @@ export function surfaceRecoveryReport(): void { timestamp: report.timestamp, }); - setError( - `Recovered from an unexpected error: ${report.message}\n\nClick Report Error to share details, or Dismiss to continue.`, - ); + setError(`Recovered from an unexpected error: ${report.message}`); } From c11cff83e4abfb1ba021a11c63ae56c688132ecc Mon Sep 17 00:00:00 2001 From: CasLinden Date: Tue, 12 May 2026 16:04:07 +0900 Subject: [PATCH 7/7] feat(tests): unit test sanitize --- apps/native/src/lib/sentry/sanitize.test.ts | 123 ++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 apps/native/src/lib/sentry/sanitize.test.ts diff --git a/apps/native/src/lib/sentry/sanitize.test.ts b/apps/native/src/lib/sentry/sanitize.test.ts new file mode 100644 index 00000000..32279d12 --- /dev/null +++ b/apps/native/src/lib/sentry/sanitize.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "vitest"; +import { sanitizeDiagnosticText, sanitizeSentryEvent } from "./sanitize"; + +describe("sanitizeSentryEvent — string redaction", () => { + test("redacts emails inside message strings", () => { + const result = sanitizeSentryEvent({ + message: "Failed for user@example.com during boot", + }) as { message: string }; + expect(result.message).not.toMatch(/user@example/); + expect(result.message).toContain("[REDACTED]"); + }); + + test("rewrites home-directory paths but keeps the remainder of the path", () => { + const result = sanitizeSentryEvent({ + message: "Crashed in /Users/cas/projects/nixmac/apps/native/src/main.tsx", + }) as { message: string }; + expect(result.message).not.toContain("/Users/cas/"); + expect(result.message).toContain("/Users/[REDACTED_USER]/projects/nixmac/apps/native/src/main.tsx"); + }); + + test("redacts Anthropic-shaped tokens", () => { + const result = sanitizeSentryEvent({ + message: "Header had sk-ant-abcdefghijklmnopqrstuvwxyz at request time", + }) as { message: string }; + expect(result.message).not.toMatch(/sk-ant-[a-z]{20}/); + expect(result.message).toContain("[REDACTED]"); + }); + + test("redacts OpenAI-shaped tokens, GitHub tokens, and Bearer headers", () => { + const result = sanitizeSentryEvent({ + message: + "openai=sk-abcdefghijklmnopqrstuvwxyz0123 gh=ghp_abcdefghijklmnopqrstuvwxyz auth=Bearer abc.def-ghi/jkl+mno=", + }) as { message: string }; + expect(result.message).not.toMatch(/sk-[a-z0-9]{20}/); + expect(result.message).not.toMatch(/ghp_[a-z0-9]{20}/i); + expect(result.message).not.toMatch(/Bearer [a-z0-9]/i); + }); + + test("strips query strings from http(s) URLs but leaves non-URL strings unchanged", () => { + const result = sanitizeSentryEvent({ + message: "https://example.com/path?token=abc&user=cas", + }) as { message: string }; + expect(result.message).not.toContain("token=abc"); + expect(result.message).not.toContain("user=cas"); + expect(result.message).toContain("https://example.com/path"); + }); + + test("redacts the value half of nix-style secret assignments", () => { + const result = sanitizeSentryEvent({ + message: 'config had password = "hunter2" inside', + }) as { message: string }; + expect(result.message).not.toContain("hunter2"); + expect(result.message).toContain("password = [REDACTED]"); + }); +}); + +describe("sanitizeSentryEvent — key-based wholesale redaction", () => { + test("redacts wholesale on keys matching APP_CONTENT_KEY_PATTERN", () => { + const result = sanitizeSentryEvent({ + extra: { + diff: "+ password = 'hunter2'", + stdout: "running...", + cwd: "/Users/cas/projects", + }, + }) as { extra: { diff: string; stdout: string; cwd: string } }; + expect(result.extra.diff).toBe("[REDACTED_APP_CONTENT]"); + expect(result.extra.stdout).toBe("[REDACTED_APP_CONTENT]"); + expect(result.extra.cwd).toBe("[REDACTED_APP_CONTENT]"); + }); + + test("redacts sensitive-keyed values (email, token) to [REDACTED] regardless of contents", () => { + const result = sanitizeSentryEvent({ + contexts: { + anything: { + email: "not-an-email-but-keyed-as-one", + token: "x", + }, + }, + }) as { contexts: { anything: { email: string; token: string } } }; + expect(result.contexts.anything.email).toBe("[REDACTED]"); + expect(result.contexts.anything.token).toBe("[REDACTED]"); + }); + + test("leaves an empty app-content string alone (falls through to string sanitizer)", () => { + const result = sanitizeSentryEvent({ + extra: { diff: "" }, + }) as { extra: { diff: string } }; + expect(result.extra.diff).toBe(""); + }); +}); + +describe("sanitizeSentryEvent — top-level scrubs", () => { + test("removes user and server_name from the top level", () => { + const result = sanitizeSentryEvent({ + message: "ok", + user: { id: "abc", email: "a@b.c" }, + server_name: "host.local", + }) as Record; + expect(result.user).toBeUndefined(); + expect(result.server_name).toBeUndefined(); + expect(result.message).toBe("ok"); + }); + + test("returns primitives untouched (no top-level scrub when non-object)", () => { + expect(sanitizeSentryEvent("plain string")).toBe("plain string"); + expect(sanitizeSentryEvent(null)).toBe(null); + expect(sanitizeSentryEvent(42)).toBe(42); + }); +}); + +describe("sanitizeDiagnosticText", () => { + test("applies the same regex pipeline as Sentry strings", () => { + expect(sanitizeDiagnosticText("hi user@example.com")).toBe("hi [REDACTED]"); + }); + + test("strips non-printables (control chars) while preserving tab and printable ASCII", () => { + expect(sanitizeDiagnosticText("hi\x07\x08there\tworld")).toBe("hithere\tworld"); + }); + + test("rewrites home paths and strips non-printables in one pass", () => { + expect(sanitizeDiagnosticText("at \x01/Users/cas/x\x02")).toBe("at /Users/[REDACTED_USER]/x"); + }); +});