Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: CI Tests

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
cache: npm

- name: Install dependencies
run: npm ci

- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium

- name: Run tests (including e2e)
run: xvfb-run npm run test

- name: Verify Docker build and health (Smoke Test)
run: |
docker build -t codex-proxy-test:latest .
docker run -d --name test-container -p 8080:8080 codex-proxy-test:latest

# Wait for healthcheck to pass
timeout=30
while [ $timeout -gt 0 ]; do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' test-container)
if [ "$STATUS" = "healthy" ]; then
echo "Container is healthy!"
break
fi
sleep 1
timeout=$((timeout-1))
done

if [ $timeout -eq 0 ]; then
echo "Container failed to become healthy in time."
docker logs test-container
exit 1
fi

# Verify HTTP endpoint returns 200
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/health)
if [ "$HTTP_STATUS" != "200" ]; then
echo "Expected HTTP 200 from /health, got $HTTP_STATUS"
exit 1
fi

docker rm -f test-container

- name: Verify Docker build and health with custom port (Smoke Test)
run: |
docker run -d --name test-container-custom -p 8090:8090 -e PORT=8090 codex-proxy-test:latest

# Wait for healthcheck to pass
timeout=30
while [ $timeout -gt 0 ]; do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' test-container-custom)
if [ "$STATUS" = "healthy" ]; then
echo "Container is healthy on custom port!"
break
fi
sleep 1
timeout=$((timeout-1))
done

if [ $timeout -eq 0 ]; then
echo "Container failed to become healthy in time on custom port."
docker logs test-container-custom
exit 1
fi

# Verify HTTP endpoint returns 200 on custom port
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8090/health)
if [ "$HTTP_STATUS" != "200" ]; then
echo "Expected HTTP 200 from /health on 8090, got $HTTP_STATUS"
exit 1
fi

docker rm -f test-container-custom
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ scripts/apply-update.ts
scripts/check-update.ts
scripts/types.ts
scripts/test-*.ts
tests/
# tests/
scripts/cron-update.sh
config/extraction-patterns.yaml

Expand Down
66 changes: 63 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
"@vitest/coverage-v8": "^3.2.4",
"electron-to-chromium": "^1.5.302",
"js-beautify": "^1.15.0",
"playwright": "^1.58.2",
"preact": "^10.29.0",
"tsx": "^4.0.0",
"typescript": "^5.5.0",
"vitest": "^3.2.4"
Expand Down
77 changes: 77 additions & 0 deletions tests/e2e/electron-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { execSync } from "child_process";
import { describe, it, expect, beforeAll } from "vitest";
import { _electron as electron } from "playwright";
import fs from "fs";
import path from "path";

describe("Electron App Smoke Test", () => {
beforeAll(() => {
// We only need to test Linux since we're on a Linux runner in CI,
// and this matches the "test: verify Electron app packages and launches"
console.log("Building the desktop frontend and bundling Electron...");
execSync("cd packages/electron && npm run build", { stdio: "pipe" });

console.log("Preparing pack resources...");
execSync("cd packages/electron && node electron/prepare-pack.mjs", { stdio: "pipe" });

console.log("Packaging the Electron app for Linux (dir mode)...");
// Use --dir to avoid actually creating a zip/AppImage, just get the unpacked binary
try {
// Memory: electron-builder might fail if we don't have GH_TOKEN or similar env variable,
// but it shouldn't for a dir build. However, in sandbox, sometimes stdout pipes break with npx.
// We pass publish never just in case.
execSync("cd packages/electron && npx electron-builder --linux --dir -c.compression=store -c.publish=null", {
stdio: "ignore",
env: {
...process.env,
GH_TOKEN: "", // prevent github publish attempts
}
});
} catch (e: any) {
console.warn("electron-builder failed with publish null, trying without publish");
try {
execSync("cd packages/electron && npx electron-builder --linux --dir -c.compression=store", {

Check failure on line 33 in tests/e2e/electron-smoke.test.ts

View workflow job for this annotation

GitHub Actions / test

tests/e2e/electron-smoke.test.ts > Electron App Smoke Test

Error: Command failed: cd packages/electron && npx electron-builder --linux --dir -c.compression=store ❯ tests/e2e/electron-smoke.test.ts:33:9 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { status: 1, signal: null, output: [ null, '<Buffer(1884) ...>', '<Buffer(0) ...>' ], pid: 4279, stdout: '<Buffer(1884) ...>', stderr: '<Buffer(0) ...>' }
stdio: "pipe",
});
} catch (e2: any) {
console.error("electron-builder failed completely");
console.error(e2.stdout?.toString());
console.error(e2.stderr?.toString());
throw e2;
}
}
}, 300000); // 5 minutes timeout for building and packaging

it("should launch the built Electron app successfully", async () => {
// Find the unpacked executable
const distDir = path.resolve(__dirname, "../../packages/electron/release/linux-unpacked");
const executablePath = path.join(distDir, "@codex-proxyelectron");

expect(fs.existsSync(executablePath)).toBe(true);

console.log("Launching Electron app via Playwright...");

// In some restricted environments like Docker, electron/playwright can hang.
// We can also verify by launching the binary directly using child_process and checking if it exits or starts.
// Let's use Playwright but with more robust arguments for headless Linux.
const electronApp = await electron.launch({
executablePath,
args: [
"--no-sandbox",
"--disable-gpu",
"--disable-dev-shm-usage",
"--disable-software-rasterizer"
],
timeout: 15000 // fail fast instead of waiting 60s if Playwright hangs
});

const window = await electronApp.firstWindow();

expect(window).toBeTruthy();

const title = await window.title();
expect(typeof title).toBe("string");

await electronApp.close();
}, 60000);
});
102 changes: 102 additions & 0 deletions tests/e2e/web-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { execSync, spawn } from "child_process";
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import http from "http";
import fs from "fs";
import path from "path";

describe("Web Frontend Smoke Test", () => {
let serverProcess: any;

beforeAll(() => {
console.log("Building web frontend and backend...");
// Memory says we might need to downgrade vite or ensure preact is resolved correctly. Let's install preact in web.
// Memory explicitly states: "To ensure path aliases in 'web/vite.config.ts' resolve correctly during global Vitest runs, 'preact' must be included in the root 'package.json' devDependencies."
// Also memory states: "If the web frontend fails to build with a 'TypeError: Cannot use in operator to search for meta' error, it is likely due to an incompatibility with Vite 6. Downgrading to vite@5 and @preact/[email protected] resolves this issue." (Though we got an ENOENT for jsx-dev-runtime instead here).

// Ensure all packages are installed correctly with npm since we are in an npm-managed project
// This is a test environment setup so we'll ensure build completes safely.
execSync("npm run build", { stdio: "inherit" });

Check failure on line 18 in tests/e2e/web-smoke.test.ts

View workflow job for this annotation

GitHub Actions / test

tests/e2e/web-smoke.test.ts > Web Frontend Smoke Test

Error: Command failed: npm run build ❯ tests/e2e/web-smoke.test.ts:18:5 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { status: 1, signal: null, output: [ null, null, null ], pid: 3711, stdout: null, stderr: null }
}, 120000); // 2 minutes timeout for building

afterAll(() => {
if (serverProcess) {
serverProcess.kill();
}
});

it("should have generated CSS files in public/assets", () => {
const assetsDir = path.resolve(__dirname, "../../public/assets");
expect(fs.existsSync(assetsDir)).toBe(true);

const files = fs.readdirSync(assetsDir);
const cssFiles = files.filter((f) => f.endsWith(".css"));

// We expect at least one compiled CSS file generated by Vite/Tailwind
expect(cssFiles.length).toBeGreaterThan(0);

// Read the contents of the generated CSS files
let combinedCss = "";
for (const file of cssFiles) {
combinedCss += fs.readFileSync(path.join(assetsDir, file), "utf-8");
}

// Verify CSS is generated for both light and dark themes
expect(combinedCss).toContain(".dark");
// Some basic tailwind utility check that might be present
expect(combinedCss).toContain("bg-bg-light");
});

it("should serve the web dashboard at /", async () => {
// Start the server as a child process
serverProcess = spawn("node", ["dist/index.js"], {
cwd: path.resolve(__dirname, "../../"),
env: {
...process.env,
PORT: "8081", // Use a different port to avoid conflicts with other tests
},
});

// Wait for the server to be ready
let isReady = false;
for (let i = 0; i < 30; i++) {
try {
const statusCode = await new Promise((resolve, reject) => {
http
.get("http://localhost:8081/health", (res) => {
resolve(res.statusCode);
})
.on("error", reject);
});

if (statusCode === 200) {
isReady = true;
break;
}
} catch (e) {
// Ignore connection refused errors while server is starting
}
await new Promise((resolve) => setTimeout(resolve, 500));
}

expect(isReady).toBe(true);

// Fetch the root HTML
const html: string = await new Promise((resolve, reject) => {
http
.get("http://localhost:8081/", (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
resolve(data);
});
})
.on("error", reject);
});

// Verify it serves the index.html from Vite build
expect(html).toContain('<div id="app"></div>');
expect(html).toContain("Codex Proxy Developer Dashboard");
}, 15000); // 15s timeout for starting and testing
});
Loading
Loading