diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml new file mode 100644 index 0000000..233cc89 --- /dev/null +++ b/.github/workflows/docker-smoke-test.yml @@ -0,0 +1,90 @@ +name: Docker Smoke Test + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t codex-proxy-test . + + - name: Test default port (8080) + run: | + docker run -d --name proxy-default -p 8080:8080 codex-proxy-test + + echo "Waiting for container to become healthy..." + for i in {1..30}; do + STATUS=$(docker inspect --format='{{.State.Health.Status}}' proxy-default) + if [ "$STATUS" = "healthy" ]; then + echo "Container is healthy!" + break + fi + if [ "$STATUS" = "unhealthy" ]; then + echo "Container is unhealthy!" + docker logs proxy-default + exit 1 + fi + sleep 1 + done + + if [ "$(docker inspect --format='{{.State.Health.Status}}' proxy-default)" != "healthy" ]; then + echo "Container failed to become healthy within 30s" + docker logs proxy-default + exit 1 + fi + + STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/health) + if [ "$STATUS_CODE" != "200" ]; then + echo "Expected 200 from /health, got $STATUS_CODE" + docker logs proxy-default + exit 1 + fi + + docker rm -f proxy-default + + - name: Test custom port (8090) + run: | + mkdir -p custom-config + cp config/default.yaml custom-config/ + sed -i 's/port: 8080/port: 8090/' custom-config/default.yaml + + docker run -d --name proxy-custom \ + -p 8090:8090 \ + -v $(pwd)/custom-config/default.yaml:/app/config/default.yaml \ + codex-proxy-test + + echo "Waiting for container to become healthy..." + for i in {1..30}; do + STATUS=$(docker inspect --format='{{.State.Health.Status}}' proxy-custom) + if [ "$STATUS" = "healthy" ]; then + echo "Container is healthy!" + break + fi + if [ "$STATUS" = "unhealthy" ]; then + echo "Container is unhealthy!" + docker logs proxy-custom + exit 1 + fi + sleep 1 + done + + if [ "$(docker inspect --format='{{.State.Health.Status}}' proxy-custom)" != "healthy" ]; then + echo "Container failed to become healthy within 30s" + docker logs proxy-custom + exit 1 + fi + + STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8090/health) + if [ "$STATUS_CODE" != "200" ]; then + echo "Expected 200 from /health on port 8090, got $STATUS_CODE" + docker logs proxy-custom + exit 1 + fi + + docker rm -f proxy-custom diff --git a/.github/workflows/electron-smoke-test.yml b/.github/workflows/electron-smoke-test.yml new file mode 100644 index 0000000..491701e --- /dev/null +++ b/.github/workflows/electron-smoke-test.yml @@ -0,0 +1,42 @@ +name: Electron Smoke Test + +on: + push: + branches: [master] + paths: + - 'packages/electron/**' + pull_request: + paths: + - 'packages/electron/**' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Build root project + run: npm run build + + - name: Build Electron app + run: cd packages/electron && npm run build + + - name: Prepare Electron pack + run: cd packages/electron && node electron/prepare-pack.mjs + + - name: Install Playwright + run: npx playwright install --with-deps chromium + + - name: Package Electron app + run: cd packages/electron && npx electron-builder --linux --dir -c.publish=never + + - name: Run Electron smoke test + run: cd packages/electron && xvfb-run npm run test -- 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..80784e4 --- /dev/null +++ b/.github/workflows/web-smoke-test.yml @@ -0,0 +1,26 @@ +name: Web Smoke Test + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Build root project + run: npm run build + + - name: Run Web smoke test + run: npm test -- src/__tests__/web-smoke.test.ts diff --git a/package-lock.json b/package-lock.json index 25c9854..8ab2cf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-proxy", - "version": "1.0.67", + "version": "1.0.83", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-proxy", - "version": "1.0.67", + "version": "1.0.83", "hasInstallScript": true, "workspaces": [ "packages/*" @@ -5622,6 +5622,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 +7411,15 @@ }, "packages/electron": { "name": "@codex-proxy/electron", - "version": "1.0.67", + "version": "1.0.83", "dependencies": { "electron-updater": "^6.3.0" }, "devDependencies": { "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..4937110 --- /dev/null +++ b/packages/electron/__tests__/launch/smoke.test.ts @@ -0,0 +1,34 @@ +import { test, expect } from "vitest"; +import { _electron as electron } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; + +test("Electron app launches and window opens", async () => { + // Find the linux-unpacked directory + const releaseDir = path.join(import.meta.dirname, "../../release"); + const unpackedDirs = fs.readdirSync(releaseDir).filter(dir => dir.endsWith("-unpacked")); + + if (unpackedDirs.length === 0) { + throw new Error(`No unpacked directory found in ${releaseDir}`); + } + + const unpackedDir = path.join(releaseDir, unpackedDirs[0]); + const executableName = "@codex-proxyelectron"; // electron-builder default for this name + + // Actually look for the binary + const binaryCandidates = fs.readdirSync(unpackedDir).filter(f => !f.endsWith(".so") && !f.endsWith(".pak") && !f.endsWith(".bin") && !f.includes(".")); + const executablePath = path.join(unpackedDir, binaryCandidates.find(c => c === executableName || c === "codex-proxy") || binaryCandidates[0]); + + console.log(`Launching executable: ${executablePath}`); + + const electronApp = await electron.launch({ + executablePath, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + + const window = await electronApp.firstWindow(); + const title = await window.title(); + expect(title).toBeDefined(); + + await electronApp.close(); +}); diff --git a/packages/electron/package.json b/packages/electron/package.json index c61f6a4..1582a8b 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -21,6 +21,7 @@ "devDependencies": { "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..4819404 --- /dev/null +++ b/src/__tests__/web-smoke.test.ts @@ -0,0 +1,51 @@ +import { test, expect, beforeAll, afterAll } from "vitest"; +import { spawn, ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +let serverProcess: ChildProcess; + +beforeAll(async () => { + // Start server + const env = { ...process.env, PORT: "8081" }; + const indexPath = path.join(import.meta.dirname, "../../dist/index.js"); + + serverProcess = spawn("node", [indexPath], { env, stdio: "inherit" }); + + // Wait a bit for server to start + await new Promise(resolve => setTimeout(resolve, 2000)); +}, 10000); + +afterAll(() => { + if (serverProcess) { + serverProcess.kill(); + } +}); + +test("CSS assets have light and dark theme rules", () => { + const assetsDir = path.join(import.meta.dirname, "../../public/assets"); + const files = fs.readdirSync(assetsDir); + const cssFiles = files.filter(f => f.endsWith(".css")); + + expect(cssFiles.length).toBeGreaterThan(0); + + let foundDarkTheme = false; + + for (const cssFile of cssFiles) { + const cssContent = fs.readFileSync(path.join(assetsDir, cssFile), "utf-8"); + if (cssContent.includes("@media (prefers-color-scheme: dark)") || cssContent.includes("dark:")) { + foundDarkTheme = true; + break; + } + } + + expect(foundDarkTheme).toBe(true); +}); + +test("Server serves HTML with #app div", async () => { + const response = await fetch("http://localhost:8081/"); + expect(response.status).toBe(200); + + const html = await response.text(); + expect(html).toContain('
'); +});