mirror of
https://github.com/johndoe6345789/docker-swarm-termina.git
synced 2026-04-24 13:45:01 +00:00
Merge pull request #33 from johndoe6345789/claude/fix-signin-button-test-RVkfH
Update login form text to match test expectations
This commit is contained in:
328
CLAUDE.md
Normal file
328
CLAUDE.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# AI Assistant Guidelines for Docker Swarm Terminal
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before working on this project, ensure you have:
|
||||
|
||||
- **Node.js 20+** - Required for frontend development
|
||||
- **Docker** - Required for running CI-equivalent tests (optional but recommended)
|
||||
- **GitHub CLI (gh)** - Required for creating pull requests
|
||||
|
||||
### Installing Docker
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in for group changes to take effect
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install --cask docker
|
||||
# Or download Docker Desktop from https://www.docker.com/products/docker-desktop
|
||||
```
|
||||
|
||||
**Verify installation:**
|
||||
```bash
|
||||
docker --version
|
||||
docker ps
|
||||
```
|
||||
|
||||
### Installing GitHub CLI
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install gh
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install gh
|
||||
```
|
||||
|
||||
**Verify installation:**
|
||||
```bash
|
||||
gh --version
|
||||
gh auth status
|
||||
```
|
||||
|
||||
**Authenticate:**
|
||||
```bash
|
||||
gh auth login
|
||||
```
|
||||
|
||||
## Critical Testing Requirements
|
||||
|
||||
**NEVER commit code without verifying it works with the existing tests.**
|
||||
|
||||
**CRITICAL: You MUST keep working until ALL tests pass and coverage is maintained.**
|
||||
- ❌ Do NOT commit if linting has ANY errors
|
||||
- ❌ Do NOT commit if ANY test fails
|
||||
- ❌ Do NOT commit if the build fails
|
||||
- ❌ Do NOT commit if coverage drops
|
||||
- ✅ Keep iterating and fixing until 100% of tests pass
|
||||
- ✅ Only commit when the FULL test suite passes (linting, tests, build)
|
||||
|
||||
### Before Making Any Changes
|
||||
|
||||
1. **Read the test files first** - Understand what the tests expect
|
||||
- E2E tests: `frontend/e2e/*.spec.ts`
|
||||
- Unit tests: `frontend/**/__tests__/*.test.tsx`
|
||||
|
||||
2. **Understand the test expectations** - Check for:
|
||||
- Button text and labels (e.g., tests expect "Sign In", not "Access Dashboard")
|
||||
- Component structure and roles
|
||||
- User interactions and flows
|
||||
|
||||
### Testing Workflow
|
||||
|
||||
When making changes to components or functionality:
|
||||
|
||||
1. **Read the relevant test file(s)** before changing code
|
||||
```bash
|
||||
# For login changes, read:
|
||||
cat frontend/e2e/login.spec.ts
|
||||
cat frontend/components/__tests__/LoginForm.test.tsx
|
||||
```
|
||||
|
||||
2. **Make your changes** ensuring they match test expectations
|
||||
|
||||
3. **Verify tests pass** - You MUST verify tests before committing:
|
||||
|
||||
**Option A: Local testing with e2e (RECOMMENDED):**
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Step 1: Install dependencies
|
||||
npm ci
|
||||
|
||||
# Step 2: Run linting (REQUIRED - must have no errors)
|
||||
npm run lint
|
||||
|
||||
# Step 3: Run unit tests (REQUIRED - must pass)
|
||||
npm test
|
||||
|
||||
# Step 4: Build the app (REQUIRED - must succeed)
|
||||
npm run build
|
||||
|
||||
# Step 5: Run e2e tests with mock backend (automatically starts servers)
|
||||
npx playwright install chromium --with-deps
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
**Note:** Playwright automatically starts:
|
||||
- Mock backend server on port 5000 (`e2e/mock-backend.js`)
|
||||
- Frontend dev server on port 3000 (`npm run dev`)
|
||||
- Both servers shut down automatically when tests complete
|
||||
|
||||
**Option B: Full Docker build (CI-equivalent):**
|
||||
```bash
|
||||
cd frontend && docker build -t frontend-test .
|
||||
```
|
||||
|
||||
**Warning:** The Dockerfile runs e2e tests at line 55 but allows them to skip
|
||||
if backend services aren't running. In CI, e2e tests may show failures but
|
||||
won't block the build. Always run Option A locally to catch issues early.
|
||||
|
||||
**Option C: Minimum verification (if e2e cannot run):**
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci # Install dependencies
|
||||
npm run lint # Run linting - MUST HAVE NO ERRORS
|
||||
npm test # Run unit tests - MUST PASS
|
||||
npm run build # Build app - MUST SUCCEED
|
||||
|
||||
# Manually verify e2e expectations by reading test files
|
||||
cat e2e/login.spec.ts
|
||||
cat e2e/dashboard.spec.ts
|
||||
cat e2e/terminal.spec.ts
|
||||
|
||||
# Check your component changes match what the e2e tests expect:
|
||||
# - Button text and labels (e.g., "Sign In" not "Access Dashboard")
|
||||
# - Heading text (e.g., "Sign In" not "Container Shell")
|
||||
# - Component roles and structure
|
||||
# - User interaction flows
|
||||
```
|
||||
|
||||
4. **Keep working until ALL tests pass**
|
||||
|
||||
**CRITICAL REQUIREMENT:**
|
||||
- If linting has errors → Fix the code and re-run until there are no errors
|
||||
- If ANY unit test fails → Fix the code and re-run until ALL pass
|
||||
- If the build fails → Fix the code and re-run until it succeeds
|
||||
- If ANY e2e test fails → Fix the code and re-run until ALL pass
|
||||
- If you can't run e2e tests → Manually verify changes match ALL e2e expectations
|
||||
- Do NOT commit partial fixes or "good enough" code
|
||||
- ONLY commit when the FULL test suite passes (no lint errors, 282/282 unit tests, 11/11 e2e tests)
|
||||
|
||||
**Your responsibility:** Keep iterating and fixing until you achieve 100% test success.
|
||||
|
||||
### Common Mistakes to Avoid
|
||||
|
||||
- ❌ Not running linting before committing
|
||||
- ❌ Committing code with linting errors (even warnings should be fixed)
|
||||
- ❌ Changing button text without checking what tests expect
|
||||
- ❌ Modifying component structure without verifying e2e selectors
|
||||
- ❌ Assuming tests will adapt to your changes
|
||||
- ❌ Committing without running tests
|
||||
- ❌ Committing when ANY test fails (even if "most" tests pass)
|
||||
- ❌ Committing with the intention to "fix it later"
|
||||
- ❌ Stopping work when 9/11 e2e tests pass (you need 11/11!)
|
||||
- ❌ Thinking test failures are "acceptable" or "good enough"
|
||||
|
||||
### Test Structure
|
||||
|
||||
- **Unit tests**: Test individual components in isolation
|
||||
- **E2E tests**: Test user workflows in Playwright
|
||||
- Tests use `getByRole()`, `getByLabel()`, and `getByText()` selectors
|
||||
- These selectors are case-insensitive with `/i` flag
|
||||
- Button text must match exactly what tests query for
|
||||
|
||||
### When Tests Fail
|
||||
|
||||
1. **Read the error message carefully** - It shows exactly what's missing
|
||||
2. **Check the test file** - See what text/structure it expects
|
||||
3. **Fix the code to match** - Don't change tests unless they're genuinely wrong
|
||||
4. **Verify the fix** - Run tests again before committing
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Install frontend dependencies
|
||||
cd frontend && npm ci
|
||||
|
||||
# Run linting (REQUIRED before commit)
|
||||
cd frontend && npm run lint
|
||||
|
||||
# Fix auto-fixable linting issues
|
||||
cd frontend && npm run lint -- --fix
|
||||
|
||||
# Run unit tests
|
||||
cd frontend && npm test
|
||||
|
||||
# Run specific unit test file
|
||||
cd frontend && npm test -- LoginForm
|
||||
|
||||
# Run unit tests with coverage
|
||||
cd frontend && npm run test:coverage
|
||||
|
||||
# Build the frontend
|
||||
cd frontend && npm run build
|
||||
|
||||
# Run e2e tests (auto-starts mock backend + dev server)
|
||||
cd frontend && npm run test:e2e
|
||||
|
||||
# Run specific e2e test
|
||||
cd frontend && npx playwright test login.spec.ts
|
||||
|
||||
# Run e2e tests with UI (for debugging)
|
||||
cd frontend && npm run test:e2e:ui
|
||||
|
||||
# Build frontend Docker image (runs all tests)
|
||||
cd frontend && docker build -t frontend-test .
|
||||
```
|
||||
|
||||
## Mock Backend for E2E Tests
|
||||
|
||||
The project includes a mock backend (`frontend/e2e/mock-backend.js`) that:
|
||||
- Runs on `http://localhost:5000`
|
||||
- Provides mock API endpoints for login, containers, etc.
|
||||
- Automatically starts when running `npm run test:e2e`
|
||||
- No manual setup required
|
||||
|
||||
**Mock credentials:**
|
||||
- Username: `admin`
|
||||
- Password: `admin123`
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `frontend/` - Next.js application
|
||||
- `components/` - React components
|
||||
- `e2e/` - Playwright end-to-end tests
|
||||
- `lib/hooks/` - Custom React hooks
|
||||
- `backend/` - Go backend service
|
||||
- `docker-compose.yml` - Local development setup
|
||||
- `Dockerfile` - Multi-stage build with test target
|
||||
|
||||
## Git Workflow
|
||||
|
||||
1. Always work on feature branches starting with `claude/`
|
||||
2. Commit messages should explain WHY, not just WHAT
|
||||
3. Push to the designated branch only
|
||||
4. Tests must pass in CI before merging
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Playwright browser installation fails
|
||||
|
||||
If `npx playwright install` fails with network errors:
|
||||
```bash
|
||||
# Try manual download
|
||||
curl -L -o /tmp/chrome.zip "https://cdn.playwright.dev/builds/cft/[VERSION]/linux64/chrome-linux64.zip"
|
||||
mkdir -p ~/.cache/ms-playwright/chromium_headless_shell-[VERSION]
|
||||
cd ~/.cache/ms-playwright/chromium_headless_shell-[VERSION]
|
||||
unzip /tmp/chrome.zip
|
||||
mv chrome-linux64 chrome-headless-shell-linux64
|
||||
cd chrome-headless-shell-linux64 && cp chrome chrome-headless-shell
|
||||
```
|
||||
|
||||
### E2E tests fail with "ERR_CONNECTION_REFUSED"
|
||||
|
||||
The mock backend or dev server isn't starting. Check:
|
||||
```bash
|
||||
# Make sure ports 3000 and 5000 are free
|
||||
lsof -ti:3000 | xargs kill -9
|
||||
lsof -ti:5000 | xargs kill -9
|
||||
|
||||
# Verify Playwright config is correct
|
||||
cat frontend/playwright.config.ts | grep webServer
|
||||
```
|
||||
|
||||
### Docker build fails
|
||||
|
||||
```bash
|
||||
# Check Docker is running
|
||||
docker ps
|
||||
|
||||
# Build with more verbose output
|
||||
cd frontend && docker build --progress=plain -t frontend-test .
|
||||
|
||||
# Build specific stage only
|
||||
cd frontend && docker build --target test -t frontend-unit-tests .
|
||||
```
|
||||
|
||||
### Tests expect different text than component shows
|
||||
|
||||
**Always read the test files first before making changes!**
|
||||
```bash
|
||||
# Find what text the tests expect
|
||||
grep -r "getByRole\|getByText\|getByLabel" frontend/e2e/
|
||||
grep -r "getByRole\|getByText\|getByLabel" frontend/**/__tests__/
|
||||
```
|
||||
|
||||
## Summary: Complete Workflow
|
||||
|
||||
1. ✅ **Read test files** to understand expectations
|
||||
2. ✅ **Make changes** matching what tests expect
|
||||
3. ✅ **Run linting**: `npm run lint` → MUST have zero errors
|
||||
4. ✅ **Run unit tests**: `npm test` → MUST show 282/282 passing
|
||||
5. ✅ **Run build**: `npm run build` → MUST succeed with no errors
|
||||
6. ✅ **Run e2e tests**: `npm run test:e2e` → MUST show 11/11 passing
|
||||
7. ✅ **Fix failures**: If ANY check fails, go back to step 2 and fix the code
|
||||
8. ✅ **Iterate**: Repeat steps 2-7 until 100% of checks pass
|
||||
9. ✅ **Commit**: ONLY after achieving full test suite success
|
||||
10. ✅ **Push**: To designated branch
|
||||
|
||||
**Acceptance Criteria Before Committing:**
|
||||
- ✅ Linting passes with zero errors (warnings should be fixed too)
|
||||
- ✅ 282/282 unit tests passing (100%)
|
||||
- ✅ Build succeeds with zero errors
|
||||
- ✅ 11/11 e2e tests passing (100%)
|
||||
- ✅ No test coverage regression
|
||||
|
||||
Remember: **Code that doesn't pass the FULL test suite (including linting) is broken code.**
|
||||
|
||||
**If linting or tests fail, you MUST fix them before committing. No exceptions.**
|
||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@@ -12,6 +12,9 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
@@ -52,7 +52,7 @@ COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Run e2e tests (non-blocking in CI as requires running backend)
|
||||
RUN npm run test:e2e || echo "E2E tests skipped (requires running services)" && touch /app/.e2e-tests-passed
|
||||
RUN (npm run test:e2e || echo "E2E tests skipped (requires running services)") && touch /app/.e2e-tests-passed
|
||||
|
||||
# Production stage
|
||||
FROM node:20-slim AS production
|
||||
@@ -63,12 +63,14 @@ WORKDIR /app
|
||||
COPY --from=test /app/.unit-tests-passed /tmp/.unit-tests-passed
|
||||
COPY --from=e2e-test /app/.e2e-tests-passed /tmp/.e2e-tests-passed
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
# Copy built artifacts from e2e-test stage (already built with standalone mode)
|
||||
COPY --from=e2e-test /app/.next/standalone ./
|
||||
COPY --from=e2e-test /app/.next/static ./.next/static
|
||||
COPY --from=e2e-test /app/public ./public
|
||||
|
||||
COPY . /app/
|
||||
RUN npm run build
|
||||
# Copy entrypoint script
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["npm", "start"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -31,7 +31,7 @@ jest.mock('../providers', () => ({
|
||||
|
||||
// Mock Next.js Script component
|
||||
jest.mock('next/script', () => {
|
||||
return function Script(props: any) {
|
||||
return function Script(props: Record<string, unknown>) {
|
||||
return <script data-testid="next-script" {...props} />;
|
||||
};
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useDashboard } from '@/lib/hooks/useDashboard';
|
||||
// Mock the hooks and components
|
||||
jest.mock('@/lib/hooks/useDashboard');
|
||||
jest.mock('@/components/Dashboard/DashboardHeader', () => {
|
||||
return function DashboardHeader({ onRefresh, onLogout }: any) {
|
||||
return function DashboardHeader({ onRefresh, onLogout }: { onRefresh: () => void; onLogout: () => void }) {
|
||||
return (
|
||||
<div data-testid="dashboard-header">
|
||||
<button onClick={onRefresh}>Refresh</button>
|
||||
@@ -21,7 +21,7 @@ jest.mock('@/components/Dashboard/EmptyState', () => {
|
||||
};
|
||||
});
|
||||
jest.mock('@/components/ContainerCard', () => {
|
||||
return function ContainerCard({ container, onOpenShell }: any) {
|
||||
return function ContainerCard({ container, onOpenShell }: { container: { id: string; name: string }; onOpenShell: () => void }) {
|
||||
return (
|
||||
<div data-testid={`container-card-${container.id}`}>
|
||||
<span>{container.name}</span>
|
||||
@@ -31,7 +31,7 @@ jest.mock('@/components/ContainerCard', () => {
|
||||
};
|
||||
});
|
||||
jest.mock('@/components/TerminalModal', () => {
|
||||
return function TerminalModal({ open, containerName, onClose }: any) {
|
||||
return function TerminalModal({ open, containerName, onClose }: { open: boolean; containerName: string; onClose: () => void }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div data-testid="terminal-modal">
|
||||
|
||||
@@ -16,14 +16,6 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<Script src="/env.js" strategy="beforeInteractive" />
|
||||
<ThemeProvider>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, Divider, Snackbar, Alert } from '@mui/material';
|
||||
import { Container } from '@/lib/api';
|
||||
import { ContainerCardProps } from '@/lib/interfaces/container';
|
||||
import { useContainerActions } from '@/lib/hooks/useContainerActions';
|
||||
import ContainerHeader from './ContainerCard/ContainerHeader';
|
||||
@@ -37,6 +36,7 @@ export default function ContainerCard({ container, onOpenShell, onContainerUpdat
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-testid="container-card"
|
||||
sx={{
|
||||
borderLeft: 4,
|
||||
borderColor: borderColors[container.status as keyof typeof borderColors] || borderColors.stopped,
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('ContainerHeader', () => {
|
||||
});
|
||||
|
||||
it('applies success color for running status', () => {
|
||||
const { container } = render(
|
||||
render(
|
||||
<ContainerHeader name="test-container" image="nginx:latest" status="running" />
|
||||
);
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('ContainerHeader', () => {
|
||||
});
|
||||
|
||||
it('applies default color for stopped status', () => {
|
||||
const { container } = render(
|
||||
render(
|
||||
<ContainerHeader name="test-container" image="nginx:latest" status="stopped" />
|
||||
);
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('ContainerHeader', () => {
|
||||
});
|
||||
|
||||
it('applies warning color for paused status', () => {
|
||||
const { container } = render(
|
||||
render(
|
||||
<ContainerHeader name="test-container" image="nginx:latest" status="paused" />
|
||||
);
|
||||
|
||||
|
||||
@@ -65,10 +65,10 @@ export default function LoginForm() {
|
||||
<LockOpen sx={{ fontSize: 32, color: 'secondary.main' }} />
|
||||
</Box>
|
||||
<Typography variant="h1" component="h1" gutterBottom>
|
||||
Container Shell
|
||||
Sign In
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Enter your credentials to access container management
|
||||
Enter your credentials to access the dashboard
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function LoginForm() {
|
||||
sx={{ mb: 2 }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Access Dashboard'}
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
|
||||
<Typography
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('LoginForm', () => {
|
||||
|
||||
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /access dashboard/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -63,7 +63,7 @@ describe('LoginForm', () => {
|
||||
it('shows loading text when loading', () => {
|
||||
renderWithProvider(<LoginForm />, true);
|
||||
|
||||
expect(screen.getByRole('button', { name: /logging in/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /signing in/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('password input is type password', () => {
|
||||
@@ -106,7 +106,7 @@ describe('LoginForm', () => {
|
||||
it('disables submit button when loading', () => {
|
||||
renderWithProvider(<LoginForm />, true);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /logging in/i });
|
||||
const submitButton = screen.getByRole('button', { name: /signing in/i });
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('LoginForm', () => {
|
||||
renderWithProvider(<LoginForm />);
|
||||
|
||||
// The component should render successfully
|
||||
expect(screen.getByRole('button', { name: /access dashboard/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles form submission with failed login', async () => {
|
||||
@@ -129,7 +129,7 @@ describe('LoginForm', () => {
|
||||
|
||||
const usernameInput = screen.getByLabelText(/username/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /access dashboard/i });
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
fireEvent.change(usernameInput, { target: { value: 'wronguser' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'wrongpass' } });
|
||||
@@ -142,7 +142,7 @@ describe('LoginForm', () => {
|
||||
|
||||
// The shake animation should be triggered (isShaking: true)
|
||||
// We can't directly test CSS animations, but we verify the component still renders
|
||||
expect(screen.getByRole('button', { name: /access dashboard/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -265,7 +265,7 @@ describe('TerminalModal', () => {
|
||||
isMobile: true,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
render(
|
||||
<TerminalModal
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
|
||||
@@ -60,10 +60,20 @@ test.describe('Dashboard Page', () => {
|
||||
|
||||
test.describe('Dashboard - Protected Route', () => {
|
||||
test('should redirect to login when not authenticated', async ({ page }) => {
|
||||
// Go to page first to establish context
|
||||
await page.goto('/');
|
||||
|
||||
// Clear any existing auth state
|
||||
await page.context().clearCookies();
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.evaluate(() => {
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch {
|
||||
// Ignore if localStorage is not accessible
|
||||
}
|
||||
});
|
||||
|
||||
// Now try to access dashboard
|
||||
await page.goto('/dashboard');
|
||||
|
||||
// Should redirect to login
|
||||
|
||||
156
frontend/e2e/mock-backend.js
Normal file
156
frontend/e2e/mock-backend.js
Normal file
@@ -0,0 +1,156 @@
|
||||
const http = require('http');
|
||||
|
||||
const mockContainers = [
|
||||
{
|
||||
id: 'container1',
|
||||
name: 'nginx-web',
|
||||
image: 'nginx:latest',
|
||||
status: 'running',
|
||||
uptime: '2 hours'
|
||||
},
|
||||
{
|
||||
id: 'container2',
|
||||
name: 'redis-cache',
|
||||
image: 'redis:7',
|
||||
status: 'running',
|
||||
uptime: '5 hours'
|
||||
}
|
||||
];
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// Set CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
// Handle preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = req.url;
|
||||
const method = req.method;
|
||||
|
||||
// Parse request body
|
||||
let body = '';
|
||||
req.on('data', chunk => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
console.log(`[${new Date().toISOString()}] ${method} ${url}`);
|
||||
try {
|
||||
// Login endpoint
|
||||
if (url === '/api/auth/login' && method === 'POST') {
|
||||
const { username, password } = JSON.parse(body);
|
||||
console.log(`Login attempt: ${username}`);
|
||||
|
||||
if (username === 'admin' && password === 'admin123') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
token: 'mock-token-12345',
|
||||
username: 'admin'
|
||||
}));
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: false,
|
||||
message: 'Invalid credentials'
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Logout endpoint
|
||||
if (url === '/api/auth/logout' && method === 'POST') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get containers
|
||||
if (url === '/api/containers' && method === 'GET') {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ containers: mockContainers }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Container operations
|
||||
const containerOpMatch = url.match(/^\/api\/containers\/([^\/]+)\/(start|stop|restart)$/);
|
||||
if (containerOpMatch && method === 'POST') {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return;
|
||||
}
|
||||
const [, , operation] = containerOpMatch;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
message: `Container ${operation}ed successfully`
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (url === '/health' && method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete container
|
||||
const deleteMatch = url.match(/^\/api\/containers\/([^\/]+)$/);
|
||||
if (deleteMatch && method === 'DELETE') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Container removed successfully'
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute command
|
||||
const execMatch = url.match(/^\/api\/containers\/([^\/]+)\/exec$/);
|
||||
if (execMatch && method === 'POST') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
output: 'Command executed successfully'
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// 404 for all other routes
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Not found' }));
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Internal server error' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 5000;
|
||||
server.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`Mock backend server running on http://127.0.0.1:${PORT}`);
|
||||
});
|
||||
|
||||
// Handle shutdown gracefully
|
||||
process.on('SIGTERM', () => {
|
||||
server.close(() => {
|
||||
console.log('Mock backend server stopped');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,27 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// CommonJS config files:
|
||||
"jest.config.js",
|
||||
"jest.setup.js",
|
||||
"show-interactive-direct.js",
|
||||
// E2E mock backend (Node.js CommonJS server):
|
||||
"e2e/mock-backend.js",
|
||||
// Test artifacts:
|
||||
"coverage/**",
|
||||
"test-results/**",
|
||||
"playwright-report/**",
|
||||
"playwright/.cache/**",
|
||||
]),
|
||||
// Relaxed rules for test files
|
||||
{
|
||||
files: ["**/__tests__/**/*", "**/*.test.*", "**/*.spec.*"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { triggerAuthError } from './store/authErrorHandler';
|
||||
|
||||
// Type definition for window.__ENV__
|
||||
declare global {
|
||||
interface Window {
|
||||
__ENV__?: {
|
||||
NEXT_PUBLIC_API_URL?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const API_BASE_URL =
|
||||
typeof window !== 'undefined' && (window as any).__ENV__?.NEXT_PUBLIC_API_URL
|
||||
? (window as any).__ENV__.NEXT_PUBLIC_API_URL
|
||||
typeof window !== 'undefined' && window.__ENV__?.NEXT_PUBLIC_API_URL
|
||||
? window.__ENV__.NEXT_PUBLIC_API_URL
|
||||
: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000';
|
||||
|
||||
export interface Container {
|
||||
@@ -24,6 +33,20 @@ export interface ContainersResponse {
|
||||
containers: Container[];
|
||||
}
|
||||
|
||||
export interface CommandResponse {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
workdir?: string;
|
||||
exit_code?: number;
|
||||
}
|
||||
|
||||
export interface ContainerActionResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private token: string | null = null;
|
||||
|
||||
@@ -117,7 +140,7 @@ class ApiClient {
|
||||
return data.containers;
|
||||
}
|
||||
|
||||
async executeCommand(containerId: string, command: string): Promise<any> {
|
||||
async executeCommand(containerId: string, command: string): Promise<CommandResponse> {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
triggerAuthError();
|
||||
@@ -145,7 +168,7 @@ class ApiClient {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async startContainer(containerId: string): Promise<any> {
|
||||
async startContainer(containerId: string): Promise<ContainerActionResponse> {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
triggerAuthError();
|
||||
@@ -172,7 +195,7 @@ class ApiClient {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async stopContainer(containerId: string): Promise<any> {
|
||||
async stopContainer(containerId: string): Promise<ContainerActionResponse> {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
triggerAuthError();
|
||||
@@ -199,7 +222,7 @@ class ApiClient {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async restartContainer(containerId: string): Promise<any> {
|
||||
async restartContainer(containerId: string): Promise<ContainerActionResponse> {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
triggerAuthError();
|
||||
@@ -226,7 +249,7 @@ class ApiClient {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async removeContainer(containerId: string): Promise<any> {
|
||||
async removeContainer(containerId: string): Promise<ContainerActionResponse> {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
triggerAuthError();
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('useContainerActions', () => {
|
||||
|
||||
describe('handleStart', () => {
|
||||
it('should start container and show success', async () => {
|
||||
mockApiClient.startContainer.mockResolvedValueOnce({ message: 'Started' });
|
||||
mockApiClient.startContainer.mockResolvedValueOnce({ success: true, message: 'Started' });
|
||||
|
||||
const { result } = renderHook(() => useContainerActions(containerId, mockOnUpdate));
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('useContainerActions', () => {
|
||||
|
||||
describe('handleStop', () => {
|
||||
it('should stop container and show success', async () => {
|
||||
mockApiClient.stopContainer.mockResolvedValueOnce({ message: 'Stopped' });
|
||||
mockApiClient.stopContainer.mockResolvedValueOnce({ success: true, message: 'Stopped' });
|
||||
|
||||
const { result } = renderHook(() => useContainerActions(containerId, mockOnUpdate));
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('useContainerActions', () => {
|
||||
|
||||
describe('handleRestart', () => {
|
||||
it('should restart container and show success', async () => {
|
||||
mockApiClient.restartContainer.mockResolvedValueOnce({ message: 'Restarted' });
|
||||
mockApiClient.restartContainer.mockResolvedValueOnce({ success: true, message: 'Restarted' });
|
||||
|
||||
const { result } = renderHook(() => useContainerActions(containerId, mockOnUpdate));
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('useContainerActions', () => {
|
||||
|
||||
describe('handleRemove', () => {
|
||||
it('should remove container and show success', async () => {
|
||||
mockApiClient.removeContainer.mockResolvedValueOnce({ message: 'Removed' });
|
||||
mockApiClient.removeContainer.mockResolvedValueOnce({ success: true, message: 'Removed' });
|
||||
|
||||
const { result } = renderHook(() => useContainerActions(containerId, mockOnUpdate));
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('useContainerActions', () => {
|
||||
|
||||
describe('closeSnackbar', () => {
|
||||
it('should close snackbar', async () => {
|
||||
mockApiClient.startContainer.mockResolvedValueOnce({ message: 'Started' });
|
||||
mockApiClient.startContainer.mockResolvedValueOnce({ success: true, message: 'Started' });
|
||||
|
||||
const { result } = renderHook(() => useContainerActions(containerId));
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useDashboard } from '../useDashboard';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAppDispatch } from '@/lib/store/hooks';
|
||||
|
||||
@@ -4,6 +4,13 @@ import { apiClient, API_BASE_URL } from '@/lib/api';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import type { FitAddon } from '@xterm/addon-fit';
|
||||
|
||||
// Type declaration for debug property
|
||||
declare global {
|
||||
interface Window {
|
||||
_debugTerminal?: Terminal;
|
||||
}
|
||||
}
|
||||
|
||||
interface UseInteractiveTerminalProps {
|
||||
open: boolean;
|
||||
containerId: string;
|
||||
@@ -15,6 +22,8 @@ interface UseInteractiveTerminalProps {
|
||||
export function useInteractiveTerminal({
|
||||
open,
|
||||
containerId,
|
||||
// containerName is not used but required in the interface for consistency
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
containerName,
|
||||
isMobile,
|
||||
onFallback,
|
||||
@@ -111,7 +120,7 @@ export function useInteractiveTerminal({
|
||||
|
||||
// Expose terminal for debugging
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any)._debugTerminal = term;
|
||||
window._debugTerminal = term;
|
||||
}
|
||||
|
||||
// Use polling only - WebSocket is blocked by Cloudflare/reverse proxy
|
||||
|
||||
@@ -36,7 +36,7 @@ export function useSimpleTerminal(containerId: string) {
|
||||
if (result.output && result.output.trim()) {
|
||||
setOutput((prev) => [...prev, {
|
||||
type: result.exit_code === 0 ? 'output' : 'error',
|
||||
content: result.output
|
||||
content: result.output || ''
|
||||
}]);
|
||||
} else if (command.trim().startsWith('ls')) {
|
||||
setOutput((prev) => [...prev, {
|
||||
|
||||
@@ -24,7 +24,7 @@ export const initAuth = createAsyncThunk('auth/init', async () => {
|
||||
await apiClient.getContainers();
|
||||
const username = apiClient.getUsername();
|
||||
return { isAuthenticated: true, username };
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Token is invalid, clear it
|
||||
apiClient.setToken(null);
|
||||
return { isAuthenticated: false, username: null };
|
||||
@@ -42,7 +42,7 @@ export const login = createAsyncThunk(
|
||||
return { username: response.username || username };
|
||||
}
|
||||
return rejectWithValue(response.message || 'Login failed');
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return rejectWithValue('Login failed. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { formatPrompt, highlightCommand } from '../terminal';
|
||||
import { OutputLine } from '@/lib/interfaces/terminal';
|
||||
|
||||
@@ -21,10 +21,18 @@ export default defineConfig({
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: process.env.CI ? undefined : {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
webServer: process.env.CI ? undefined : [
|
||||
{
|
||||
command: 'node e2e/mock-backend.js',
|
||||
url: 'http://localhost:5000/health',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 10 * 1000,
|
||||
},
|
||||
{
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
window.__ENV__ = {
|
||||
NEXT_PUBLIC_API_URL: '{{NEXT_PUBLIC_API_URL}}'
|
||||
NEXT_PUBLIC_API_URL: '{{NEXT_PUBLIC_API_URL}}' === '{{' + 'NEXT_PUBLIC_API_URL' + '}}'
|
||||
? 'http://localhost:5000' // Default for development
|
||||
: '{{NEXT_PUBLIC_API_URL}}'
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user