Skip to content

Commit 28281c2

Browse files
committed
Add browser-test screenshot harness for the WSL settings UX
Adds a vitest-browser-playwright test that captures the Connections-panel screenshots used in the PR overview, plus the surrounding fixes needed to keep `bun run test:browser` green. - New `ConnectionsSettings.screenshots.browser.tsx`: mounts the panel in four states (WSL off, WSL on with Ubuntu, confirmation dialog, phased loading) and saves PNGs under `screenshots-out/pr-2353/`. The panel-only captures use playwright's `element` option targeting the `Manage local backend` SettingsSection so the desktop viewport doesn't pad them with empty page below the card. - `vitest.browser.config.ts`: pin the playwright viewport to 1280x800 so the panel renders at desktop dimensions instead of mobile width. - `SettingsPanels.browser.tsx`: stub `useLocation`/`useNavigate` from `@tanstack/react-router` (ConnectionsSettings reads them since the WSL polish landed; the harness mounts without a RouterProvider). Also tighten two `getByText("This Mac")` assertions to `getByRole("heading", ...)` so they don't substring-match into the WSL row's "No WSL distributions were found on this machine." text. - `.gitignore`: ignore the screenshot output directory; the captured PNGs are pushed to the dedicated `josh/pr-2353-screenshots` branch rather than this branch.
1 parent 443ec84 commit 28281c2

4 files changed

Lines changed: 288 additions & 2 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ release-mock/
1818
apps/web/.playwright
1919
apps/web/playwright-report
2020
apps/web/src/components/__screenshots__
21+
apps/web/src/components/settings/screenshots-out/
2122
.vitest-*
2223
__screenshots__/
2324
.tanstack
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import "../../index.css";
2+
3+
import {
4+
DEFAULT_SERVER_SETTINGS,
5+
EnvironmentId,
6+
type DesktopBridge,
7+
type DesktopUpdateState,
8+
type ServerConfig,
9+
} from "@t3tools/contracts";
10+
import { page } from "vitest/browser";
11+
import { afterEach, beforeEach, describe, it, vi } from "vitest";
12+
import { render } from "vitest-browser-react";
13+
14+
vi.mock("@tanstack/react-router", async () => {
15+
const actual =
16+
await vi.importActual<typeof import("@tanstack/react-router")>("@tanstack/react-router");
17+
return {
18+
...actual,
19+
useLocation: ({ select }: { select?: (location: { pathname: string }) => unknown } = {}) => {
20+
const location = { pathname: "/settings/connections" };
21+
return select ? select(location) : location;
22+
},
23+
useNavigate: () => async () => undefined,
24+
};
25+
});
26+
27+
import { __resetLocalApiForTests } from "../../localApi";
28+
import {
29+
writePrimaryEnvironmentDescriptor,
30+
__resetPrimaryEnvironmentBootstrapForTests,
31+
} from "../../environments/primary/context";
32+
import { AppAtomRegistryProvider } from "../../rpc/atomRegistry";
33+
import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";
34+
import { ConnectionsSettings } from "./ConnectionsSettings";
35+
36+
const SCREENSHOT_DIR = "screenshots-out/pr-2353";
37+
38+
function createBaseServerConfig(): ServerConfig {
39+
return {
40+
environment: {
41+
environmentId: EnvironmentId.make("environment-local"),
42+
label: "Local environment",
43+
platform: { os: "windows" as const, arch: "x64" as const },
44+
serverVersion: "0.0.0-test",
45+
capabilities: { repositoryIdentity: true },
46+
},
47+
auth: {
48+
policy: "loopback-browser",
49+
bootstrapMethods: ["one-time-token"],
50+
sessionMethods: ["browser-session-cookie", "bearer-session-token"],
51+
sessionCookieName: "t3_session",
52+
},
53+
cwd: "/repo/project",
54+
keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",
55+
keybindings: [],
56+
issues: [],
57+
providers: [],
58+
availableEditors: ["cursor"],
59+
observability: {
60+
logsDirectoryPath: "/repo/project/.t3/logs",
61+
localTracingEnabled: true,
62+
otlpTracesUrl: "http://localhost:4318/v1/traces",
63+
otlpTracesEnabled: true,
64+
otlpMetricsEnabled: false,
65+
},
66+
settings: DEFAULT_SERVER_SETTINGS,
67+
};
68+
}
69+
70+
interface BridgeOverrides {
71+
readonly wslConfig: { enabled: boolean; distro: string | null };
72+
readonly wslDistros: ReadonlyArray<{ name: string; isDefault: boolean; version: 1 | 2 }>;
73+
readonly wslSetConfig?: DesktopBridge["wslSetConfig"];
74+
}
75+
76+
function createBridge(overrides: BridgeOverrides): DesktopBridge {
77+
const idleUpdateState: DesktopUpdateState = {
78+
enabled: false,
79+
status: "idle",
80+
channel: "latest",
81+
currentVersion: "0.0.0-test",
82+
hostArch: "x64",
83+
appArch: "x64",
84+
runningUnderArm64Translation: false,
85+
availableVersion: null,
86+
downloadedVersion: null,
87+
downloadPercent: null,
88+
checkedAt: null,
89+
message: null,
90+
errorContext: null,
91+
canRetry: false,
92+
};
93+
return {
94+
getAppBranding: vi.fn().mockReturnValue(null),
95+
getLocalEnvironmentBootstrap: () => ({
96+
label: "Local environment",
97+
httpBaseUrl: "http://127.0.0.1:3773",
98+
wsBaseUrl: "ws://127.0.0.1:3773",
99+
bootstrapToken: "desktop-bootstrap-token",
100+
}),
101+
getClientSettings: vi.fn().mockResolvedValue(null),
102+
setClientSettings: vi.fn().mockResolvedValue(undefined),
103+
getSavedEnvironmentRegistry: vi.fn().mockResolvedValue([]),
104+
setSavedEnvironmentRegistry: vi.fn().mockResolvedValue(undefined),
105+
getSavedEnvironmentSecret: vi.fn().mockResolvedValue(null),
106+
setSavedEnvironmentSecret: vi.fn().mockResolvedValue(true),
107+
removeSavedEnvironmentSecret: vi.fn().mockResolvedValue(undefined),
108+
getServerExposureState: vi.fn().mockResolvedValue({
109+
mode: "local-only",
110+
endpointUrl: null,
111+
advertisedHost: null,
112+
}),
113+
setServerExposureMode: vi.fn().mockImplementation(async (mode) => ({
114+
mode,
115+
endpointUrl: null,
116+
advertisedHost: null,
117+
})),
118+
pickFolder: vi.fn().mockResolvedValue(null),
119+
confirm: vi.fn().mockResolvedValue(false),
120+
setTheme: vi.fn().mockResolvedValue(undefined),
121+
showContextMenu: vi.fn().mockResolvedValue(null),
122+
openExternal: vi.fn().mockResolvedValue(true),
123+
onMenuAction: () => () => {},
124+
getUpdateState: vi.fn().mockResolvedValue(idleUpdateState),
125+
setUpdateChannel: vi.fn().mockImplementation(async (channel) => ({
126+
...idleUpdateState,
127+
channel,
128+
})),
129+
checkForUpdate: vi.fn().mockResolvedValue({ checked: false, state: idleUpdateState }),
130+
downloadUpdate: vi
131+
.fn()
132+
.mockResolvedValue({ accepted: false, completed: false, state: idleUpdateState }),
133+
installUpdate: vi
134+
.fn()
135+
.mockResolvedValue({ accepted: false, completed: false, state: idleUpdateState }),
136+
wslListDistros: vi.fn().mockResolvedValue(overrides.wslDistros),
137+
wslGetConfig: vi.fn().mockResolvedValue(overrides.wslConfig),
138+
wslSetConfig: overrides.wslSetConfig ?? vi.fn().mockResolvedValue(true),
139+
onUpdateState: () => () => {},
140+
};
141+
}
142+
143+
const distros = [
144+
{ name: "Ubuntu", isDefault: true, version: 2 as const },
145+
{ name: "Debian", isDefault: false, version: 2 as const },
146+
{ name: "Alpine", isDefault: false, version: 2 as const },
147+
];
148+
149+
describe("ConnectionsSettings WSL screenshots", () => {
150+
let mounted:
151+
| (Awaited<ReturnType<typeof render>> & { cleanup?: () => Promise<void> })
152+
| null = null;
153+
154+
beforeEach(async () => {
155+
resetServerStateForTests();
156+
await __resetLocalApiForTests();
157+
__resetPrimaryEnvironmentBootstrapForTests();
158+
localStorage.clear();
159+
setServerConfigSnapshot(createBaseServerConfig());
160+
writePrimaryEnvironmentDescriptor({
161+
environmentId: EnvironmentId.make("environment-local"),
162+
label: "Local environment",
163+
platform: { os: "windows" as const, arch: "x64" as const },
164+
serverVersion: "0.0.0-test",
165+
capabilities: { repositoryIdentity: true },
166+
});
167+
});
168+
169+
afterEach(async () => {
170+
if (mounted) {
171+
const teardown = mounted.cleanup ?? mounted.unmount;
172+
await teardown?.call(mounted).catch(() => {});
173+
}
174+
mounted = null;
175+
vi.unstubAllGlobals();
176+
Reflect.deleteProperty(window, "desktopBridge");
177+
__resetPrimaryEnvironmentBootstrapForTests();
178+
document.body.innerHTML = "";
179+
});
180+
181+
// Capture only the "Manage local backend" section element so the desktop
182+
// viewport doesn't pad the panel screenshots with empty page below.
183+
const findManageLocalBackendSection = async (): Promise<Element> => {
184+
const heading = await page
185+
.getByRole("heading", { name: "Manage local backend", exact: true })
186+
.element();
187+
const section = heading.closest("section");
188+
if (!section) throw new Error("Could not find SettingsSection ancestor");
189+
return section;
190+
};
191+
192+
it("captures: WSL backend off (default)", async () => {
193+
window.desktopBridge = createBridge({
194+
wslConfig: { enabled: false, distro: null },
195+
wslDistros: distros,
196+
});
197+
mounted = await render(
198+
<AppAtomRegistryProvider>
199+
<ConnectionsSettings />
200+
</AppAtomRegistryProvider>,
201+
);
202+
await page.getByText("WSL backend").element();
203+
await page.getByText("Launch the local backend inside WSL instead of Windows.").element();
204+
await page.screenshot({
205+
path: `${SCREENSHOT_DIR}/wsl-backend-settings-off.png`,
206+
save: true,
207+
element: await findManageLocalBackendSection(),
208+
});
209+
});
210+
211+
it("captures: WSL backend on with Ubuntu selected", async () => {
212+
window.desktopBridge = createBridge({
213+
wslConfig: { enabled: true, distro: null },
214+
wslDistros: distros,
215+
});
216+
mounted = await render(
217+
<AppAtomRegistryProvider>
218+
<ConnectionsSettings />
219+
</AppAtomRegistryProvider>,
220+
);
221+
await page.getByText("Launching the local backend inside Ubuntu.").element();
222+
await page.screenshot({
223+
path: `${SCREENSHOT_DIR}/wsl-backend-settings-on.png`,
224+
save: true,
225+
element: await findManageLocalBackendSection(),
226+
});
227+
});
228+
229+
it("captures: confirmation dialog when enabling WSL", async () => {
230+
window.desktopBridge = createBridge({
231+
wslConfig: { enabled: false, distro: null },
232+
wslDistros: distros,
233+
});
234+
mounted = await render(
235+
<AppAtomRegistryProvider>
236+
<ConnectionsSettings />
237+
</AppAtomRegistryProvider>,
238+
);
239+
await page.getByLabelText("Enable WSL backend").click();
240+
await page.getByText("Enable WSL backend?").element();
241+
await page.screenshot({ path: `${SCREENSHOT_DIR}/wsl-backend-confirm-dialog.png`, save: true });
242+
});
243+
244+
it("captures: phased loading state during a swap", async () => {
245+
let resolveSetConfig!: (value: boolean) => void;
246+
const setConfigPromise = new Promise<boolean>((resolve) => {
247+
resolveSetConfig = resolve;
248+
});
249+
window.desktopBridge = createBridge({
250+
wslConfig: { enabled: false, distro: null },
251+
wslDistros: distros,
252+
wslSetConfig: vi.fn().mockReturnValue(setConfigPromise),
253+
});
254+
mounted = await render(
255+
<AppAtomRegistryProvider>
256+
<ConnectionsSettings />
257+
</AppAtomRegistryProvider>,
258+
);
259+
await page.getByLabelText("Enable WSL backend").click();
260+
await page.getByRole("button", { name: "Restart in WSL" }).click();
261+
await page.getByText("Restarting backend…").element();
262+
await page.screenshot({
263+
path: `${SCREENSHOT_DIR}/wsl-backend-confirm-loading.png`,
264+
save: true,
265+
});
266+
resolveSetConfig(true);
267+
});
268+
});

apps/web/src/components/settings/SettingsPanels.browser.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,22 @@ import { page } from "vitest/browser";
1717
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1818
import { render } from "vitest-browser-react";
1919

20+
// ConnectionsSettings reads useLocation/useNavigate from @tanstack/react-router.
21+
// The harness mounts it without a RouterProvider, so stub the hooks to a fixed
22+
// pathname and a no-op navigate.
23+
vi.mock("@tanstack/react-router", async () => {
24+
const actual =
25+
await vi.importActual<typeof import("@tanstack/react-router")>("@tanstack/react-router");
26+
return {
27+
...actual,
28+
useLocation: ({ select }: { select?: (location: { pathname: string }) => unknown } = {}) => {
29+
const location = { pathname: "/settings/connections" };
30+
return select ? select(location) : location;
31+
},
32+
useNavigate: () => async () => undefined,
33+
};
34+
});
35+
2036
import { __resetLocalApiForTests } from "../../localApi";
2137
import { AppAtomRegistryProvider } from "../../rpc/atomRegistry";
2238
import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";
@@ -557,7 +573,7 @@ describe("GeneralSettingsPanel observability", () => {
557573

558574
await expect.element(page.getByText("Authorized clients")).toBeInTheDocument();
559575
await expect.element(page.getByText("Revoke others")).toBeInTheDocument();
560-
await expect.element(page.getByText("This Mac")).toBeInTheDocument();
576+
await expect.element(page.getByRole("heading", { name: "This Mac" })).toBeInTheDocument();
561577
await page.getByRole("button", { name: "Create link", exact: true }).click();
562578
await expect.element(page.getByText("Create pairing link")).toBeInTheDocument();
563579
await page.getByRole("button", { name: "Create link", exact: true }).click();
@@ -650,7 +666,7 @@ describe("GeneralSettingsPanel observability", () => {
650666

651667
await expect.element(page.getByText("Julius iPhone")).toBeInTheDocument();
652668
await page.getByRole("button", { name: "Revoke others", exact: true }).click();
653-
await expect.element(page.getByText("This Mac")).toBeInTheDocument();
669+
await expect.element(page.getByRole("heading", { name: "This Mac" })).toBeInTheDocument();
654670
await expect.element(page.getByText("Julius iPhone")).not.toBeInTheDocument();
655671
expect(fetchMock).toHaveBeenCalled();
656672
});

apps/web/vitest.browser.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export default mergeConfig(
2626
provider: playwright(),
2727
instances: [{ browser: "chromium" }],
2828
headless: true,
29+
viewport: { width: 1280, height: 800 },
2930
api: {
3031
strictPort: false,
3132
},

0 commit comments

Comments
 (0)