diff --git a/apps/web/scripts/check-structure.js b/apps/web/scripts/check-structure.js new file mode 100644 index 0000000..ea15fa8 --- /dev/null +++ b/apps/web/scripts/check-structure.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/* eslint-disable */ +/** + * Verifies repository structure conventions. + * Run with: node scripts/check-structure.js + */ + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SRC_DIR = path.join(__dirname, "..", "src"); + +let errors = 0; +let warnings = 0; + +function checkFileSize(filePath, maxLines = 300) { + const content = fs.readFileSync(filePath, "utf-8"); + const lines = content.split("\n").length; + if (lines > maxLines) { + console.error(`❌ OVERSIZED (${lines} lines): ${path.relative(SRC_DIR, filePath)}`); + errors++; + } +} + +function walk(dir, callback) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name.startsWith(".")) continue; + walk(fullPath, callback); + } else { + callback(fullPath); + } + } +} + +console.log("Checking file sizes...\n"); +walk(SRC_DIR, (filePath) => { + const ext = path.extname(filePath); + if ([".ts", ".tsx", ".py", ".css"].includes(ext)) { + checkFileSize(filePath); + } +}); + +console.log("\n---"); +if (errors === 0 && warnings === 0) { + console.log("✅ All checks passed!"); + process.exit(0); +} else { + console.log(`❌ ${errors} error(s), ${warnings} warning(s)`); + process.exit(1); +} diff --git a/apps/web/src/components/features/git/FileBrowser.test.tsx b/apps/web/src/components/features/git/FileBrowser.test.tsx index b6e47a4..c736343 100644 --- a/apps/web/src/components/features/git/FileBrowser.test.tsx +++ b/apps/web/src/components/features/git/FileBrowser.test.tsx @@ -5,58 +5,58 @@ import { FileBrowser } from "./FileBrowser"; // Mock apiClient vi.mock("../../../api/client", () => ({ - apiClient: { - get: vi.fn(), - }, + apiClient: { + get: vi.fn(), + }, })); import { apiClient } from "../../../api/client"; describe("FileBrowser", () => { - it("renders loading state initially", () => { - render( - - - - ); - expect(screen.getByText(/loading files/i)).toBeInTheDocument(); - }); + it("renders loading state initially", () => { + render( + + + , + ); + expect(screen.getByText(/loading files/i)).toBeInTheDocument(); + }); - it("renders file entries after loading", async () => { - const mockedGet = apiClient.get as ReturnType; - mockedGet.mockResolvedValueOnce({ - data: { - entries: [ - { name: "src", type: "directory", path: "src" }, - { name: "README.md", type: "file", path: "README.md" }, - ], - }, - }); + it("renders file entries after loading", async () => { + const mockedGet = apiClient.get as ReturnType; + mockedGet.mockResolvedValueOnce({ + data: { + entries: [ + { name: "src", type: "directory", path: "src" }, + { name: "README.md", type: "file", path: "README.md" }, + ], + }, + }); - render( - - - - ); + render( + + + , + ); - await waitFor(() => { - expect(screen.getByText("src")).toBeInTheDocument(); - }); - expect(screen.getByText("README.md")).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText("src")).toBeInTheDocument(); + }); + expect(screen.getByText("README.md")).toBeInTheDocument(); + }); - it("renders error state on failure", async () => { - const mockedGet = apiClient.get as ReturnType; - mockedGet.mockRejectedValueOnce(new Error("Network error")); + it("renders error state on failure", async () => { + const mockedGet = apiClient.get as ReturnType; + mockedGet.mockRejectedValueOnce(new Error("Network error")); - render( - - - - ); + render( + + + , + ); - await waitFor(() => { - expect(screen.getByText(/failed to load files/i)).toBeInTheDocument(); - }); - }); + await waitFor(() => { + expect(screen.getByText(/failed to load files/i)).toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/src/components/features/session/CreateSessionForm.test.tsx b/apps/web/src/components/features/session/CreateSessionForm.test.tsx index 6f0747a..4da0710 100644 --- a/apps/web/src/components/features/session/CreateSessionForm.test.tsx +++ b/apps/web/src/components/features/session/CreateSessionForm.test.tsx @@ -5,91 +5,143 @@ import type { Project } from "@/types/project"; import type { ToolType } from "@/types/tool-type"; vi.mock("@/api/git_repositories", () => ({ - listRepositories: vi.fn(), + listRepositories: vi.fn(), })); vi.mock("@/api/sessions", () => ({ - createInstance: vi.fn(), - startInstance: vi.fn(), + createInstance: vi.fn(), + startInstance: vi.fn(), })); vi.mock("@/api/settings", () => ({ - updateUserConfig: vi.fn(), + updateUserConfig: vi.fn(), })); import { listRepositories } from "@/api/git_repositories"; import { createInstance } from "@/api/sessions"; const mockProjects = [ - { id: "p1", name: "Project One", description: null, owner_id: "u1", default_ssh_key_id: null }, - { id: "p2", name: "Project Two", description: null, owner_id: "u1", default_ssh_key_id: null }, + { + id: "p1", + name: "Project One", + description: null, + owner_id: "u1", + default_ssh_key_id: null, + }, + { + id: "p2", + name: "Project Two", + description: null, + owner_id: "u1", + default_ssh_key_id: null, + }, ] as Project[]; const mockToolTypes = [ - { id: "t1", name: "vscode", display_name: "VS Code", description: null, category: "editor", interfaces: ["web"], default_port: 8443, definition_type: "compose", compose_template: "", dockerfile_template: null, readiness_probe: null, required_variables: [], is_builtin: true, build_context: null, created_by_id: "u1", created_at: "", updated_at: "" }, - { id: "t2", name: "terminal", display_name: "Terminal", description: null, category: "shell", interfaces: ["terminal"], default_port: 22, definition_type: "dockerfile", compose_template: null, dockerfile_template: "", readiness_probe: null, required_variables: [], is_builtin: true, build_context: null, created_by_id: "u1", created_at: "", updated_at: "" }, + { + id: "t1", + name: "vscode", + display_name: "VS Code", + description: null, + category: "editor", + interfaces: ["web"], + default_port: 8443, + definition_type: "compose", + compose_template: "", + dockerfile_template: null, + readiness_probe: null, + required_variables: [], + is_builtin: true, + build_context: null, + created_by_id: "u1", + created_at: "", + updated_at: "", + }, + { + id: "t2", + name: "terminal", + display_name: "Terminal", + description: null, + category: "shell", + interfaces: ["terminal"], + default_port: 22, + definition_type: "dockerfile", + compose_template: null, + dockerfile_template: "", + readiness_probe: null, + required_variables: [], + is_builtin: true, + build_context: null, + created_by_id: "u1", + created_at: "", + updated_at: "", + }, ] as ToolType[]; describe("CreateSessionForm", () => { - it("renders form with create button", () => { - render( - - ); + it("renders form with create button", () => { + render( + , + ); - expect(screen.getByText("Create New Session")).toBeInTheDocument(); - expect(screen.getByText("Create Session")).toBeInTheDocument(); - }); + expect(screen.getByText("Create New Session")).toBeInTheDocument(); + expect(screen.getByText("Create Session")).toBeInTheDocument(); + }); - it("shows validation error when fields are missing", async () => { - render( - - ); + it("shows validation error when fields are missing", async () => { + render( + , + ); - const { container } = render( - - ); + const { container } = render( + , + ); - const submitBtn = container.querySelector('button[type="submit"]') as HTMLButtonElement; - fireEvent.click(submitBtn); + const submitBtn = container.querySelector( + 'button[type="submit"]', + ) as HTMLButtonElement; + fireEvent.click(submitBtn); - await waitFor(() => { - expect( - screen.getByText(/project, repository, and tool type are required/i) - ).toBeInTheDocument(); - }); + await waitFor(() => { + expect( + screen.getByText(/project, repository, and tool type are required/i), + ).toBeInTheDocument(); + }); - expect(createInstance).not.toHaveBeenCalled(); - }); + expect(createInstance).not.toHaveBeenCalled(); + }); - it("loads repositories when project selected", async () => { - const mockedList = listRepositories as ReturnType; - mockedList.mockResolvedValueOnce([{ id: "r1", name: "repo-one" }]); + it("loads repositories when project selected", async () => { + const mockedList = listRepositories as ReturnType; + mockedList.mockResolvedValueOnce([{ id: "r1", name: "repo-one" }]); - const { container } = render( - - ); + const { container } = render( + , + ); - const projectSelect = container.querySelector("select") as HTMLSelectElement; - fireEvent.change(projectSelect, { target: { value: "p1" } }); + const projectSelect = container.querySelector( + "select", + ) as HTMLSelectElement; + fireEvent.change(projectSelect, { target: { value: "p1" } }); - await waitFor(() => { - expect(listRepositories).toHaveBeenCalledWith("p1"); - }); - }); + await waitFor(() => { + expect(listRepositories).toHaveBeenCalledWith("p1"); + }); + }); }); diff --git a/apps/web/src/components/features/session/SessionCard.test.tsx b/apps/web/src/components/features/session/SessionCard.test.tsx index 0951468..e4d3282 100644 --- a/apps/web/src/components/features/session/SessionCard.test.tsx +++ b/apps/web/src/components/features/session/SessionCard.test.tsx @@ -4,69 +4,73 @@ import { SessionCard } from "./SessionCard"; import type { Session } from "@/types/session"; const mockSession: Session = { - id: "s1", - display_name: "Dev Environment", - tool_type_name: "VS Code", - tool_icon: "code", - tool_type_interfaces: ["web", "terminal"], - repository_name: "my-repo", - repository_id: "r1", - project_name: "My Project", - project_id: "p1", - status: "running", - url: "https://example.com", + id: "s1", + display_name: "Dev Environment", + tool_type_name: "VS Code", + tool_icon: "code", + tool_type_interfaces: ["web", "terminal"], + repository_name: "my-repo", + repository_id: "r1", + project_name: "My Project", + project_id: "p1", + status: "running", + url: "https://example.com", }; describe("SessionCard", () => { - it("renders active variant with display name and status", () => { - render( - - ); + it("renders active variant with display name and status", () => { + render( + , + ); - expect(screen.getByText("running")).toBeInTheDocument(); - expect(screen.getAllByText("Dev Environment").length).toBeGreaterThanOrEqual(1); - }); + expect(screen.getByText("running")).toBeInTheDocument(); + expect( + screen.getAllByText("Dev Environment").length, + ).toBeGreaterThanOrEqual(1); + }); - it("renders recent variant with display name", () => { - render( - - ); + it("renders recent variant with display name", () => { + render( + , + ); - expect(screen.getAllByText("Dev Environment").length).toBeGreaterThanOrEqual(1); - }); + expect( + screen.getAllByText("Dev Environment").length, + ).toBeGreaterThanOrEqual(1); + }); - it("shows unnamed fallback when display_name is empty", () => { - render( - - ); + it("shows unnamed fallback when display_name is empty", () => { + render( + , + ); - expect(screen.getByText("VS Code")).toBeInTheDocument(); - }); + expect(screen.getByText("VS Code")).toBeInTheDocument(); + }); }); diff --git a/apps/web/src/components/features/tool-workshop/ToolTypesTab.test.tsx b/apps/web/src/components/features/tool-workshop/ToolTypesTab.test.tsx index 943f755..ca5ab85 100644 --- a/apps/web/src/components/features/tool-workshop/ToolTypesTab.test.tsx +++ b/apps/web/src/components/features/tool-workshop/ToolTypesTab.test.tsx @@ -3,56 +3,58 @@ import { describe, it, expect, vi } from "vitest"; import { ToolTypesTab } from "./ToolTypesTab"; vi.mock("../../../api/tool_types", () => ({ - listToolTypes: vi.fn(() => Promise.resolve([])), - createToolType: vi.fn(), - deleteToolType: vi.fn(), - updateToolType: vi.fn(), + listToolTypes: vi.fn(() => Promise.resolve([])), + createToolType: vi.fn(), + deleteToolType: vi.fn(), + updateToolType: vi.fn(), })); import { listToolTypes } from "../../../api/tool_types"; describe("ToolTypesTab", () => { - it("renders loading state initially", () => { - render(); - expect(screen.getByText(/loading tool types/i)).toBeInTheDocument(); - }); + it("renders loading state initially", () => { + render(); + expect(screen.getByText(/loading tool types/i)).toBeInTheDocument(); + }); - it("renders tool types heading after loading", async () => { - const mockedList = listToolTypes as ReturnType; - mockedList.mockResolvedValueOnce([ - { - id: "1", - name: "code-server", - display_name: "VS Code Server", - description: "Web-based IDE", - category: "editor", - interfaces: ["web"], - default_port: 8443, - definition_type: "compose", - compose_template: "version: '3.8'\nservices:\n app:", - dockerfile_template: null, - readiness_probe: null, - required_variables: [], - is_builtin: true, - }, - ]); + it("renders tool types heading after loading", async () => { + const mockedList = listToolTypes as ReturnType; + mockedList.mockResolvedValueOnce([ + { + id: "1", + name: "code-server", + display_name: "VS Code Server", + description: "Web-based IDE", + category: "editor", + interfaces: ["web"], + default_port: 8443, + definition_type: "compose", + compose_template: "version: '3.8'\nservices:\n app:", + dockerfile_template: null, + readiness_probe: null, + required_variables: [], + is_builtin: true, + }, + ]); - render(); + render(); - await waitFor(() => { - expect(screen.getByText("Tool Types")).toBeInTheDocument(); - }); - expect(screen.getByText("VS Code Server")).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText("Tool Types")).toBeInTheDocument(); + }); + expect(screen.getByText("VS Code Server")).toBeInTheDocument(); + }); - it("renders error state on failure", async () => { - const mockedList = listToolTypes as ReturnType; - mockedList.mockRejectedValueOnce(new Error("Network error")); + it("renders error state on failure", async () => { + const mockedList = listToolTypes as ReturnType; + mockedList.mockRejectedValueOnce(new Error("Network error")); - render(); + render(); - await waitFor(() => { - expect(screen.getByText(/failed to load tool types/i)).toBeInTheDocument(); - }); - }); + await waitFor(() => { + expect( + screen.getByText(/failed to load tool types/i), + ).toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/src/components/ui/ErrorState.test.tsx b/apps/web/src/components/ui/ErrorState.test.tsx index 9f4fdea..bf6ac23 100644 --- a/apps/web/src/components/ui/ErrorState.test.tsx +++ b/apps/web/src/components/ui/ErrorState.test.tsx @@ -3,20 +3,20 @@ import { describe, it, expect, vi } from "vitest"; import { ErrorState } from "./ErrorState"; describe("ErrorState", () => { - it("renders message", () => { - render(); - expect(screen.getByText("Failed to load")).toBeInTheDocument(); - }); + it("renders message", () => { + render(); + expect(screen.getByText("Failed to load")).toBeInTheDocument(); + }); - it("does not render retry button when onRetry is absent", () => { - render(); - expect(screen.queryByText("Retry")).not.toBeInTheDocument(); - }); + it("does not render retry button when onRetry is absent", () => { + render(); + expect(screen.queryByText("Retry")).not.toBeInTheDocument(); + }); - it("calls onRetry when button clicked", () => { - const onRetry = vi.fn(); - render(); - fireEvent.click(screen.getByText("Retry")); - expect(onRetry).toHaveBeenCalledTimes(1); - }); + it("calls onRetry when button clicked", () => { + const onRetry = vi.fn(); + render(); + fireEvent.click(screen.getByText("Retry")); + expect(onRetry).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/web/src/components/ui/LoadingState.test.tsx b/apps/web/src/components/ui/LoadingState.test.tsx index add8cb1..d9cde84 100644 --- a/apps/web/src/components/ui/LoadingState.test.tsx +++ b/apps/web/src/components/ui/LoadingState.test.tsx @@ -3,13 +3,13 @@ import { describe, it, expect } from "vitest"; import { LoadingState } from "./LoadingState"; describe("LoadingState", () => { - it("renders default message", () => { - render(); - expect(screen.getByText("Loading...")).toBeInTheDocument(); - }); + it("renders default message", () => { + render(); + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); - it("renders custom message", () => { - render(); - expect(screen.getByText("Loading sessions...")).toBeInTheDocument(); - }); + it("renders custom message", () => { + render(); + expect(screen.getByText("Loading sessions...")).toBeInTheDocument(); + }); }); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index fa9b6ac..25256ba 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -3,17 +3,17 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ - plugins: [react()], - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - }, - }, - server: { - port: 5173 - }, - test: { - environment: "jsdom", - setupFiles: "./src/test/setup.ts" - } + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + port: 5173, + }, + test: { + environment: "jsdom", + setupFiles: "./src/test/setup.ts", + }, }); diff --git a/docs/development/naming.md b/docs/development/naming.md new file mode 100644 index 0000000..5f8839e --- /dev/null +++ b/docs/development/naming.md @@ -0,0 +1,172 @@ +# Naming Conventions + +This document defines the file and identifier naming conventions for the Headquarter codebase. + +## Frontend (`apps/web/src/`) + +### React Components + +**File naming:** PascalCase, matching the exported component name exactly. + +``` +✅ Good: + components/features/git/FileBrowser.tsx + components/features/dashboard/DashboardSummary.tsx + pages/DashboardPage.tsx + +❌ Bad: + components/file-browser.tsx + pages/dashboard.tsx +``` + +**Component naming:** PascalCase. Page components end with `Page`. + +```typescript +// Component +export const FileBrowser = () => { ... } + +// Page +export const DashboardPage = () => { ... } +``` + +### Hooks + +**File naming:** camelCase with `use` prefix. + +``` +✅ Good: + hooks/use-theme.ts + hooks/use-dashboard-actions.ts +``` + +### API Modules + +**File naming:** kebab-case. + +``` +✅ Good: + api/tool-types.ts + api/git-repositories.ts + api/config-folders.ts +``` + +### Type Modules + +**File naming:** kebab-case. + +``` +✅ Good: + types/tool-type.ts + types/git-repository.ts +``` + +### Utilities + +**File naming:** kebab-case. + +``` +✅ Good: + utils/terminal-protocol.ts + utils/language.ts +``` + +### CSS Modules + +**File naming:** kebab-case, matching the component file name with `.module.css` suffix. + +``` +✅ Good: + FileBrowser.tsx + FileBrowser.module.css +``` + +## Backend (`apps/api/src/`) + +### Routers + +**File naming:** snake_case. + +``` +✅ Good: + api/tool_instances.py + api/git_repositories.py +``` + +### Services + +**File naming:** snake_case. + +``` +✅ Good: + services/docker/compose.py + services/profile_resolver.py +``` + +### Models + +**File naming:** snake_case. Class names use PascalCase. + +``` +✅ Good: + models/tool_instance.py + class ToolInstance(Base): +``` + +### Schemas + +**File naming:** snake_case. + +``` +✅ Good: + schemas/tool_instance.py +``` + +## Tests + +### Frontend Tests + +**File naming:** Same as source file with `.test.tsx` suffix. + +``` +✅ Good: + FileBrowser.tsx + FileBrowser.test.tsx +``` + +### Backend Tests + +**File naming:** `test_` prefix + snake_case. + +``` +✅ Good: + test_tool_instances.py +``` + +## Directory Structure Summary + +``` +apps/web/src/ +├── api/ # kebab-case files +├── components/ +│ ├── ui/ # PascalCase files +│ ├── layout/ # PascalCase files +│ └── features/ # PascalCase files, grouped by domain +│ ├── git/ +│ ├── project/ +│ ├── session/ +│ └── ... +├── hooks/ # camelCase files +├── pages/ # PascalCase files ending with Page +├── styles/ # kebab-case CSS files +├── types/ # kebab-case files +└── utils/ # kebab-case files + +apps/api/src/ +├── api/ # snake_case files +├── models/ # snake_case files +├── schemas/ # snake_case files +├── services/ # snake_case files +└── auth/ # snake_case files +``` + +## Migration Notes + +Some legacy files may not yet follow these conventions. When touching a file for other work, rename it to match the convention in the same PR.