Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { mergeConfig } from "vite";
import { readFileSync } from "fs";
import path from "path";

// The boot loader's CSS is inlined in index.html so the pre-JS placeholder paints styled, and
// LoadingScreen (plus the update restart overlay built on it) reuses those classes. Lift the same
// rules into the preview so stories render them faithfully without a second copy of the CSS.
function bootLoaderStyles(): string {
const html = readFileSync(path.join(process.cwd(), "index.html"), "utf8");
const start = html.indexOf(".boot-loader {");
const end = html.indexOf("</style>", start);
if (start === -1 || end === -1) {
throw new Error("index.html no longer contains the inline .boot-loader styles");
}
return html.slice(start, end);
}

const config: StorybookConfig = {
stories: [
"../src/browser/stories/**/*.stories.@(ts|tsx)",
"../src/browser/components/**/*.stories.@(ts|tsx)",
"../src/browser/features/**/*.stories.@(ts|tsx)",
],
addons: ["@storybook/addon-links", "@storybook/addon-docs"],
previewHead: (head) => `${head ?? ""}<style>${bootLoaderStyles()}</style>`,
framework: {
name: "@storybook/react-vite",
options: {},
Expand Down
2 changes: 2 additions & 0 deletions src/browser/components/AppLoader/AppLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
UserPreferencesProvider,
} from "@/browser/contexts/UserPreferencesContext";
import { TerminalRouterProvider } from "../../terminal/TerminalRouterContext";
import { UpdateRestartOverlay } from "@/browser/components/UpdateRestartOverlay/UpdateRestartOverlay";

const USER_PREFERENCES_BOOTSTRAP_TIMEOUT_MS = 2000;

Expand Down Expand Up @@ -271,6 +272,7 @@ function AppLoaderInner() {
>
<TerminalRouterProvider>
<App />
<UpdateRestartOverlay />
Comment thread
ibetitsmike marked this conversation as resolved.
</TerminalRouterProvider>
</motion.div>
)}
Expand Down
6 changes: 5 additions & 1 deletion src/browser/components/TitleBar/TitleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,11 @@ export function TitleBar(props: TitleBarProps) {
return <RefreshCw className="size-3.5" />;
}

if (updateStatus.type === "downloading" || updateStatus.type === "checking") {
if (
updateStatus.type === "downloading" ||
updateStatus.type === "checking" ||
updateStatus.type === "restarting"
) {
return <Loader2 className="size-3.5 animate-spin" />;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "@storybook/test";
import { APIProvider } from "@/browser/contexts/API";
import { lightweightMeta } from "@/browser/stories/meta";
import { createMockORPCClient } from "@/browser/stories/mocks/orpc";
import { UpdateRestartOverlay } from "./UpdateRestartOverlay";

function RestartOverlayStory() {
const [client] = useState(() =>
createMockORPCClient({ updateStatus: { type: "restarting", info: { version: "0.29.0" } } })
);
return (
<APIProvider client={client}>
<div className="text-foreground space-y-2 p-6">
<h1 className="text-lg font-medium">Workspace content</h1>
<p className="text-muted text-sm">
Everything here must stay hidden behind the restart screen.
</p>
</div>
<UpdateRestartOverlay />
</APIProvider>
);
}

const meta: Meta = {
...lightweightMeta,
title: "Components/UpdateRestartOverlay",
component: RestartOverlayStory,
};
export default meta;
type Story = StoryObj<typeof meta>;

async function expectOverlayCoversViewport() {
const overlay = await within(document.body).findByTestId("update-restart-overlay");
await expect(within(overlay).getByText("Restarting Xum…")).toBeVisible();
const rect = overlay.getBoundingClientRect();
await expect(rect.top).toBeLessThanOrEqual(0);
await expect(rect.left).toBeLessThanOrEqual(0);
await expect(rect.right).toBeGreaterThanOrEqual(window.innerWidth);
await expect(rect.bottom).toBeGreaterThanOrEqual(window.innerHeight);
}

export const Restarting: Story = {
play: expectOverlayCoversViewport,
};

export const RestartingPhone: Story = {
parameters: { pixel: { matrix: { viewports: ["phone"] } } },
globals: { viewport: { value: "mobile1", isRotated: false } },
play: expectOverlayCoversViewport,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import "../../../../tests/ui/dom";

import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import type { UpdateStatus } from "@/common/orpc/types";
import type * as APIModule from "@/browser/contexts/API";
import type { APIClient } from "@/browser/contexts/API";
import { installDom } from "../../../../tests/ui/dom";
import { ThemeProvider } from "../../contexts/ThemeContext";

// SVG ?react imports don't work in happy-dom; stub them as simple svgs.
void mock.module("@/browser/assets/logos/xum-logo-dark.svg?react", () => ({
__esModule: true,
default: () => <svg data-testid="xum-logo-mock" />,
}));
void mock.module("@/browser/assets/logos/xum-logo-light.svg?react", () => ({
__esModule: true,
default: () => <svg data-testid="xum-logo-mock" />,
}));

/** Push-driven stand-in for the update.onStatus subscription. */
function createStatusStream() {
const queue: UpdateStatus[] = [];
let wake: (() => void) | null = null;
const onStatus = async function* (_input: undefined, options: { signal: AbortSignal }) {
while (!options.signal.aborted) {
const next = queue.shift();
if (next) {
yield next;
continue;
}
await new Promise<void>((resolve) => {
wake = resolve;
options.signal.addEventListener("abort", () => resolve(), { once: true });
});
}
};
return {
api: { update: { onStatus } } as unknown as APIClient,
push(status: UpdateStatus) {
queue.push(status);
wake?.();
wake = null;
},
};
}

let apiState: { api: APIClient | null; status: "connected" | "reconnecting" } = {
api: null,
status: "reconnecting",
};

/* eslint-disable @typescript-eslint/no-require-imports */
const actualAPI = require("@/browser/contexts/API?real=1") as typeof APIModule;
/* eslint-enable @typescript-eslint/no-require-imports */

// Spread the real module: replacing it outright deletes exports other test files import
// statically (module mocks are process-wide and persist across files).
void mock.module("@/browser/contexts/API", () => ({
...actualAPI,
useAPI: () => ({
api: apiState.api,
status: apiState.status,
error: null,
attempt: 1,
authenticate: () => undefined,
retry: () => undefined,
}),
}));

import type { UpdateRestartOverlay as UpdateRestartOverlayComponent } from "./UpdateRestartOverlay";

// Required after the mocks above so the svg stubs are in place when LoadingScreen evaluates.
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment */
const {
UpdateRestartOverlay,
}: {
UpdateRestartOverlay: typeof UpdateRestartOverlayComponent;
} = require("./UpdateRestartOverlay");
/* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment */

const OVERLAY = "update-restart-overlay";

function renderOverlay() {
return render(
<ThemeProvider>
<UpdateRestartOverlay />
</ThemeProvider>
);
}

async function flushStream() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}

let cleanupDom: (() => void) | null = null;

describe("UpdateRestartOverlay", () => {
beforeEach(() => {
cleanupDom = installDom();
apiState = { api: null, status: "reconnecting" };
});

afterEach(() => {
cleanup();
cleanupDom?.();
cleanupDom = null;
});

test("covers the app from the restarting status until a relaunched server reports otherwise", async () => {
const before = createStatusStream();
apiState = { api: before.api, status: "connected" };
const view = renderOverlay();

before.push({ type: "downloaded", info: { version: "0.29.0" } });
await flushStream();
expect(view.queryByTestId(OVERLAY)).toBeNull();

before.push({ type: "restarting", info: { version: "0.29.0" } });
await waitFor(() => expect(view.getByTestId(OVERLAY)).toBeTruthy());

// The server goes away while it restarts: the client has no api during reconnects.
apiState = { api: null, status: "reconnecting" };
view.rerender(
<ThemeProvider>
<UpdateRestartOverlay />
</ThemeProvider>
);
await flushStream();
expect(view.getByTestId(OVERLAY)).toBeTruthy();

// The relaunched server's first status clears the cover.
const after = createStatusStream();
apiState = { api: after.api, status: "connected" };
view.rerender(
<ThemeProvider>
<UpdateRestartOverlay />
</ThemeProvider>
);
await flushStream();
expect(view.getByTestId(OVERLAY)).toBeTruthy();
after.push({ type: "idle" });
await waitFor(() => expect(view.queryByTestId(OVERLAY)).toBeNull());
});

test("hides again when the install fails after restarting was announced", async () => {
const stream = createStatusStream();
apiState = { api: stream.api, status: "connected" };
const view = renderOverlay();

stream.push({ type: "restarting", info: { version: "0.29.0" } });
await waitFor(() => expect(view.getByTestId(OVERLAY)).toBeTruthy());

stream.push({ type: "error", phase: "install", message: "activation failed" });
await waitFor(() => expect(view.queryByTestId(OVERLAY)).toBeNull());
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useAPI } from "@/browser/contexts/API";
import { LoadingScreen } from "@/browser/components/LoadingScreen/LoadingScreen";

/**
* Full-viewport cover shown from the moment an update install is confirmed (status
* "restarting") until the relaunched backend reports a different status. It replaces the old
* UI immediately, while the desktop app is still quitting or the server is tearing down.
*/
export function UpdateRestartOverlay() {
const { api } = useAPI();
const [restarting, setRestarting] = useState(false);

useEffect(() => {
// Deliberately no reset when api is null: the client passes through "reconnecting" with
// api === null while the server restarts, and the overlay must stay up until the relaunched
// server's first status event (idle/unsupported) replaces it.
if (!api) {
return;
}

const controller = new AbortController();
const { signal } = controller;

(async () => {
try {
const iterator = await api.update.onStatus(undefined, { signal });
for await (const status of iterator) {
if (signal.aborted) {
break;
}
setRestarting(status.type === "restarting");
}
} catch (error) {
if (!signal.aborted) {
console.error("Update status stream error:", error);
}
}
})();

return () => {
controller.abort();
};
}, [api]);

if (!restarting) {
return null;
}

// Portaled to <body>, outside the app root: an open Radix modal (the About dialog is the usual
// install trigger) marks the app root aria-hidden and locks body pointer events, so rendering
// inside it would hide the status from assistive technology; pointer-events-auto keeps clicks
// from falling through the cover. Stacked above dialogs, toasts, and menus so nothing from the
// old UI peeks through.
return createPortal(
<div
className="bg-surface-primary pointer-events-auto fixed inset-0 z-[10002]"
data-testid="update-restart-overlay"
>
<LoadingScreen statusText="Restarting Xum…" />
</div>,
document.body
);
}
14 changes: 13 additions & 1 deletion src/browser/features/About/AboutDialog.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "@storybook/test";
import { expect, userEvent, waitFor, within } from "@storybook/test";
import type { UpdateStatus } from "@/common/orpc/types";
import { APIProvider } from "@/browser/contexts/API";
import { AboutDialogProvider, useAboutDialog } from "@/browser/contexts/AboutDialogContext";
Expand Down Expand Up @@ -75,6 +75,18 @@ export const Downloading: Story = {
},
};

export const RestartingClosesDialog: Story = {
args: { status: { type: "restarting", info: { version: "0.29.0" } } },
play: async (context) => {
await meta.play(context);
// The dialog hands the screen to the restart cover as soon as the restarting status lands,
// releasing its focus trap; the mock emits that status on subscribe, right after opening.
await waitFor(() =>
expect(within(document.body).queryByRole("dialog")).not.toBeInTheDocument()
);
},
};

export const BlockedPhone: Story = {
args: {
status: {
Expand Down
Loading
Loading