Skip to content
Source
Sign inSign up
Open History to see the last commit

ElectronBrowserHost.browser.test.tsx

163 lines5.5 KB

1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import {
3 DEFAULT_CLIENT_SETTINGS,
4 EnvironmentId,
5 ThreadId,
6 type ClientSettings,
7 type DesktopPreviewBridge,
8 type PreviewSessionSnapshot,
9} from "@t3tools/contracts";
10import { describe, expect, it, vi } from "vite-plus/test";
11​
12import { act, fakeTimers, render } from "../../test/render";
13​
14const mocks = vi.hoisted(() => ({
15 getClientSettings: vi.fn<() => Promise<ClientSettings | null>>(),
16 setClientSettings: vi.fn<(settings: ClientSettings) => Promise<void>>(),
17 createTab: vi.fn<DesktopPreviewBridge["createTab"]>(),
18 closeTab: vi.fn<DesktopPreviewBridge["closeTab"]>(),
19 registerWebview: vi.fn<DesktopPreviewBridge["registerWebview"]>(),
20 getPreviewConfig: vi.fn<DesktopPreviewBridge["getPreviewConfig"]>(),
21}));
22​
23vi.mock(import("~/env"), async (original) => ({ ...(await original()), isElectron: true }));
24​
25vi.mock(import("~/state/session"), async (original) => ({
26 ...(await original()),
27 useEnvironmentScope: () => true,
28}));
29​
30vi.mock(import("~/localApi"), async (original) => {
31 const actual = await original();
32 return {
33 ...actual,
34 ensureLocalApi: () => ({ ...actual.ensureLocalApi(), persistence: mocks as never }),
35 };
36});
37​
38// The Electron desktop bridge; a browser has none.
39vi.mock(import("~/components/preview/previewBridge"), async (original) => ({
40 ...(await original()),
41 previewBridge: {
42 createTab: mocks.createTab,
43 closeTab: mocks.closeTab,
44 registerWebview: mocks.registerWebview,
45 getPreviewConfig: mocks.getPreviewConfig,
46 } as never,
47}));
48​
49vi.mock(import("~/components/preview/usePreviewBridge"), async (original) => ({
50 ...(await original()),
51 usePreviewBridge: () => undefined,
52}));
53​
54import {
55 __resetClientSettingsPersistenceForTests,
56 ensureClientSettingsHydrated,
57} from "~/hooks/useSettings";
58import { reconcilePreviewServerSessions, resetPreviewStateForTests } from "~/previewStateStore";
59import { AppAtomRegistryProvider } from "~/rpc/atomRegistry";
60import { ElectronBrowserHost } from "./ElectronBrowserHost";
61​
62const threadRef = scopeThreadRef(
63 EnvironmentId.make("host-webview-order"),
64 ThreadId.make("thread-webview-order"),
65);
66​
67const snapshot = (tabId: string, updatedAt: string): PreviewSessionSnapshot => ({
68 threadId: threadRef.threadId,
69 tabId,
70 navStatus: { _tag: "Success", url: `https://example.com/${tabId}`, title: tabId },
71 canGoBack: false,
72 canGoForward: false,
73 updatedAt,
74});
75​
76function setup(): Disposable {
77 __resetClientSettingsPersistenceForTests();
78 mocks.getClientSettings.mockReset().mockResolvedValue(DEFAULT_CLIENT_SETTINGS);
79 mocks.setClientSettings.mockReset().mockResolvedValue(undefined);
80 mocks.createTab.mockReset().mockResolvedValue(undefined);
81 mocks.closeTab.mockReset().mockResolvedValue(undefined);
82 mocks.registerWebview.mockReset().mockResolvedValue(undefined);
83 mocks.getPreviewConfig.mockReset().mockResolvedValue({
84 partition: "persist:t3-preview-default",
85 webPreferences: "contextIsolation=yes",
86 preloadUrl: null,
87 });
88 return {
89 [Symbol.dispose]() {
90 resetPreviewStateForTests();
91 __resetClientSettingsPersistenceForTests();
92 },
93 };
95​
96async function renderHost() {
97 await using stack = new AsyncDisposableStack();
98 const screen = stack.use(
99 await render(
100 <AppAtomRegistryProvider>
101 <ElectronBrowserHost />
102 </AppAtomRegistryProvider>,
103 ),
104 );
105 // Unmounting releases each tab on a close timer; run it now so it never fires in a later test.
106 stack.defer(async () => {
107 using _timers = fakeTimers();
108 await screen.unmount();
109 await vi.advanceTimersByTimeAsync(0);
110 });
111 await act(() => ensureClientSettingsHydrated());
112 const environment = stack.move();
113 return { container: screen.container, [Symbol.asyncDispose]: () => environment.disposeAsync() };
115​
116describe("ElectronBrowserHost", () => {
117 it("never moves a tab's webview when the server reorders tabs by last update", async () => {
118 using _state = setup();
119 reconcilePreviewServerSessions(threadRef, {
120 sessions: [
121 snapshot("tab_7", "2026-01-01T00:00:01.000Z"),
122 snapshot("tab_8", "2026-01-01T00:00:02.000Z"),
123 ],
124 serverEpoch: "server-a",
125 revision: 1,
126 });
127 await using screen = await renderHost();
128​
129 const host = screen.container.querySelector("[data-electron-browser-host]")!;
130 const webviews = () => [...host.querySelectorAll("webview")];
131 const before = webviews();
132 expect(before.map((node) => node.getAttribute("data-preview-server-tab")).toSorted()).toEqual([
133 "tab_7",
134 "tab_8",
135 ]);
136​
137 // Electron destroys a webview's guest when its element leaves the DOM, so a reorder that
138 // detaches and re-inserts the element blanks the tab.
139 const detached: Node[] = [];
140 const observer = new MutationObserver((records) => {
141 for (const record of records) detached.push(...record.removedNodes);
142 });
143 observer.observe(host, { childList: true, subtree: true });
144​
145 await act(async () => {
146 reconcilePreviewServerSessions(threadRef, {
147 sessions: [
148 snapshot("tab_8", "2026-01-01T00:00:02.000Z"),
149 snapshot("tab_7", "2026-01-01T00:00:03.000Z"),
150 ],
151 serverEpoch: "server-a",
152 revision: 2,
153 });
154 });
155 observer.takeRecords().forEach((record) => detached.push(...record.removedNodes));
156 observer.disconnect();
157​
158 expect(detached).toEqual([]);
159 const after = webviews();
160 expect(after).toHaveLength(2);
161 expect(after.every((node, index) => node.isSameNode(before[index]!))).toBe(true);
162 });
163});