From f0b4f42cb195fc3b3af724b93907168c760241f7 Mon Sep 17 00:00:00 2001 From: antigravity Date: Mon, 15 Jun 2026 14:00:18 +0200 Subject: [PATCH] Initial commit with tests and deployment pipeline --- .gitea/workflows/test-and-deploy.yml | 58 ++++++ .gitignore | 4 + package-lock.json | 78 +++++++ package.json | 14 ++ playwright.config.js | 27 +++ public/index.html | 293 +++++++++++++++++++++++++++ public/js/utils.js | 12 ++ server.js | 52 +++++ tests/e2e.spec.js | 24 +++ tests/integration.test.js | 35 ++++ tests/unit.test.js | 16 ++ 11 files changed, 613 insertions(+) create mode 100644 .gitea/workflows/test-and-deploy.yml create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.js create mode 100644 public/index.html create mode 100644 public/js/utils.js create mode 100644 server.js create mode 100644 tests/e2e.spec.js create mode 100644 tests/integration.test.js create mode 100644 tests/unit.test.js diff --git a/.gitea/workflows/test-and-deploy.yml b/.gitea/workflows/test-and-deploy.yml new file mode 100644 index 0000000..f3307e2 --- /dev/null +++ b/.gitea/workflows/test-and-deploy.yml @@ -0,0 +1,58 @@ +name: Test and Deploy Website + +on: + push: + branches: + - '**' + +jobs: + test-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install Dependencies + run: npm ci + + - name: Run Unit Tests + run: npm run test:unit + + - name: Run Integration Tests + run: npm run test:integration + + - name: Install Playwright Browsers and Deps + run: npx playwright install --with-deps chromium + + - name: Run E2E Tests + run: npm run test:e2e + + - name: Deploy to Production + if: github.ref == 'refs/heads/main' + run: | + echo "Deploying to Production website..." + # Make sure target directory exists (it is mounted from host) + mkdir -p /root/docker/website-prod/html + # Clean old files + rm -rf /root/docker/website-prod/html/* + # Copy new files + cp -r public/* /root/docker/website-prod/html/ + echo "Production deployment completed!" + + - name: Deploy to Staging + if: github.ref != 'refs/heads/main' + run: | + echo "Deploying to Staging website..." + # Make sure target directory exists (it is mounted from host) + mkdir -p /root/docker/website-stage/html + # Clean old files + rm -rf /root/docker/website-stage/html/* + # Copy new files + cp -r public/* /root/docker/website-stage/html/ + echo "Staging deployment completed!" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf771ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +test-results/ +playwright-report/ +.env diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d3ed5ae --- /dev/null +++ b/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "robert-mueller-homepage", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "robert-mueller-homepage", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.44.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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/playwright": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..14e7adb --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "robert-mueller-homepage", + "version": "1.0.0", + "description": "Personal Homepage for Robert Müller", + "main": "index.js", + "scripts": { + "test:unit": "node --test tests/unit.test.js", + "test:integration": "node --test tests/integration.test.js", + "test:e2e": "playwright test --workers=1" + }, + "devDependencies": { + "@playwright/test": "^1.44.0" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..efe4b55 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,27 @@ +const { defineConfig, devices } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './tests', + fullyParallel: false, + forbiddenOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, // Restrict to 1 worker to conserve RAM on 2GB VPS + reporter: 'list', + use: { + baseURL: 'http://localhost:8080', + trace: 'on-first-retry', + headless: true, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'node server.js', + url: 'http://localhost:8080', + reuseExistingServer: !process.env.CI, + timeout: 10000, + }, +}); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..62b2d4a --- /dev/null +++ b/public/index.html @@ -0,0 +1,293 @@ + + + + + + Robert Müller — Software Engineer & Architect + + + + + + +
+
+ +
+ Online +

Robert Müller

+
Software Engineer & Architect
+ +
...
+ +

+ Specializing in building robust, high-performance distributed systems, container orchestration, and security-hardened self-hosted platforms. Committed to craftsmanship, optimization, and clean architectures. +

+ +
Core Competencies
+
+
+

DevOps

+

Docker & Compose

+
+
+

Backend

+

PostgreSQL & Node.js

+
+
+

SysOps

+

Nginx & Linux OS

+
+
+ + Get In Touch +
+ + + + + + + diff --git a/public/js/utils.js b/public/js/utils.js new file mode 100644 index 0000000..49f5c0b --- /dev/null +++ b/public/js/utils.js @@ -0,0 +1,12 @@ +function validateEmail(email) { + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return re.test(email); +} + +function formatGreeting(timeOfDay, name) { + return `Good ${timeOfDay}, welcome to ${name}'s portfolio!`; +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { validateEmail, formatGreeting }; +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..a0db208 --- /dev/null +++ b/server.js @@ -0,0 +1,52 @@ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const PORT = 8080; +const PUBLIC_DIR = path.join(__dirname, 'public'); + +const MIME_TYPES = { + '.html': 'text/html', + '.css': 'text/css', + '.js': 'text/javascript', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.svg': 'image/svg+xml', +}; + +const server = http.createServer((req, res) => { + let filePath = path.join(PUBLIC_DIR, req.url === '/' ? 'index.html' : req.url); + + // Prevent directory traversal + if (!filePath.startsWith(PUBLIC_DIR)) { + res.statusCode = 403; + res.end('Forbidden'); + return; + } + + const ext = path.extname(filePath); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + + fs.readFile(filePath, (err, data) => { + if (err) { + if (err.code === 'ENOENT') { + res.statusCode = 404; + res.setHeader('Content-Type', 'text/plain'); + res.end('Not Found'); + } else { + res.statusCode = 500; + res.setHeader('Content-Type', 'text/plain'); + res.end('Internal Server Error'); + } + return; + } + res.statusCode = 200; + res.setHeader('Content-Type', contentType); + res.end(data); + }); +}); + +server.listen(PORT, () => { + console.log(`Server running at http://localhost:${PORT}/`); +}); diff --git a/tests/e2e.spec.js b/tests/e2e.spec.js new file mode 100644 index 0000000..0b7afb5 --- /dev/null +++ b/tests/e2e.spec.js @@ -0,0 +1,24 @@ +const { test, expect } = require('@playwright/test'); + +test.describe('E2E Tests: Portfolio Homepage', () => { + test('should load page and display correct title and header', async ({ page }) => { + await page.goto('/'); + + // Check title + await expect(page).toHaveTitle(/Robert Müller/i); + + // Check header heading + const heading = page.locator('h1'); + await expect(heading).toBeVisible(); + await expect(heading).toContainText('Robert Müller'); + }); + + test('should contain interactive email link', async ({ page }) => { + await page.goto('/'); + + // Check if mail link exists and has correct href + const mailLink = page.locator('a[href^="mailto:"]'); + await expect(mailLink).toBeVisible(); + await expect(mailLink).toHaveAttribute('href', 'mailto:mail@robert-mueller.net'); + }); +}); diff --git a/tests/integration.test.js b/tests/integration.test.js new file mode 100644 index 0000000..e0f7470 --- /dev/null +++ b/tests/integration.test.js @@ -0,0 +1,35 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const http = require('http'); +const { spawn } = require('child_process'); + +test('Integration Tests: Local Static Server', async (t) => { + // Start server as a child process + const serverProcess = spawn('node', ['server.js']); + + // Wait for server to start + await new Promise((resolve) => setTimeout(resolve, 1500)); + + await t.test('Server returns 200 OK for index.html', () => { + return new Promise((resolve, reject) => { + http.get('http://localhost:8080/', (res) => { + assert.strictEqual(res.statusCode, 200); + assert.match(res.headers['content-type'], /text\/html/); + resolve(); + }).on('error', reject); + }); + }); + + await t.test('Server returns 404 for non-existent routes', () => { + return new Promise((resolve, reject) => { + http.get('http://localhost:8080/non-existent-page.html', (res) => { + assert.strictEqual(res.statusCode, 404); + resolve(); + }).on('error', reject); + }); + }); + + // Stop server + serverProcess.kill(); + await new Promise((resolve) => setTimeout(resolve, 500)); +}); diff --git a/tests/unit.test.js b/tests/unit.test.js new file mode 100644 index 0000000..680a0aa --- /dev/null +++ b/tests/unit.test.js @@ -0,0 +1,16 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { validateEmail, formatGreeting } = require('../public/js/utils.js'); + +test('Unit Tests: validateEmail', (t) => { + assert.strictEqual(validateEmail('mail@robert-mueller.net'), true); + assert.strictEqual(validateEmail('invalid-email'), false); + assert.strictEqual(validateEmail('@domain.com'), false); +}); + +test('Unit Tests: formatGreeting', (t) => { + assert.strictEqual( + formatGreeting('evening', 'Robert Müller'), + "Good evening, welcome to Robert Müller's portfolio!" + ); +});