Skip to content

Commit 2c848d6

Browse files
Merge pull request #1 from offendingcommit/fix/web-settings-and-demo-provider
fix(web): settings shows on first load + global DemoProvider
2 parents 3fa4d59 + 34319db commit 2c848d6

13 files changed

Lines changed: 243 additions & 33 deletions

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"build": "turbo run build",
99
"lint": "turbo run lint",
1010
"test": "turbo run test",
11+
"test:e2e": "turbo run test:e2e",
1112
"typecheck": "turbo run typecheck",
1213
"prepare": "husky"
1314
},

packages/web/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
test-results/
2+
playwright-report/

packages/web/e2e/sidebar.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
const CONFIG_KEY = "openconcho:config";
4+
const CONFIG_VALUE = JSON.stringify({ baseUrl: "http://localhost:9999", token: "" });
5+
6+
test.describe("Sidebar", () => {
7+
test.beforeEach(async ({ context }) => {
8+
await context.addInitScript(
9+
([key, value]) => {
10+
window.localStorage.setItem(key, value);
11+
},
12+
[CONFIG_KEY, CONFIG_VALUE],
13+
);
14+
});
15+
16+
test("renders the sidebar nav on the dashboard route", async ({ page }) => {
17+
await page.goto("/");
18+
await expect(page.getByRole("complementary")).toBeVisible();
19+
await expect(page.getByRole("link", { name: /dashboard/i })).toBeVisible();
20+
await expect(page.getByRole("link", { name: /workspaces/i })).toBeVisible();
21+
await expect(page.getByRole("link", { name: /settings/i })).toBeVisible();
22+
});
23+
24+
test("renders the sidebar nav on the settings route", async ({ page }) => {
25+
await page.goto("/settings");
26+
await expect(page.getByRole("complementary")).toBeVisible();
27+
await expect(page.getByRole("link", { name: /dashboard/i })).toBeVisible();
28+
});
29+
});

packages/web/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,10 @@
1010
"lint": "biome check src/",
1111
"lint:fix": "biome check --write src/",
1212
"test": "vitest run --passWithNoTests",
13+
"test:e2e": "playwright test",
1314
"generate:api": "openapi-typescript openapi.json -o src/api/schema.d.ts"
1415
},
1516
"dependencies": {
16-
"@tauri-apps/api": "^2",
17-
"@tauri-apps/plugin-http": "^2",
18-
"@tauri-apps/plugin-shell": "^2",
1917
"@fontsource/dm-mono": "^5.2.7",
2018
"@fontsource/dm-sans": "^5.2.8",
2119
"@radix-ui/react-collapsible": "^1.1.12",
@@ -27,6 +25,9 @@
2725
"@tailwindcss/vite": "^4.2.4",
2826
"@tanstack/react-query": "^5.74.4",
2927
"@tanstack/react-router": "^1.120.3",
28+
"@tauri-apps/api": "^2",
29+
"@tauri-apps/plugin-http": "^2",
30+
"@tauri-apps/plugin-shell": "^2",
3031
"class-variance-authority": "^0.7.1",
3132
"clsx": "^2.1.1",
3233
"framer-motion": "^12.38.0",
@@ -42,6 +43,7 @@
4243
"zod": "catalog:"
4344
},
4445
"devDependencies": {
46+
"@playwright/test": "catalog:",
4547
"@tanstack/router-plugin": "^1.120.3",
4648
"@testing-library/jest-dom": "catalog:",
4749
"@testing-library/react": "catalog:",

packages/web/playwright.config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
3+
export default defineConfig({
4+
testDir: "./e2e",
5+
fullyParallel: true,
6+
reporter: "list",
7+
use: {
8+
baseURL: "http://localhost:5173",
9+
},
10+
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
11+
webServer: {
12+
command: "pnpm dev",
13+
url: "http://localhost:5173",
14+
reuseExistingServer: !process.env.CI,
15+
timeout: 60_000,
16+
},
17+
});

packages/web/src/main.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
22
import { createRouter, RouterProvider } from "@tanstack/react-router";
33
import { StrictMode } from "react";
44
import { createRoot } from "react-dom/client";
5+
import { DemoProvider } from "./context/DemoContext";
56
import { routeTree } from "./routeTree.gen";
67
import "./index.css";
78

@@ -32,7 +33,9 @@ if (!root) throw new Error("Missing #root element");
3233
createRoot(root).render(
3334
<StrictMode>
3435
<QueryClientProvider client={queryClient}>
35-
<RouterProvider router={router} />
36+
<DemoProvider>
37+
<RouterProvider router={router} />
38+
</DemoProvider>
3639
</QueryClientProvider>
3740
</StrictMode>,
3841
);

packages/web/src/routes/__root.tsx

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,36 @@
1-
import { createRootRoute, Outlet, useRouter } from "@tanstack/react-router";
1+
import { createRootRoute, Outlet, redirect } from "@tanstack/react-router";
22
import { useEffect } from "react";
33
import { Sidebar } from "@/components/layout/Sidebar";
4-
import { DemoProvider } from "@/context/DemoContext";
54
import { loadConfig } from "@/lib/config";
65
import { applyTheme, getStoredTheme } from "@/lib/theme";
76

8-
function RootLayout() {
9-
const config = loadConfig();
10-
const router = useRouter();
11-
const isSettings = router.state.location.pathname === "/settings";
7+
const SETTINGS_PATH = "/settings";
128

9+
function RootLayout() {
1310
useEffect(() => {
1411
applyTheme(getStoredTheme());
1512
}, []);
1613

17-
useEffect(() => {
18-
if (!config && !isSettings) {
19-
router.navigate({ to: "/settings" as never });
20-
}
21-
}, [config, isSettings, router]);
22-
23-
if (isSettings) {
24-
return <Outlet />;
25-
}
26-
27-
if (!config) return null;
28-
2914
return (
30-
<DemoProvider>
31-
<div
32-
className="flex h-screen w-full overflow-hidden"
33-
style={{ background: "var(--bg)", position: "relative", zIndex: 1 }}
34-
>
35-
<Sidebar />
36-
<main className="flex-1 overflow-auto" style={{ position: "relative", zIndex: 1 }}>
37-
<Outlet />
38-
</main>
39-
</div>
40-
</DemoProvider>
15+
<div
16+
className="flex h-screen w-full overflow-hidden"
17+
style={{ background: "var(--bg)", position: "relative", zIndex: 1 }}
18+
>
19+
<Sidebar />
20+
<main className="flex-1 overflow-auto" style={{ position: "relative", zIndex: 1 }}>
21+
<Outlet />
22+
</main>
23+
</div>
4124
);
4225
}
4326

4427
export const Route = createRootRoute({
28+
beforeLoad: ({ location }) => {
29+
// Redirect to settings synchronously when no config is present, so the
30+
// first paint already shows the settings form instead of a blank screen.
31+
if (location.pathname !== SETTINGS_PATH && !loadConfig()) {
32+
throw redirect({ to: SETTINGS_PATH as never });
33+
}
34+
},
4535
component: RootLayout,
4636
});

packages/web/src/test/app.test.tsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router";
3+
import { render, screen } from "@testing-library/react";
4+
import { describe, expect, it } from "vitest";
5+
import { DemoProvider } from "@/context/DemoContext";
6+
import { useDemo } from "@/hooks/useDemo";
7+
import { routeTree } from "@/routeTree.gen";
8+
9+
function renderAt(initialPath: string) {
10+
const router = createRouter({
11+
routeTree,
12+
history: createMemoryHistory({ initialEntries: [initialPath] }),
13+
});
14+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
15+
return render(
16+
<QueryClientProvider client={qc}>
17+
<DemoProvider>
18+
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
19+
<RouterProvider router={router as any} />
20+
</DemoProvider>
21+
</QueryClientProvider>,
22+
);
23+
}
24+
25+
describe("first load with no config", () => {
26+
it("renders the settings form on first paint when no config exists", async () => {
27+
localStorage.clear();
28+
renderAt("/");
29+
// Should be visible immediately — bug 1: RootLayout returns null while
30+
// a useEffect-driven navigate fires, leaving a blank screen.
31+
expect(
32+
await screen.findByText(/Connect to your self-hosted Honcho instance/i),
33+
).toBeInTheDocument();
34+
});
35+
});
36+
37+
describe("Sidebar/useDemo availability across routes", () => {
38+
it("does not throw when a useDemo consumer mounts alongside the routed app", () => {
39+
function DemoConsumer() {
40+
const { demo } = useDemo();
41+
return <span data-testid="demo-flag">{String(demo)}</span>;
42+
}
43+
// After the fix, DemoProvider wraps the app at the root (main.tsx /
44+
// __root.tsx) so consumers anywhere in the tree resolve. This test
45+
// renders a consumer as a sibling of the router under the same provider
46+
// the production wiring uses.
47+
localStorage.clear();
48+
expect(() => {
49+
const router = createRouter({
50+
routeTree,
51+
history: createMemoryHistory({ initialEntries: ["/settings"] }),
52+
});
53+
const qc = new QueryClient({
54+
defaultOptions: { queries: { retry: false } },
55+
});
56+
render(
57+
<QueryClientProvider client={qc}>
58+
<DemoProvider>
59+
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
60+
<RouterProvider router={router as any} />
61+
<DemoConsumer />
62+
</DemoProvider>
63+
</QueryClientProvider>,
64+
);
65+
}).not.toThrow();
66+
expect(screen.getByTestId("demo-flag")).toBeInTheDocument();
67+
});
68+
});

packages/web/src/test/setup.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import "@testing-library/jest-dom/vitest";
2+
import { cleanup } from "@testing-library/react";
3+
import { afterEach, vi } from "vitest";
4+
5+
// jsdom doesn't implement matchMedia; theme code reads it on mount.
6+
if (!window.scrollTo) {
7+
window.scrollTo = vi.fn() as unknown as typeof window.scrollTo;
8+
}
9+
10+
if (!window.matchMedia) {
11+
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
12+
matches: false,
13+
media: query,
14+
onchange: null,
15+
addListener: vi.fn(),
16+
removeListener: vi.fn(),
17+
addEventListener: vi.fn(),
18+
removeEventListener: vi.fn(),
19+
dispatchEvent: vi.fn(),
20+
}));
21+
}
22+
23+
afterEach(() => {
24+
cleanup();
25+
localStorage.clear();
26+
});

packages/web/vitest.config.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import path from "node:path";
2+
import { fileURLToPath } from "node:url";
3+
import react from "@vitejs/plugin-react";
4+
import { defineConfig } from "vitest/config";
5+
6+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
7+
8+
export default defineConfig({
9+
plugins: [react()],
10+
resolve: {
11+
alias: {
12+
"@": path.resolve(__dirname, "./src"),
13+
},
14+
},
15+
define: {
16+
__APP_VERSION__: JSON.stringify("0.0.0-test"),
17+
},
18+
test: {
19+
environment: "jsdom",
20+
globals: true,
21+
setupFiles: ["./src/test/setup.ts"],
22+
css: false,
23+
include: ["src/**/*.{test,spec}.{ts,tsx}"],
24+
exclude: ["node_modules", "dist", "e2e"],
25+
},
26+
});

0 commit comments

Comments
 (0)