From 8f5a6aa7e9118e182d5959f902f07dafe2026f30 Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Sun, 3 May 2026 16:41:59 -0500 Subject: [PATCH 1/4] fix(web): show settings on first load and hoist DemoProvider globally Bug 1: On a fresh load with no saved config, RootLayout returned `null` while a useEffect-driven `router.navigate()` fired, leaving a blank screen until the user manually refreshed. Move the redirect into the root route's `beforeLoad` so it happens synchronously during route resolution and the settings form renders on first paint. Bug 2: `DemoProvider` was mounted inside `RootLayout` only on the non-settings branch, so any component reading `useDemo()` outside that branch would throw "useDemoContext must be used within DemoProvider". Hoist `` to `main.tsx` so the context is available app-wide. Adds vitest + RTL setup with regression tests for both behaviours. --- packages/web/src/main.tsx | 5 ++- packages/web/src/routes/__root.tsx | 43 +++++++++--------- packages/web/src/test/app.test.tsx | 72 ++++++++++++++++++++++++++++++ packages/web/src/test/setup.ts | 26 +++++++++++ packages/web/vitest.config.ts | 24 ++++++++++ 5 files changed, 146 insertions(+), 24 deletions(-) create mode 100644 packages/web/src/test/app.test.tsx create mode 100644 packages/web/src/test/setup.ts create mode 100644 packages/web/vitest.config.ts 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..07b902f 100644 --- a/packages/web/src/routes/__root.tsx +++ b/packages/web/src/routes/__root.tsx @@ -1,46 +1,43 @@ -import { createRootRoute, Outlet, useRouter } from "@tanstack/react-router"; +import { createRootRoute, Outlet, redirect, useRouter } 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"; +const SETTINGS_PATH = "/settings"; + function RootLayout() { - const config = loadConfig(); const router = useRouter(); - const isSettings = router.state.location.pathname === "/settings"; + const isSettings = router.state.location.pathname === SETTINGS_PATH; 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..28e0160 --- /dev/null +++ b/packages/web/src/test/app.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { routeTree } from "@/routeTree.gen"; +import { DemoProvider } from "@/context/DemoContext"; +import { useDemo } from "@/hooks/useDemo"; + +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..95266bc --- /dev/null +++ b/packages/web/src/test/setup.ts @@ -0,0 +1,26 @@ +import "@testing-library/jest-dom/vitest"; +import { afterEach, vi } from "vitest"; +import { cleanup } from "@testing-library/react"; + +// 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..e154f77 --- /dev/null +++ b/packages/web/vitest.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +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, + }, +}); From 784cbee870f853534edd1c7d2344ef3f3b5c3498 Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Sun, 3 May 2026 16:54:18 -0500 Subject: [PATCH 2/4] style(web): fix biome import order in test files --- packages/web/src/test/app.test.tsx | 12 ++++-------- packages/web/src/test/setup.ts | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/web/src/test/app.test.tsx b/packages/web/src/test/app.test.tsx index 28e0160..d62d859 100644 --- a/packages/web/src/test/app.test.tsx +++ b/packages/web/src/test/app.test.tsx @@ -1,14 +1,10 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { - createMemoryHistory, - createRouter, - RouterProvider, -} from "@tanstack/react-router"; -import { routeTree } from "@/routeTree.gen"; +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({ diff --git a/packages/web/src/test/setup.ts b/packages/web/src/test/setup.ts index 95266bc..d05756f 100644 --- a/packages/web/src/test/setup.ts +++ b/packages/web/src/test/setup.ts @@ -1,6 +1,6 @@ import "@testing-library/jest-dom/vitest"; -import { afterEach, vi } from "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) { From 557fecf03807b207539e6b50f32b3eec0cfe7191 Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Sun, 3 May 2026 16:58:43 -0500 Subject: [PATCH 3/4] fix(web): render sidebar on settings route Settings page was rendering Outlet directly, omitting the Sidebar nav. Adds a playwright e2e test asserting sidebar visibility on both dashboard and settings routes. --- packages/web/.gitignore | 2 ++ packages/web/e2e/sidebar.spec.ts | 29 +++++++++++++++++++++++ packages/web/package.json | 8 ++++--- packages/web/playwright.config.ts | 17 +++++++++++++ packages/web/src/routes/__root.tsx | 9 +------ pnpm-lock.yaml | 38 ++++++++++++++++++++++++++++++ 6 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 packages/web/.gitignore create mode 100644 packages/web/e2e/sidebar.spec.ts create mode 100644 packages/web/playwright.config.ts 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..00056b4 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": "^1.59.1", "@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/routes/__root.tsx b/packages/web/src/routes/__root.tsx index 07b902f..8dce51e 100644 --- a/packages/web/src/routes/__root.tsx +++ b/packages/web/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { createRootRoute, Outlet, redirect, useRouter } from "@tanstack/react-router"; +import { createRootRoute, Outlet, redirect } from "@tanstack/react-router"; import { useEffect } from "react"; import { Sidebar } from "@/components/layout/Sidebar"; import { loadConfig } from "@/lib/config"; @@ -7,17 +7,10 @@ import { applyTheme, getStoredTheme } from "@/lib/theme"; const SETTINGS_PATH = "/settings"; function RootLayout() { - const router = useRouter(); - const isSettings = router.state.location.pathname === SETTINGS_PATH; - useEffect(() => { applyTheme(getStoredTheme()); }, []); - if (isSettings) { - return ; - } - return (
=18'} + hasBin: true + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -2267,6 +2275,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 +3212,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 +4609,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 +5996,9 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -6955,6 +6985,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: From 34319db283a4a6d910771feff288c90c8eeb854b Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Sun, 3 May 2026 17:03:55 -0500 Subject: [PATCH 4/4] chore(test): catalog @playwright/test and wire e2e into turbo - Promote @playwright/test to the workspace catalog - Add test:e2e turbo task (uncached) - Add root pnpm test:e2e script - Vitest scopes to src/**/*.{test,spec} and excludes e2e/ --- package.json | 1 + packages/web/package.json | 2 +- packages/web/vitest.config.ts | 6 ++++-- pnpm-lock.yaml | 5 ++++- pnpm-workspace.yaml | 1 + turbo.json | 4 ++++ 6 files changed, 15 insertions(+), 4 deletions(-) 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/package.json b/packages/web/package.json index 00056b4..a5426c8 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -43,7 +43,7 @@ "zod": "catalog:" }, "devDependencies": { - "@playwright/test": "^1.59.1", + "@playwright/test": "catalog:", "@tanstack/router-plugin": "^1.120.3", "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts index e154f77..6e546cb 100644 --- a/packages/web/vitest.config.ts +++ b/packages/web/vitest.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from "vitest/config"; -import react from "@vitejs/plugin-react"; 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)); @@ -20,5 +20,7 @@ export default defineConfig({ 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 322366c..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 @@ -193,7 +196,7 @@ importers: version: 4.3.6 devDependencies: '@playwright/test': - specifier: ^1.59.1 + specifier: 'catalog:' version: 1.59.1 '@tanstack/router-plugin': specifier: ^1.120.3 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": []