diff --git a/package.json b/package.json
index fbce850..211e0c6 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test",
+ "test:e2e": "turbo run test:e2e",
"typecheck": "turbo run typecheck",
"prepare": "husky"
},
diff --git a/packages/web/.gitignore b/packages/web/.gitignore
new file mode 100644
index 0000000..aaa9103
--- /dev/null
+++ b/packages/web/.gitignore
@@ -0,0 +1,2 @@
+test-results/
+playwright-report/
diff --git a/packages/web/e2e/sidebar.spec.ts b/packages/web/e2e/sidebar.spec.ts
new file mode 100644
index 0000000..b0eb6e0
--- /dev/null
+++ b/packages/web/e2e/sidebar.spec.ts
@@ -0,0 +1,29 @@
+import { expect, test } from "@playwright/test";
+
+const CONFIG_KEY = "openconcho:config";
+const CONFIG_VALUE = JSON.stringify({ baseUrl: "http://localhost:9999", token: "" });
+
+test.describe("Sidebar", () => {
+ test.beforeEach(async ({ context }) => {
+ await context.addInitScript(
+ ([key, value]) => {
+ window.localStorage.setItem(key, value);
+ },
+ [CONFIG_KEY, CONFIG_VALUE],
+ );
+ });
+
+ test("renders the sidebar nav on the dashboard route", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.getByRole("complementary")).toBeVisible();
+ await expect(page.getByRole("link", { name: /dashboard/i })).toBeVisible();
+ await expect(page.getByRole("link", { name: /workspaces/i })).toBeVisible();
+ await expect(page.getByRole("link", { name: /settings/i })).toBeVisible();
+ });
+
+ test("renders the sidebar nav on the settings route", async ({ page }) => {
+ await page.goto("/settings");
+ await expect(page.getByRole("complementary")).toBeVisible();
+ await expect(page.getByRole("link", { name: /dashboard/i })).toBeVisible();
+ });
+});
diff --git a/packages/web/package.json b/packages/web/package.json
index a56bdea..a5426c8 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -10,12 +10,10 @@
"lint": "biome check src/",
"lint:fix": "biome check --write src/",
"test": "vitest run --passWithNoTests",
+ "test:e2e": "playwright test",
"generate:api": "openapi-typescript openapi.json -o src/api/schema.d.ts"
},
"dependencies": {
- "@tauri-apps/api": "^2",
- "@tauri-apps/plugin-http": "^2",
- "@tauri-apps/plugin-shell": "^2",
"@fontsource/dm-mono": "^5.2.7",
"@fontsource/dm-sans": "^5.2.8",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -27,6 +25,9 @@
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-router": "^1.120.3",
+ "@tauri-apps/api": "^2",
+ "@tauri-apps/plugin-http": "^2",
+ "@tauri-apps/plugin-shell": "^2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
@@ -42,6 +43,7 @@
"zod": "catalog:"
},
"devDependencies": {
+ "@playwright/test": "catalog:",
"@tanstack/router-plugin": "^1.120.3",
"@testing-library/jest-dom": "catalog:",
"@testing-library/react": "catalog:",
diff --git a/packages/web/playwright.config.ts b/packages/web/playwright.config.ts
new file mode 100644
index 0000000..350ca99
--- /dev/null
+++ b/packages/web/playwright.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./e2e",
+ fullyParallel: true,
+ reporter: "list",
+ use: {
+ baseURL: "http://localhost:5173",
+ },
+ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
+ webServer: {
+ command: "pnpm dev",
+ url: "http://localhost:5173",
+ reuseExistingServer: !process.env.CI,
+ timeout: 60_000,
+ },
+});
diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx
index 6dc7e15..65fe499 100644
--- a/packages/web/src/main.tsx
+++ b/packages/web/src/main.tsx
@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
+import { DemoProvider } from "./context/DemoContext";
import { routeTree } from "./routeTree.gen";
import "./index.css";
@@ -32,7 +33,9 @@ if (!root) throw new Error("Missing #root element");
createRoot(root).render(
-
+
+
+
,
);
diff --git a/packages/web/src/routes/__root.tsx b/packages/web/src/routes/__root.tsx
index 024c1bc..8dce51e 100644
--- a/packages/web/src/routes/__root.tsx
+++ b/packages/web/src/routes/__root.tsx
@@ -1,46 +1,36 @@
-import { createRootRoute, Outlet, useRouter } from "@tanstack/react-router";
+import { createRootRoute, Outlet, redirect } from "@tanstack/react-router";
import { useEffect } from "react";
import { Sidebar } from "@/components/layout/Sidebar";
-import { DemoProvider } from "@/context/DemoContext";
import { loadConfig } from "@/lib/config";
import { applyTheme, getStoredTheme } from "@/lib/theme";
-function RootLayout() {
- const config = loadConfig();
- const router = useRouter();
- const isSettings = router.state.location.pathname === "/settings";
+const SETTINGS_PATH = "/settings";
+function RootLayout() {
useEffect(() => {
applyTheme(getStoredTheme());
}, []);
- useEffect(() => {
- if (!config && !isSettings) {
- router.navigate({ to: "/settings" as never });
- }
- }, [config, isSettings, router]);
-
- if (isSettings) {
- return ;
- }
-
- if (!config) return null;
-
return (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
);
}
export const Route = createRootRoute({
+ beforeLoad: ({ location }) => {
+ // Redirect to settings synchronously when no config is present, so the
+ // first paint already shows the settings form instead of a blank screen.
+ if (location.pathname !== SETTINGS_PATH && !loadConfig()) {
+ throw redirect({ to: SETTINGS_PATH as never });
+ }
+ },
component: RootLayout,
});
diff --git a/packages/web/src/test/app.test.tsx b/packages/web/src/test/app.test.tsx
new file mode 100644
index 0000000..d62d859
--- /dev/null
+++ b/packages/web/src/test/app.test.tsx
@@ -0,0 +1,68 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { DemoProvider } from "@/context/DemoContext";
+import { useDemo } from "@/hooks/useDemo";
+import { routeTree } from "@/routeTree.gen";
+
+function renderAt(initialPath: string) {
+ const router = createRouter({
+ routeTree,
+ history: createMemoryHistory({ initialEntries: [initialPath] }),
+ });
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ {/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
+
+
+ ,
+ );
+}
+
+describe("first load with no config", () => {
+ it("renders the settings form on first paint when no config exists", async () => {
+ localStorage.clear();
+ renderAt("/");
+ // Should be visible immediately — bug 1: RootLayout returns null while
+ // a useEffect-driven navigate fires, leaving a blank screen.
+ expect(
+ await screen.findByText(/Connect to your self-hosted Honcho instance/i),
+ ).toBeInTheDocument();
+ });
+});
+
+describe("Sidebar/useDemo availability across routes", () => {
+ it("does not throw when a useDemo consumer mounts alongside the routed app", () => {
+ function DemoConsumer() {
+ const { demo } = useDemo();
+ return {String(demo)};
+ }
+ // After the fix, DemoProvider wraps the app at the root (main.tsx /
+ // __root.tsx) so consumers anywhere in the tree resolve. This test
+ // renders a consumer as a sibling of the router under the same provider
+ // the production wiring uses.
+ localStorage.clear();
+ expect(() => {
+ const router = createRouter({
+ routeTree,
+ history: createMemoryHistory({ initialEntries: ["/settings"] }),
+ });
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ render(
+
+
+ {/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
+
+
+
+ ,
+ );
+ }).not.toThrow();
+ expect(screen.getByTestId("demo-flag")).toBeInTheDocument();
+ });
+});
diff --git a/packages/web/src/test/setup.ts b/packages/web/src/test/setup.ts
new file mode 100644
index 0000000..d05756f
--- /dev/null
+++ b/packages/web/src/test/setup.ts
@@ -0,0 +1,26 @@
+import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterEach, vi } from "vitest";
+
+// jsdom doesn't implement matchMedia; theme code reads it on mount.
+if (!window.scrollTo) {
+ window.scrollTo = vi.fn() as unknown as typeof window.scrollTo;
+}
+
+if (!window.matchMedia) {
+ window.matchMedia = vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+}
+
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+});
diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts
new file mode 100644
index 0000000..6e546cb
--- /dev/null
+++ b/packages/web/vitest.config.ts
@@ -0,0 +1,26 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vitest/config";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ define: {
+ __APP_VERSION__: JSON.stringify("0.0.0-test"),
+ },
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./src/test/setup.ts"],
+ css: false,
+ include: ["src/**/*.{test,spec}.{ts,tsx}"],
+ exclude: ["node_modules", "dist", "e2e"],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6b70db9..f5f9c2e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,6 +9,9 @@ catalogs:
'@biomejs/biome':
specifier: ^2.4.0
version: 2.4.13
+ '@playwright/test':
+ specifier: ^1.59.1
+ version: 1.59.1
'@testing-library/jest-dom':
specifier: ^6.6.3
version: 6.9.1
@@ -192,6 +195,9 @@ importers:
specifier: 'catalog:'
version: 4.3.6
devDependencies:
+ '@playwright/test':
+ specifier: 'catalog:'
+ version: 1.59.1
'@tanstack/router-plugin':
specifier: ^1.120.3
version: 1.167.23(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))
@@ -837,6 +843,11 @@ packages:
'@oxc-project/types@0.127.0':
resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
+ '@playwright/test@1.59.1':
+ resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@pnpm/config.env-replace@1.1.0':
resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==}
engines: {node: '>=12.22.0'}
@@ -2267,6 +2278,11 @@ packages:
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
engines: {node: '>=14.14'}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -3199,6 +3215,16 @@ packages:
resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==}
engines: {node: '>=4'}
+ playwright-core@1.59.1:
+ resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.59.1:
+ resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -4586,6 +4612,10 @@ snapshots:
'@oxc-project/types@0.127.0': {}
+ '@playwright/test@1.59.1':
+ dependencies:
+ playwright: 1.59.1
+
'@pnpm/config.env-replace@1.1.0': {}
'@pnpm/network.ca-file@1.0.2':
@@ -5969,6 +5999,9 @@ snapshots:
jsonfile: 6.2.1
universalify: 2.0.1
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -6955,6 +6988,14 @@ snapshots:
find-up: 2.1.0
load-json-file: 4.0.0
+ playwright-core@1.59.1: {}
+
+ playwright@1.59.1:
+ dependencies:
+ playwright-core: 1.59.1
+ optionalDependencies:
+ fsevents: 2.3.2
+
pluralize@8.0.0: {}
postcss@8.5.10:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index c6ece1a..9235cda 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -8,6 +8,7 @@ catalog:
"@biomejs/biome": "^2.4.0"
# Testing
+ "@playwright/test": "^1.59.1"
"@testing-library/jest-dom": "^6.6.3"
"@testing-library/react": "^16.3.0"
"@testing-library/user-event": "^14.6.1"
diff --git a/turbo.json b/turbo.json
index 1b43942..4002a61 100644
--- a/turbo.json
+++ b/turbo.json
@@ -15,6 +15,10 @@
"test": {
"inputs": ["src/**", "vitest.config.*", "package.json"]
},
+ "test:e2e": {
+ "cache": false,
+ "inputs": ["e2e/**", "src/**", "playwright.config.*", "package.json"]
+ },
"cargo-check": {
"inputs": ["src-tauri/src/**", "src-tauri/Cargo.toml", "src-tauri/Cargo.lock"],
"outputs": []