diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 00000000..644576bd --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,91 @@ +name: E2E Tests + +on: + pull_request: + branches: + - "*" + push: + branches: + - main + - master + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install dependencies + # Use npm install instead of npm ci to correctly resolve platform-specific + # optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries) + run: npm install + + - name: Install Linux native bindings + # Workaround for npm optional dependencies bug (npm/cli#4828) + # Explicitly install Linux bindings needed for build tools + run: | + npm install --no-save --force \ + @rollup/rollup-linux-x64-gnu@4.53.3 \ + @tailwindcss/oxide-linux-x64-gnu@4.1.17 + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + working-directory: apps/app + + - name: Build server + run: npm run build --workspace=apps/server + + - name: Start backend server + run: npm run start --workspace=apps/server & + env: + PORT: 3008 + NODE_ENV: test + + - name: Wait for backend server + run: | + echo "Waiting for backend server to be ready..." + for i in {1..30}; do + if curl -s http://localhost:3008/api/health > /dev/null 2>&1; then + echo "Backend server is ready!" + exit 0 + fi + echo "Waiting... ($i/30)" + sleep 1 + done + echo "Backend server failed to start!" + exit 1 + + - name: Run E2E tests + # Playwright automatically starts the Next.js frontend via webServer config + # (see apps/app/playwright.config.ts) - no need to start it manually + run: npm run test --workspace=apps/app + env: + CI: true + NEXT_PUBLIC_SERVER_URL: http://localhost:3008 + NEXT_PUBLIC_SKIP_SETUP: "true" + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: apps/app/playwright-report/ + retention-days: 7 + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-results + path: apps/app/test-results/ + retention-days: 7 diff --git a/apps/app/package.json b/apps/app/package.json index a5bafda6..ad9100db 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -27,15 +27,19 @@ "postinstall": "electron-builder install-app-deps", "start": "next start", "lint": "eslint", + "pretest": "node scripts/setup-e2e-fixtures.js", "test": "playwright test", "test:headed": "playwright test --headed", "dev:electron:wsl": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron . --no-sandbox --disable-gpu\"", "dev:electron:wsl:gpu": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA electron . --no-sandbox --disable-gpu-sandbox\"" }, "dependencies": { + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/theme-one-dark": "^6.1.3", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@lezer/highlight": "^1.2.3", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -46,6 +50,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.12", + "@uiw/react-codemirror": "^4.25.4", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", @@ -122,7 +127,9 @@ { "from": "../../.env", "to": ".env", - "filter": ["**/*"] + "filter": [ + "**/*" + ] } ], "mac": { diff --git a/apps/app/playwright.config.ts b/apps/app/playwright.config.ts index d01cb870..d4d0f866 100644 --- a/apps/app/playwright.config.ts +++ b/apps/app/playwright.config.ts @@ -30,6 +30,10 @@ export default defineConfig({ url: `http://localhost:${port}`, reuseExistingServer: !process.env.CI, timeout: 120000, + env: { + ...process.env, + NEXT_PUBLIC_SKIP_SETUP: "true", + }, }, }), }); diff --git a/apps/app/scripts/setup-e2e-fixtures.js b/apps/app/scripts/setup-e2e-fixtures.js new file mode 100644 index 00000000..63ad5a02 --- /dev/null +++ b/apps/app/scripts/setup-e2e-fixtures.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +/** + * Setup script for E2E test fixtures + * Creates the necessary test fixture directories and files before running Playwright tests + */ + +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Resolve workspace root (apps/app/scripts -> workspace root) +const WORKSPACE_ROOT = path.resolve(__dirname, "../../.."); +const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA"); +const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt"); + +const SPEC_CONTENT = ` + Test Project A + A test fixture project for Playwright testing + + TypeScript + React + + +`; + +function setupFixtures() { + console.log("Setting up E2E test fixtures..."); + console.log(`Workspace root: ${WORKSPACE_ROOT}`); + console.log(`Fixture path: ${FIXTURE_PATH}`); + + // Create fixture directory + const specDir = path.dirname(SPEC_FILE_PATH); + if (!fs.existsSync(specDir)) { + fs.mkdirSync(specDir, { recursive: true }); + console.log(`Created directory: ${specDir}`); + } + + // Create app_spec.txt + fs.writeFileSync(SPEC_FILE_PATH, SPEC_CONTENT); + console.log(`Created fixture file: ${SPEC_FILE_PATH}`); + + console.log("E2E test fixtures setup complete!"); +} + +setupFixtures(); diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 06dfd503..29a74578 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -222,12 +222,6 @@ function HomeContent() { return (
- {/* Environment indicator */} - {isMounted && !isElectron() && ( -
- Web Mode -
- )}
); } @@ -242,13 +236,6 @@ function HomeContent() { {renderView()} - {/* Environment indicator - only show after mount to prevent hydration issues */} - {isMounted && !isElectron() && ( -
- Web Mode -
- )} - {/* Hidden streamer panel - opens with "\" key, pushes content */}
(""); + const [pathInput, setPathInput] = useState(""); const [parentPath, setParentPath] = useState(null); const [directories, setDirectories] = useState([]); const [drives, setDrives] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const pathInputRef = useRef(null); const browseDirectory = async (dirPath?: string) => { setLoading(true); @@ -54,7 +65,8 @@ export function FileBrowserDialog({ try { // Get server URL from environment or default - const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; + const serverUrl = + process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; const response = await fetch(`${serverUrl}/api/fs/browse`, { method: "POST", @@ -66,6 +78,7 @@ export function FileBrowserDialog({ if (result.success) { setCurrentPath(result.currentPath); + setPathInput(result.currentPath); setParentPath(result.parentPath); setDirectories(result.directories); setDrives(result.drives || []); @@ -73,7 +86,9 @@ export function FileBrowserDialog({ setError(result.error || "Failed to browse directory"); } } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load directories"); + setError( + err instanceof Error ? err.message : "Failed to load directories" + ); } finally { setLoading(false); } @@ -104,6 +119,20 @@ export function FileBrowserDialog({ browseDirectory(drivePath); }; + const handleGoToPath = () => { + const trimmedPath = pathInput.trim(); + if (trimmedPath) { + browseDirectory(trimmedPath); + } + }; + + const handlePathInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleGoToPath(); + } + }; + const handleSelect = () => { if (currentPath) { onSelect(currentPath); @@ -125,6 +154,31 @@ export function FileBrowserDialog({
+ {/* Direct path input */} +
+ setPathInput(e.target.value)} + onKeyDown={handlePathInputKeyDown} + className="flex-1 font-mono text-sm" + data-testid="path-input" + disabled={loading} + /> + +
+ {/* Drives selector (Windows only) */} {drives.length > 0 && (
@@ -135,7 +189,9 @@ export function FileBrowserDialog({ {drives.map((drive) => (
diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx index 27e7e9aa..2a094481 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -207,6 +207,16 @@ export function Sidebar() { moveProjectToTrash, } = useAppStore(); + // Environment variable flags for hiding sidebar items + // Note: Next.js requires static access to process.env variables (no dynamic keys) + const hideTerminal = process.env.NEXT_PUBLIC_HIDE_TERMINAL === "true"; + const hideWiki = process.env.NEXT_PUBLIC_HIDE_WIKI === "true"; + const hideRunningAgents = + process.env.NEXT_PUBLIC_HIDE_RUNNING_AGENTS === "true"; + const hideContext = process.env.NEXT_PUBLIC_HIDE_CONTEXT === "true"; + const hideSpecEditor = process.env.NEXT_PUBLIC_HIDE_SPEC_EDITOR === "true"; + const hideAiProfiles = process.env.NEXT_PUBLIC_HIDE_AI_PROFILES === "true"; + // Get customizable keyboard shortcuts const shortcuts = useKeyboardShortcutsConfig(); @@ -590,54 +600,75 @@ export function Sidebar() { } }, [emptyTrash, trashedProjects.length]); - const navSections: NavSection[] = [ - { - label: "Project", - items: [ - { - id: "board", - label: "Kanban Board", - icon: LayoutGrid, - shortcut: shortcuts.board, - }, - { - id: "agent", - label: "Agent Runner", - icon: Bot, - shortcut: shortcuts.agent, - }, - ], - }, - { - label: "Tools", - items: [ - { - id: "spec", - label: "Spec Editor", - icon: FileText, - shortcut: shortcuts.spec, - }, - { - id: "context", - label: "Context", - icon: BookOpen, - shortcut: shortcuts.context, - }, - { - id: "profiles", - label: "AI Profiles", - icon: UserCircle, - shortcut: shortcuts.profiles, - }, - { - id: "terminal", - label: "Terminal", - icon: Terminal, - shortcut: shortcuts.terminal, - }, - ], - }, - ]; + const navSections: NavSection[] = useMemo(() => { + const allToolsItems: NavItem[] = [ + { + id: "spec", + label: "Spec Editor", + icon: FileText, + shortcut: shortcuts.spec, + }, + { + id: "context", + label: "Context", + icon: BookOpen, + shortcut: shortcuts.context, + }, + { + id: "profiles", + label: "AI Profiles", + icon: UserCircle, + shortcut: shortcuts.profiles, + }, + { + id: "terminal", + label: "Terminal", + icon: Terminal, + shortcut: shortcuts.terminal, + }, + ]; + + // Filter out hidden items + const visibleToolsItems = allToolsItems.filter((item) => { + if (item.id === "spec" && hideSpecEditor) { + return false; + } + if (item.id === "context" && hideContext) { + return false; + } + if (item.id === "profiles" && hideAiProfiles) { + return false; + } + if (item.id === "terminal" && hideTerminal) { + return false; + } + return true; + }); + + return [ + { + label: "Project", + items: [ + { + id: "board", + label: "Kanban Board", + icon: LayoutGrid, + shortcut: shortcuts.board, + }, + { + id: "agent", + label: "Agent Runner", + icon: Bot, + shortcut: shortcuts.agent, + }, + ], + }, + { + label: "Tools", + items: visibleToolsItems, + }, + ]; + }, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles]); // Handle selecting the currently highlighted project const selectHighlightedProject = useCallback(() => { @@ -1268,108 +1299,112 @@ export function Sidebar() { {/* Course Promo Badge */} {/* Wiki Link */} -
- -
- {/* Running Agents Link */} -
- +
+ )} + {/* Running Agents Link */} + {!hideRunningAgents && ( +
+
- + Running Agents + )} - > - Running Agents - - {/* Running agents count badge - shown in expanded state */} - {sidebarOpen && runningAgentsCount > 0 && ( - - {runningAgentsCount > 99 ? "99" : runningAgentsCount} - - )} - {!sidebarOpen && ( - - Running Agents - - )} - -
+ +
+ )} {/* Settings Link */}