diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 66f2735..7105234 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -38,13 +38,52 @@ jobs: - name: Build fetchr binary run: make build - - name: Run end-to-end tests + - name: Run Go end-to-end tests run: | - echo "🚀 Starting end-to-end tests..." + echo "🚀 Starting Go end-to-end tests..." make e2e env: GO_TEST_TIMEOUT: 10m + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: dashboard/package-lock.json + + - name: Install dashboard dependencies + run: cd dashboard && npm ci + + - name: Build dashboard + run: cd dashboard && npm run build + + - name: Install Playwright browsers + run: cd dashboard && npx playwright install --with-deps chromium + + - name: Start netkit server for Playwright tests + run: | + ./bin/netkit serve --port 8080 --admin-port 8081 --dashboard-port 3000 & + echo $! > /tmp/netkit.pid + # Wait for servers to be ready + timeout 30 bash -c 'until curl -s http://localhost:8081/healthz > /dev/null; do sleep 1; done' + timeout 30 bash -c 'until curl -s http://localhost:3000 > /dev/null; do sleep 1; done' + echo "✅ Netkit server is ready" + + - name: Run Playwright E2E tests + run: | + echo "🎭 Starting Playwright E2E tests..." + cd dashboard && npm run test:e2e + env: + CI: true + + - name: Stop netkit server + if: always() + run: | + if [ -f /tmp/netkit.pid ]; then + kill $(cat /tmp/netkit.pid) || true + fi + - name: Upload test results if: always() uses: actions/upload-artifact@v4 @@ -55,6 +94,14 @@ jobs: *.log retention-days: 30 + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: dashboard/playwright-report/ + retention-days: 7 + - name: Comment PR on failure if: failure() && github.event_name == 'push' uses: actions/github-script@v7 diff --git a/.gitiginore b/.gitiginore deleted file mode 100644 index e69de29..0000000 diff --git a/.gitignore b/.gitignore index 992ecf0..4a26f4b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ coverage.txt coverage-e2e.txt +dashboard/test-results +dashboard/playwrite-report netkit # Embedded dashboard files (generated during build) -internal/dashboard/out/ \ No newline at end of file +internal/dashboard/out/ + diff --git a/Makefile b/Makefile index 039e049..8037b10 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: build test lint clean coverage release version install check ci help all dev commit-feat commit-fix commit-refactor commit-perf commit-security .PHONY: build-dashboard test-dashboard lint-dashboard clean-dashboard install-dashboard install-backend dashboard-dev serve-dev build-all clean-all .PHONY: changelog changelog-update changelog-init install-changelog-tools release-patch release-minor release-major release-prerelease release-prerelease-next -.PHONY: commit-docs commit-style commit-test commit-chore commit-breaking check-conventional-commits +.PHONY: commit-docs commit-style commit-test commit-chore commit-breaking check-conventional-commits test-e2e test-e2e-ui test-e2e-headed # Build variables BINARY_NAME=netkit @@ -36,7 +36,10 @@ help: @echo "Test commands:" @echo " test - Run Go unit tests with coverage" @echo " test-dashboard - Run dashboard tests" - @echo " e2e - Run end-to-end tests" + @echo " e2e - Run Go end-to-end tests" + @echo " test-e2e - Run Playwright E2E tests (headless)" + @echo " test-e2e-ui - Run Playwright E2E tests with UI mode (interactive)" + @echo " test-e2e-headed - Run Playwright E2E tests in headed mode (watch browser)" @echo "" @echo "Lint commands:" @echo " lint - Run golangci-lint for Go code" @@ -221,6 +224,33 @@ test-dashboard: fi @cd $(DASHBOARD_DIR) && npm run test --legacy-peer-deps +# Run Playwright E2E tests (headless) +test-e2e: + @if [ ! -d "$(DASHBOARD_DIR)" ]; then \ + echo "❌ Error: Dashboard directory '$(DASHBOARD_DIR)' not found"; \ + exit 1; \ + fi + @echo "🎭 Running Playwright E2E tests..." + @cd $(DASHBOARD_DIR) && npm run test:e2e + +# Run Playwright E2E tests with UI mode (interactive) +test-e2e-ui: + @if [ ! -d "$(DASHBOARD_DIR)" ]; then \ + echo "❌ Error: Dashboard directory '$(DASHBOARD_DIR)' not found"; \ + exit 1; \ + fi + @echo "🎭 Running Playwright E2E tests in UI mode..." + @cd $(DASHBOARD_DIR) && npm run test:e2e:ui + +# Run Playwright E2E tests in headed mode (watch browser) +test-e2e-headed: + @if [ ! -d "$(DASHBOARD_DIR)" ]; then \ + echo "❌ Error: Dashboard directory '$(DASHBOARD_DIR)' not found"; \ + exit 1; \ + fi + @echo "🎭 Running Playwright E2E tests in headed mode..." + @cd $(DASHBOARD_DIR) && npm run test:e2e:headed + # Run end-to-end tests e2e: $(GO) test -v -race -coverprofile=coverage-e2e.txt -covermode=atomic ./test/e2e/... -tags=e2e diff --git a/cmd/netkit/main.go b/cmd/netkit/main.go index 33aaf59..a8e5358 100644 --- a/cmd/netkit/main.go +++ b/cmd/netkit/main.go @@ -34,7 +34,7 @@ func main() { func runServe() { // Parse command line flags port := flag.Int("port", 8080, "Port to listen on") - adminPort := flag.Int("admin-port", 0, "Admin port for health checks and metrics (0 to disable)") + adminPort := flag.Int("admin-port", 8081, "Admin port for health checks and metrics (0 to disable)") historySize := flag.Int("history-size", 1000, "Maximum number of requests to keep in history") dashboard := flag.Bool("dashboard", true, "Enable web dashboard") dashboardPort := flag.Int("dashboard-port", 3000, "Dashboard port") diff --git a/dashboard/e2e/README.md b/dashboard/e2e/README.md new file mode 100644 index 0000000..b646cf7 --- /dev/null +++ b/dashboard/e2e/README.md @@ -0,0 +1,141 @@ +# Playwright E2E Tests + +This directory contains end-to-end tests for the Netkit dashboard using Playwright. + +## Prerequisites + +Before running the tests, you need to have: + +1. **Netkit backend running** on port 8080 (proxy) and 8081 (admin) +2. **Dashboard dev server running** on port 3000 (or built dashboard) + +## Running Tests + +### Option 1: Automatic (Recommended for Local Development) + +The Playwright config will automatically start the dev server for you: + +```bash +# From the dashboard directory +npm run test:e2e +``` + +**Note:** You still need to start the netkit backend separately: + +```bash +# From the root directory, in a separate terminal +make serve-dev +``` + +### Option 2: Manual Setup + +1. Start the netkit backend: + ```bash + # From root directory + make serve-dev + ``` + +2. In another terminal, start the dashboard: + ```bash + # From root directory + make dashboard-dev + ``` + +3. In a third terminal, run the tests: + ```bash + # From dashboard directory + npm run test:e2e + ``` + +### Option 3: Using Make (Full Stack) + +From the root directory: + +```bash +# Start both backend and dashboard +make dev +``` + +Then in another terminal: + +```bash +# Run E2E tests +make test-e2e +``` + +## Test Commands + +- `npm run test:e2e` - Run tests headless (CI mode) +- `npm run test:e2e:ui` - Run tests with Playwright UI (interactive) +- `npm run test:e2e:headed` - Run tests with visible browser +- `npm run test:e2e:report` - View last test report + +Or from root directory with Make: +- `make test-e2e` - Run tests headless +- `make test-e2e-ui` - Run with UI +- `make test-e2e-headed` - Run with visible browser + +## CI/CD + +In GitHub Actions, the workflow: +1. Builds the netkit backend +2. Builds the dashboard +3. Starts the netkit server on ports 8080/8081/3000 +4. Runs the Playwright tests +5. Uploads test reports as artifacts + +The tests will run automatically on: +- Push to main branch +- Pull requests to main branch + +## Writing Tests + +Tests are located in `dashboard/e2e/*.spec.ts`. The tests: + +- Use Playwright's page object model +- Target the dashboard at `http://localhost:3000` +- Assume the proxy is running at `http://localhost:8080` +- Assume the admin API is at `http://localhost:8081` + +Example test structure: + +```typescript +import { test, expect } from '@playwright/test'; + +test.describe('Feature Name', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('should do something', async ({ page }) => { + // Your test code + }); +}); +``` + +## Debugging Failed Tests + +If tests fail: + +1. Check the HTML report: `npm run test:e2e:report` +2. Run with UI mode: `npm run test:e2e:ui` +3. Check screenshots in `test-results/` directory +4. Verify backend is running: `curl http://localhost:8081/healthz` +5. Verify dashboard is running: `curl http://localhost:3000` + +## Common Issues + +### "Proxy Offline" error +- The dashboard shows "Proxy Offline" when it can't reach the backend +- Ensure `make serve-dev` is running +- Check ports 8080 and 8081 are not in use by other processes + +### Tests timeout +- Increase timeout in `playwright.config.ts` if needed +- Check network connectivity +- Verify the backend responds quickly: `time curl http://localhost:8081/healthz` + +### Element not found errors +- The dashboard UI may have changed +- Update selectors in test files +- Use Playwright Inspector: `npx playwright test --debug` diff --git a/dashboard/e2e/dashboard.spec.ts b/dashboard/e2e/dashboard.spec.ts new file mode 100644 index 0000000..0676218 --- /dev/null +++ b/dashboard/e2e/dashboard.spec.ts @@ -0,0 +1,92 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Netkit Dashboard E2E', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('should load the dashboard', async ({ page }) => { + await expect(page).toHaveTitle(/netkit/i); + }); + + test('should display request builder form', async ({ page }) => { + // Check for method selector (combobox based on actual page structure) + const methodSelector = page.locator('role=combobox').first(); + await expect(methodSelector).toBeVisible(); + + // Check for URL input (textbox with placeholder) + const urlInput = page.getByPlaceholder(/api.example.com/i); + await expect(urlInput).toBeVisible(); + + // Check for send button + await expect(page.getByRole('button', { name: /send/i })).toBeVisible(); + }); + + test('should make a request through the proxy', async ({ page }) => { + // Fill in the URL + const urlInput = page.getByPlaceholder(/api.example.com/i); + await urlInput.fill('https://httpbin.org/get'); + + // The method is already GET by default, so we can skip selecting it + + // Click send button + await page.getByRole('button', { name: /send/i }).click(); + + // Wait for response to appear + await page.waitForTimeout(3000); + + // Check that the response is displayed (look for 200 status code or success text) + const responseSection = page.locator('text=/200|success/i').first(); + await expect(responseSection).toBeVisible({ timeout: 10000 }); + }); + + test('should display proxy request history', async ({ page }) => { + // Look for "Requests History" text which is visible in the sidebar + const historySection = page.locator('text=/Requests History/i').first(); + await expect(historySection).toBeVisible(); + }); + + test('should display request builder heading', async ({ page }) => { + // Look for Request Builder heading + const builderHeading = page.locator('text=/Request Builder/i').first(); + await expect(builderHeading).toBeVisible(); + }); + + test('should add custom headers to request', async ({ page }) => { + // Find and click add header button + const addHeaderButton = page.getByRole('button', { name: /add header/i }); + + if (await addHeaderButton.isVisible()) { + await addHeaderButton.click(); + + // Find the empty header name/value inputs (second row since first has User-Agent) + const headerNameInputs = page.getByPlaceholder(/header name/i); + const headerValueInputs = page.getByPlaceholder(/header value/i); + + // Get the last (newly added) header inputs + const newHeaderName = headerNameInputs.last(); + const newHeaderValue = headerValueInputs.last(); + + await newHeaderName.fill('X-Custom-Header'); + await newHeaderValue.fill('test-value'); + + await expect(newHeaderName).toHaveValue('X-Custom-Header'); + } + }); + + test('should handle request errors gracefully', async ({ page }) => { + // Fill in an invalid URL + const urlInput = page.getByPlaceholder(/api.example.com/i); + await urlInput.fill('http://invalid-domain-that-does-not-exist-12345.com'); + + // Click send + await page.getByRole('button', { name: /send/i }).click(); + + // Wait for error to appear + await page.waitForTimeout(5000); + + // Check that some error indication is shown + const errorIndicator = page.locator('text=/error|failed|could not/i').first(); + await expect(errorIndicator).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 3df2d75..6868796 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -32,8 +32,9 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "^1.55.1", "@tailwindcss/postcss": "^4", - "@types/node": "^20", + "@types/node": "^20.19.19", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -1024,6 +1025,22 @@ "node": ">=12.4.0" } }, + "node_modules/@playwright/test": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz", + "integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.55.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -2303,20 +2320,20 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.17.54", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.54.tgz", - "integrity": "sha512-aD9UeDW/GV8H54ESLKuvBHslOzXIKF4XTgHxvlCqUr7AsY9wnHFQQO3TrrAoaLmC113xe002YFFU8WCscD9W7g==", + "version": "20.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.19.tgz", + "integrity": "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.19.2" + "undici-types": "~6.21.0" } }, "node_modules/@types/react": { "version": "19.1.6", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz", "integrity": "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2326,7 +2343,7 @@ "version": "19.1.5", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz", "integrity": "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -4508,6 +4525,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6190,6 +6222,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", + "integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", + "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -7339,9 +7403,9 @@ } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, diff --git a/dashboard/package.json b/dashboard/package.json index 7d4d93a..e973eb7 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -7,7 +7,11 @@ "build": "next build", "build:static": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:report": "playwright show-report" }, "dependencies": { "@radix-ui/react-avatar": "^1.1.10", @@ -34,8 +38,9 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "^1.55.1", "@tailwindcss/postcss": "^4", - "@types/node": "^20", + "@types/node": "^20.19.19", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/dashboard/playwright-report/data/9212b1fe2a5e09cd395321c55444e28eaee74ab6.md b/dashboard/playwright-report/data/9212b1fe2a5e09cd395321c55444e28eaee74ab6.md new file mode 100644 index 0000000..523a84a --- /dev/null +++ b/dashboard/playwright-report/data/9212b1fe2a5e09cd395321c55444e28eaee74ab6.md @@ -0,0 +1,111 @@ +# Page snapshot + +```yaml +- generic [active] [ref=e1]: + - generic [ref=e2]: + - banner [ref=e3]: + - generic [ref=e4]: + - generic [ref=e5]: + - generic [ref=e6]: + - img [ref=e8] + - generic [ref=e10]: + - heading "netkit" [level=1] [ref=e11] + - paragraph [ref=e12]: HTTP Proxy Dashboard + - navigation [ref=e13]: + - link "Request Builder" [ref=e14] [cursor=pointer]: + - /url: / + - button "Request Builder" [ref=e15]: + - img + - text: Request Builder + - link "Requests History" [ref=e16] [cursor=pointer]: + - /url: /requests-history/ + - button "Requests History" [ref=e17]: + - img + - text: Requests History + - link "Requests Statistics" [ref=e18] [cursor=pointer]: + - /url: /requests-statistics/ + - button "Requests Statistics" [ref=e19]: + - img + - text: Requests Statistics + - generic [ref=e20]: + - generic [ref=e21]: Proxy Offline + - generic [ref=e23]: + - img + - text: localhost:8080 + - generic [ref=e24]: "Admin: 8081" + - generic [ref=e25]: + - button [ref=e26]: + - img + - button [ref=e27]: + - img + - generic [ref=e28]: + - generic [ref=e32]: + - generic [ref=e33]: + - generic [ref=e34]: + - generic [ref=e35]: + - img [ref=e36] + - generic [ref=e40]: Requests History + - generic [ref=e41]: + - generic [ref=e42]: "0" + - button [ref=e43]: + - img + - paragraph [ref=e44]: "Last updated: less than a minute ago" + - generic [ref=e51]: + - img [ref=e52] + - paragraph [ref=e55]: No requests yet + - paragraph [ref=e56]: Requests through the proxy will appear here + - main [ref=e58]: + - generic [ref=e60]: + - generic [ref=e62]: + - generic [ref=e63]: Request Builder + - button [ref=e65]: + - img + - generic [ref=e66]: + - generic [ref=e67]: + - combobox [ref=e68]: + - generic: + - generic: GET + - img + - textbox "https://api.example.com/endpoint" [ref=e69] + - button "Send" [disabled]: + - generic: + - img + - text: Send + - generic [ref=e70]: + - tablist [ref=e71]: + - tab "Headers" [selected] [ref=e72] + - tab "Body" [ref=e73] + - tabpanel "Headers" [ref=e74]: + - generic [ref=e75]: + - generic [ref=e76]: Headers + - button "Add Header" [ref=e77]: + - img + - text: Add Header + - generic [ref=e78]: + - generic [ref=e79]: + - checkbox [checked] [ref=e80] + - textbox "Header name" [ref=e81]: User-Agent + - textbox "Header value" [ref=e82]: fetchr.sh/1.0 + - button [ref=e83]: + - img + - generic [ref=e84]: + - checkbox [checked] [ref=e85] + - textbox "Header name" [ref=e86] + - textbox "Header value" [ref=e87] + - button [ref=e88]: + - img + - alert [ref=e89] + - generic [ref=e94] [cursor=pointer]: + - button "Open Next.js Dev Tools" [ref=e95] [cursor=pointer]: + - img [ref=e96] [cursor=pointer] + - generic [ref=e99] [cursor=pointer]: + - button "Open issues overlay" [ref=e100] [cursor=pointer]: + - generic [ref=e101] [cursor=pointer]: + - generic [ref=e102] [cursor=pointer]: "1" + - generic [ref=e103] [cursor=pointer]: "2" + - generic [ref=e104] [cursor=pointer]: + - text: Issue + - generic [ref=e105] [cursor=pointer]: s + - button "Collapse issues badge" [ref=e106] [cursor=pointer]: + - img [ref=e107] [cursor=pointer] +``` \ No newline at end of file diff --git a/dashboard/playwright-report/index.html b/dashboard/playwright-report/index.html new file mode 100644 index 0000000..4c478bb --- /dev/null +++ b/dashboard/playwright-report/index.html @@ -0,0 +1,76 @@ + + + + +
+ + + +