diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml new file mode 100644 index 0000000..71f41c6 --- /dev/null +++ b/.github/workflows/docker-smoke-test.yml @@ -0,0 +1,86 @@ +name: CI — Docker smoke test + +on: + pull_request: + branches: [master] + push: + branches: [master] + +jobs: + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t codex-proxy-test . + + - name: Run container on default port (8080) + run: | + docker run -d --name codex-proxy-test-default -p 8080:8080 codex-proxy-test + + # Wait for healthcheck (up to 30s) + echo "Waiting for container to become healthy..." + for i in $(seq 1 15); do + STATUS=$(docker inspect --format='{{.State.Health.Status}}' codex-proxy-test-default 2>/dev/null || echo "starting") + if [ "$STATUS" = "healthy" ]; then + echo "Container is healthy" + break + fi + sleep 2 + done + + if [ "$STATUS" != "healthy" ]; then + echo "::error::Container did not become healthy within 30s" + docker logs codex-proxy-test-default + exit 1 + fi + + # Verify /health returns 200 + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/health) + if [ "$HTTP_STATUS" != "200" ]; then + echo "::error::Expected HTTP 200 from /health, got $HTTP_STATUS" + exit 1 + fi + echo "/health returned 200" + + - name: Cleanup default container + if: always() + run: docker rm -f codex-proxy-test-default || true + + - name: Test custom port (8090) + run: | + # Modify config/default.yaml to use port 8090 + sed -i 's/port: 8080/port: 8090/' config/default.yaml + + # Run container with modified config mounted + docker run -d --name codex-proxy-test-custom -p 8090:8090 -v $(pwd)/config/default.yaml:/app/config/default.yaml codex-proxy-test + + # Wait for healthcheck (up to 30s) + echo "Waiting for container to become healthy on custom port..." + for i in $(seq 1 15); do + STATUS=$(docker inspect --format='{{.State.Health.Status}}' codex-proxy-test-custom 2>/dev/null || echo "starting") + if [ "$STATUS" = "healthy" ]; then + echo "Container is healthy" + break + fi + sleep 2 + done + + if [ "$STATUS" != "healthy" ]; then + echo "::error::Container did not become healthy within 30s" + docker logs codex-proxy-test-custom + exit 1 + fi + + # Verify /health returns 200 on 8090 + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8090/health) + if [ "$HTTP_STATUS" != "200" ]; then + echo "::error::Expected HTTP 200 from /health on port 8090, got $HTTP_STATUS" + exit 1 + fi + echo "/health returned 200 on port 8090" + + - name: Cleanup custom container + if: always() + run: docker rm -f codex-proxy-test-custom || true diff --git a/.github/workflows/electron-smoke-test.yml b/.github/workflows/electron-smoke-test.yml new file mode 100644 index 0000000..0b185c7 --- /dev/null +++ b/.github/workflows/electron-smoke-test.yml @@ -0,0 +1,45 @@ +name: CI — Electron smoke test + +on: + pull_request: + branches: [master] + paths: + - 'packages/electron/**' + push: + branches: [master] + paths: + - 'packages/electron/**' + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build root + run: npm run build + + - name: Build Electron desktop frontend + run: cd packages/electron && npm run build + + - name: Prepare packaging + run: cd packages/electron && node electron/prepare-pack.mjs + + - name: Package with electron-builder + run: cd packages/electron && npx electron-builder --linux --dir + + - name: Install Playwright Chromium dependencies + run: npx playwright install --with-deps chromium + + - name: Test launch and close cleanly + run: xvfb-run npm test -- packages/electron/__tests__/launch/smoke.test.ts diff --git a/.github/workflows/web-smoke-test.yml b/.github/workflows/web-smoke-test.yml new file mode 100644 index 0000000..2b88770 --- /dev/null +++ b/.github/workflows/web-smoke-test.yml @@ -0,0 +1,29 @@ +name: CI — Web smoke test + +on: + pull_request: + branches: [master] + push: + branches: [master] + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Test frontend build and server + run: npm test -- src/__tests__/web-smoke.test.ts diff --git a/package-lock.json b/package-lock.json index f895cc4..80c0401 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-proxy", - "version": "2.0.25", + "version": "2.0.36", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-proxy", - "version": "2.0.25", + "version": "2.0.36", "hasInstallScript": true, "workspaces": [ "packages/*" @@ -1224,6 +1224,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -5622,6 +5638,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/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/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -7364,14 +7427,16 @@ }, "packages/electron": { "name": "@codex-proxy/electron", - "version": "2.0.25", + "version": "2.0.36", "dependencies": { "electron-updater": "^6.3.0" }, "devDependencies": { + "@playwright/test": "^1.58.2", "electron": "^35.7.5", "electron-builder": "^26.0.0", - "esbuild": "^0.25.12" + "esbuild": "^0.25.12", + "playwright": "^1.58.2" } }, "packages/electron/node_modules/@esbuild/aix-ppc64": { diff --git a/packages/electron/__tests__/launch/smoke.test.ts b/packages/electron/__tests__/launch/smoke.test.ts new file mode 100644 index 0000000..58593c5 --- /dev/null +++ b/packages/electron/__tests__/launch/smoke.test.ts @@ -0,0 +1,44 @@ +import { test, expect } from "vitest"; +import { _electron } from "playwright"; +import { resolve } from "path"; +import { readdirSync } from "fs"; + +// Increase timeout for the launch test +test("Electron app packages and launches main window", async () => { + // Find the linux unpacked directory + const unpackedDir = resolve(import.meta.dirname, "../../release/linux-unpacked"); + + // Find the actual executable dynamically + const files = readdirSync(unpackedDir); + const executableName = files.find(file => { + return !file.includes(".") && !["locales", "resources"].includes(file) && !file.includes("chrome-sandbox") && !file.includes("chrome_crashpad_handler"); + }); + + if (!executableName) { + throw new Error("Could not find executable in linux-unpacked directory"); + } + + const executablePath = resolve(unpackedDir, executableName); + + // Launch the app + const electronApp = await _electron.launch({ + executablePath, + args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], + }); + + // Verify app launched correctly by waiting for the first window + const window = await electronApp.firstWindow(); + + // Verify it exists (it resolves to a Playwright Page object) + expect(window).toBeDefined(); + + // Wait for it to be ready + await window.waitForLoadState("domcontentloaded"); + + // Get window title to verify it loaded our app + const title = await window.title(); + expect(title).toBe("Codex Proxy Developer Dashboard"); + + // Close cleanly + await electronApp.close(); +}, 60000); diff --git a/packages/electron/package.json b/packages/electron/package.json index e4f7b9a..da288be 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -19,8 +19,10 @@ "electron-updater": "^6.3.0" }, "devDependencies": { + "@playwright/test": "^1.58.2", "electron": "^35.7.5", "electron-builder": "^26.0.0", - "esbuild": "^0.25.12" + "esbuild": "^0.25.12", + "playwright": "^1.58.2" } } diff --git a/src/__tests__/web-smoke.test.ts b/src/__tests__/web-smoke.test.ts new file mode 100644 index 0000000..1e348f8 --- /dev/null +++ b/src/__tests__/web-smoke.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { execSync, spawn, ChildProcess } from "child_process"; +import { resolve } from "path"; +import { readdirSync, readFileSync, writeFileSync, mkdirSync } from "fs"; + +describe("Web Frontend Smoke Test", () => { + let serverProcess: ChildProcess; + const PORT = 8081; + const dataDir = resolve(import.meta.dirname, "../../data"); + const localYamlPath = resolve(dataDir, "local.yaml"); + + beforeAll(async () => { + }, 120000); + + afterAll(() => { + if (serverProcess) { + serverProcess.kill(); + } + }); + + it("should have CSS files in public/assets with light and dark theme rules", () => { + const assetsDir = resolve(import.meta.dirname, "../../public/assets"); + const files = readdirSync(assetsDir); + const cssFiles = files.filter(f => f.endsWith(".css")); + + expect(cssFiles.length).toBeGreaterThan(0); + + let hasDark = false; + let hasLight = false; + + for (const cssFile of cssFiles) { + const content = readFileSync(resolve(assetsDir, cssFile), "utf-8"); + if (content.includes(".dark")) hasDark = true; + if (content.includes(".light") || content.includes(":root")) hasLight = true; + } + + expect(hasDark).toBe(true); + expect(hasLight).toBe(true); + }); + + it("should start the server and serve the HTML containing
", async () => { + let backupYaml = ""; + try { + backupYaml = readFileSync(localYamlPath, "utf-8"); + } catch (e) { + } + + try { + mkdirSync(dataDir, { recursive: true }); + writeFileSync(localYamlPath, `tls:\n transport: curl-cli\nserver:\n port: ${PORT}\n`); + + serverProcess = spawn("node", ["dist/index.js"], { + cwd: resolve(import.meta.dirname, "../../"), + stdio: "inherit" + }); + + let attempts = 0; + let serverReady = false; + while (attempts < 20 && !serverReady) { + try { + const res = await fetch(`http://localhost:${PORT}/`); + if (res.status === 200) { + serverReady = true; + } + } catch (e) { + } + if (!serverReady) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + attempts++; + } + } + + expect(serverReady).toBe(true); + + const res = await fetch(`http://localhost:${PORT}/`); + expect(res.status).toBe(200); + + const html = await res.text(); + expect(html).toContain(''); + + } finally { + try { + if (backupYaml) { + writeFileSync(localYamlPath, backupYaml); + } else { + writeFileSync(localYamlPath, ""); + } + } catch (e) {} + } + }, 30000); +});