Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/peekaboo-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 1 addition & 1 deletion apps/native/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
Original file line number Diff line number Diff line change
@@ -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: () => <AppFatalFallback error={new Error("Unable to read user preferences")} />,
});

export const LongMessage = meta.story({
render: () => (
<AppFatalFallback
error={
new Error(
"TypeError: Cannot read properties of undefined (reading 'sendDiagnostics'). The widget store may have been corrupted by an earlier IPC failure; reloading typically clears this state, but if the underlying preference file is malformed the same error will reappear after reload.",
)
}
/>
),
});

export const WithStack = meta.story({
render: () => {
const error = new Error("Render crashed in <EvolveOverlayPanel />");
error.stack = `Error: Render crashed in <EvolveOverlayPanel />
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 <AppFatalFallback error={error} />;
},
});

export const NoErrorObject = meta.story({
render: () => <AppFatalFallback />,
});
44 changes: 44 additions & 0 deletions apps/native/src/components/widget/layout/AppFatalFallback.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="alert"
className="flex h-screen w-screen flex-col items-center justify-center bg-background text-foreground"
>
<img src="/outline-white.png" alt="" className="mb-3 h-16 w-16 object-contain" />
<h3 className="mb-5 font-semibold text-lg">Something went wrong</h3>
<Button onClick={handleReload} size="sm">
<RotateCcw />
Reload
</Button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`Default 1`] = `"<div class="flex h-screen w-screen items-center justify-center bg-zinc-950 text-zinc-100"><div role="alert" class="rounded-xl border border-zinc-700 bg-zinc-900 p-7 shadow-2xl"><h1 class="mb-5 font-semibold text-base">Something went wrong</h1><button class="inline-flex shrink-0 items-center justify-center whitespace-nowrap font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 bg-primary text-primary-foreground hover:bg-primary/90 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5" data-slot="button"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg>Reload</button></div></div>"`;

exports[`Long Message 1`] = `"<div class="flex h-screen w-screen items-center justify-center bg-zinc-950 text-zinc-100"><div role="alert" class="rounded-xl border border-zinc-700 bg-zinc-900 p-7 shadow-2xl"><h1 class="mb-5 font-semibold text-base">Something went wrong</h1><button class="inline-flex shrink-0 items-center justify-center whitespace-nowrap font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 bg-primary text-primary-foreground hover:bg-primary/90 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5" data-slot="button"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg>Reload</button></div></div>"`;

exports[`No Error Object 1`] = `"<div class="flex h-screen w-screen items-center justify-center bg-zinc-950 text-zinc-100"><div role="alert" class="rounded-xl border border-zinc-700 bg-zinc-900 p-7 shadow-2xl"><h1 class="mb-5 font-semibold text-base">Something went wrong</h1><button class="inline-flex shrink-0 items-center justify-center whitespace-nowrap font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 bg-primary text-primary-foreground hover:bg-primary/90 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5" data-slot="button"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg>Reload</button></div></div>"`;

exports[`With Stack 1`] = `"<div class="flex h-screen w-screen items-center justify-center bg-zinc-950 text-zinc-100"><div role="alert" class="rounded-xl border border-zinc-700 bg-zinc-900 p-7 shadow-2xl"><h1 class="mb-5 font-semibold text-base">Something went wrong</h1><button class="inline-flex shrink-0 items-center justify-center whitespace-nowrap font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg:not([class*='size-'])]:size-4 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 bg-primary text-primary-foreground hover:bg-primary/90 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5" data-slot="button"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg>Reload</button></div></div>"`;
5 changes: 3 additions & 2 deletions apps/native/src/components/widget/widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -35,7 +36,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";
Expand Down Expand Up @@ -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();
})();
Expand Down
89 changes: 89 additions & 0 deletions apps/native/src/e2e/boot-harness.ts
Original file line number Diff line number Diff line change
@@ -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);
}
89 changes: 89 additions & 0 deletions apps/native/src/e2e/dom-snapshots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { bootBreadcrumb } from "@/lib/boot-diagnostics";
import { sanitizeDiagnosticText } from "@/lib/sentry/sanitize";

const e2eBootDiagnosticsEnabled = import.meta.env.VITE_NIXMAC_E2E_MODE === "true";

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 simpleHash(value: string) {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}

function excerpt(value: string, maxLength: number) {
const sanitized = sanitizeDiagnosticText(value).replace(/\s+/g, " ").trim();
if (sanitized.length <= maxLength) return sanitized;
return `${sanitized.slice(0, maxLength)}...`;
}

type DomSnapshotOptions = {
storagePrefix?: string;
};

export function recordE2eDomSnapshot(label: string, options: DomSnapshotOptions = {}) {
if (!e2eBootDiagnosticsEnabled) return;

const root = document.getElementById("root");
const rawBodyText = document.body?.innerText ?? "";
const rawRootHtml = root?.innerHTML ?? "";
const snapshot = {
label,
title: sanitizeDiagnosticText(document.title || ""),
bootStage: sanitizeDiagnosticText(document.documentElement.dataset.nixmacBootStage ?? ""),
rootChildCount: root?.childElementCount ?? null,
bodyTextLength: rawBodyText.length,
rootHtmlLength: rawRootHtml.length,
bodyTextHash: simpleHash(rawBodyText),
rootHtmlHash: simpleHash(rawRootHtml),
bodyWidth: document.body?.clientWidth ?? null,
bodyHeight: document.body?.clientHeight ?? null,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
};
const textExcerpt = excerpt(rawBodyText, 360);
const htmlExcerpt = excerpt(rawRootHtml, 520);
const compact = JSON.stringify({
label: snapshot.label,
title: snapshot.title,
bootStage: snapshot.bootStage,
rootChildCount: snapshot.rootChildCount,
bodyTextLength: snapshot.bodyTextLength,
rootHtmlLength: snapshot.rootHtmlLength,
bodyTextHash: snapshot.bodyTextHash,
rootHtmlHash: snapshot.rootHtmlHash,
});

const storagePrefix = options.storagePrefix ?? "nixmac:e2e-dom-snapshot";
document.documentElement.dataset.nixmacE2eDomSnapshot = compact.slice(0, 900);
setStorageValue(`${storagePrefix}:last`, compact);
setStorageValue(`${storagePrefix}:text`, textExcerpt);
setStorageValue(`${storagePrefix}:html`, htmlExcerpt);

bootBreadcrumb(`E2E DOM snapshot ${label} summary`, snapshot);
bootBreadcrumb(`E2E DOM snapshot ${label} text`, textExcerpt);
bootBreadcrumb(`E2E DOM snapshot ${label} html`, htmlExcerpt);
}

export function scheduleE2eDomSnapshots(prefix: string, count = 5, intervalMs = 2_000) {
if (!e2eBootDiagnosticsEnabled) return;

let emitted = 0;
const emit = () => {
emitted += 1;
recordE2eDomSnapshot(`${prefix}-${emitted}`);
if (emitted < count) {
window.setTimeout(emit, intervalMs);
}
};
window.setTimeout(emit, 0);
}
70 changes: 70 additions & 0 deletions apps/native/src/hooks/use-feedback-on-recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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<StoredErrorReport>;
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}`);
}
Loading
Loading