Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 2 additions & 0 deletions packages/web/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
test-results/
playwright-report/
29 changes: 29 additions & 0 deletions packages/web/e2e/sidebar.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
8 changes: 5 additions & 3 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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:",
Expand Down
17 changes: 17 additions & 0 deletions packages/web/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
5 changes: 4 additions & 1 deletion packages/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -32,7 +33,9 @@ if (!root) throw new Error("Missing #root element");
createRoot(root).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<DemoProvider>
<RouterProvider router={router} />
</DemoProvider>
</QueryClientProvider>
</StrictMode>,
);
48 changes: 19 additions & 29 deletions packages/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -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 <Outlet />;
}

if (!config) return null;

return (
<DemoProvider>
<div
className="flex h-screen w-full overflow-hidden"
style={{ background: "var(--bg)", position: "relative", zIndex: 1 }}
>
<Sidebar />
<main className="flex-1 overflow-auto" style={{ position: "relative", zIndex: 1 }}>
<Outlet />
</main>
</div>
</DemoProvider>
<div
className="flex h-screen w-full overflow-hidden"
style={{ background: "var(--bg)", position: "relative", zIndex: 1 }}
>
<Sidebar />
<main className="flex-1 overflow-auto" style={{ position: "relative", zIndex: 1 }}>
<Outlet />
</main>
</div>
);
}

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,
});
68 changes: 68 additions & 0 deletions packages/web/src/test/app.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={qc}>
<DemoProvider>
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
<RouterProvider router={router as any} />
</DemoProvider>
</QueryClientProvider>,
);
}

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 <span data-testid="demo-flag">{String(demo)}</span>;
}
// 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(
<QueryClientProvider client={qc}>
<DemoProvider>
{/* biome-ignore lint/suspicious/noExplicitAny: test router type */}
<RouterProvider router={router as any} />
<DemoConsumer />
</DemoProvider>
</QueryClientProvider>,
);
}).not.toThrow();
expect(screen.getByTestId("demo-flag")).toBeInTheDocument();
});
});
26 changes: 26 additions & 0 deletions packages/web/src/test/setup.ts
Original file line number Diff line number Diff line change
@@ -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();
});
26 changes: 26 additions & 0 deletions packages/web/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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"],
},
});
41 changes: 41 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading