refactor: flatten monorepo structure to backend/ frontend/ devops/
Rename subdirectories for a cleaner single-repo layout: - website-monitoring-backend/ → backend/ - website-monitoring-frontend/ → frontend/ - website-monitoring-devops/ → devops/ Update all references in package.json scripts, CI workflows, docker-compose, pre-commit hooks, and documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import request from "supertest";
|
||||
import { app } from "../index.js";
|
||||
|
||||
describe("API Server", () => {
|
||||
describe("GET /health", () => {
|
||||
it("should return 200 with status ok", async () => {
|
||||
const res = await request(app).get("/health");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe("ok");
|
||||
expect(res.body.timestamp).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /", () => {
|
||||
it("should return API info", async () => {
|
||||
const res = await request(app).get("/");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe("Website Monitoring API");
|
||||
expect(res.body.version).toBe("1.0.0");
|
||||
expect(res.body.endpoints).toContain("/health");
|
||||
expect(res.body.endpoints).toContain("/api/lighthouse");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/lighthouse", () => {
|
||||
it("should return 400 when no URL provided", async () => {
|
||||
const res = await request(app).post("/api/lighthouse").send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("Missing URL");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import request from "supertest";
|
||||
import { app } from "../index.js";
|
||||
|
||||
describe("Lighthouse API", () => {
|
||||
describe("POST /api/lighthouse", () => {
|
||||
it("should return 400 when body is empty", async () => {
|
||||
const res = await request(app).post("/api/lighthouse").send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("Missing URL");
|
||||
});
|
||||
|
||||
it("should return 400 when URL is missing from body", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/lighthouse")
|
||||
.send({ notUrl: "something" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("Missing URL");
|
||||
});
|
||||
|
||||
it("should accept a valid URL and return a clientId", async () => {
|
||||
// This test will get a clientId back but Chrome won't be available in test
|
||||
// The endpoint returns 200 with clientId before starting Chrome
|
||||
const res = await request(app)
|
||||
.post("/api/lighthouse")
|
||||
.send({ url: "https://example.com" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.clientId).toBeDefined();
|
||||
expect(typeof res.body.clientId).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/lighthouse/status/:id", () => {
|
||||
it("should be a valid SSE endpoint", () => {
|
||||
// SSE endpoints keep connections open, so we just verify the route exists
|
||||
// Full SSE testing would require a dedicated SSE test client
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Health & Info Endpoints", () => {
|
||||
describe("GET /health", () => {
|
||||
it("should return status ok with timestamp", async () => {
|
||||
const res = await request(app).get("/health");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
status: "ok",
|
||||
});
|
||||
expect(res.body.timestamp).toBeDefined();
|
||||
// Timestamp should be valid ISO string
|
||||
expect(new Date(res.body.timestamp).toISOString()).toBe(res.body.timestamp);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /", () => {
|
||||
it("should return API metadata", async () => {
|
||||
const res = await request(app).get("/");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe("Website Monitoring API");
|
||||
expect(res.body.version).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
expect(Array.isArray(res.body.endpoints)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("404 handling", () => {
|
||||
it("should return 404 for unknown routes", async () => {
|
||||
const res = await request(app).get("/api/unknown");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CORS", () => {
|
||||
it("should include CORS headers", async () => {
|
||||
const res = await request(app).get("/health");
|
||||
// CORS middleware is enabled
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import cors from "cors";
|
||||
import dotenv from "dotenv";
|
||||
import lighthouseRouter from "./routes/lighthouse.js";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
// Rate limiting (simple in-memory for single instance)
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||
const RATE_LIMIT_WINDOW = 60_000; // 1 minute
|
||||
const RATE_LIMIT_MAX = 30; // requests per window
|
||||
|
||||
function rateLimit(req: Request, res: Response, next: () => void) {
|
||||
const ip = req.ip || req.headers["x-forwarded-for"] || "unknown";
|
||||
const key = String(ip);
|
||||
const now = Date.now();
|
||||
const entry = rateLimitMap.get(key);
|
||||
|
||||
if (!entry || now > entry.resetAt) {
|
||||
rateLimitMap.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
|
||||
return next();
|
||||
}
|
||||
|
||||
if (entry.count >= RATE_LIMIT_MAX) {
|
||||
res.status(429).json({ error: "Too many requests" });
|
||||
return;
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
next();
|
||||
}
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || "*" }));
|
||||
app.use(express.json());
|
||||
app.use(rateLimit);
|
||||
|
||||
app.get("/health", (_req: Request, res: Response) => {
|
||||
res.status(200).json({ status: "ok", timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
app.get("/", (_req: Request, res: Response) => {
|
||||
res.status(200).json({
|
||||
name: "Website Monitoring API",
|
||||
version: "1.0.0",
|
||||
endpoints: ["/health", "/api/lighthouse"],
|
||||
});
|
||||
});
|
||||
|
||||
app.use("/api/lighthouse", lighthouseRouter);
|
||||
|
||||
export { app };
|
||||
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
const PORT = process.env.PORT || 5000;
|
||||
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const router = express.Router();
|
||||
const progressClients = new Map<string, Response>();
|
||||
|
||||
/** Send SSE progress data to the browser */
|
||||
function sendProgress(clientId: string, data: any) {
|
||||
const client = progressClients.get(clientId);
|
||||
if (client) {
|
||||
client.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// SSE endpoint for progress
|
||||
router.get("/status/:id", (req: Request, res: Response) => {
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
|
||||
const clientId = req.params.id;
|
||||
progressClients.set(clientId, res);
|
||||
|
||||
// Send an initial event
|
||||
sendProgress(clientId, {
|
||||
status: "Connected",
|
||||
progress: 0,
|
||||
stage: "setup",
|
||||
});
|
||||
|
||||
req.on("close", () => {
|
||||
progressClients.delete(clientId);
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
const { url } = req.body;
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: "Missing URL" });
|
||||
}
|
||||
|
||||
const clientId = uuidv4(); // Generate a UUID
|
||||
console.log(`New client ID: ${clientId}`);
|
||||
let currentProgress = 0;
|
||||
let auditCount = 0;
|
||||
let totalAudits = 0;
|
||||
|
||||
try {
|
||||
// Immediately tell the frontend we’re starting (0%)
|
||||
sendProgress(clientId, {
|
||||
status: "Starting analysis...",
|
||||
progress: 0,
|
||||
stage: "setup",
|
||||
});
|
||||
|
||||
// Return clientId so the frontend can open the SSE channel
|
||||
res.status(200).json({ clientId });
|
||||
|
||||
// Dynamically import chrome-launcher and lighthouse
|
||||
const { launch } = await import("chrome-launcher");
|
||||
const lighthouse = (await import("lighthouse")).default;
|
||||
const log = (await import("lighthouse-logger")).default;
|
||||
|
||||
// Launch Chrome
|
||||
const chrome = await launch({
|
||||
chromeFlags: ["--headless", "--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
|
||||
// Update progress to 10% after launching Chrome
|
||||
currentProgress = 10;
|
||||
sendProgress(clientId, {
|
||||
status: "Chrome launched",
|
||||
progress: currentProgress,
|
||||
stage: "setup",
|
||||
});
|
||||
|
||||
// Turn on Lighthouse logs at "info" level
|
||||
log.setLevel("info");
|
||||
|
||||
// Listen for Lighthouse status events
|
||||
log.events.addListener("status", (lhStatus) => {
|
||||
let msg = lhStatus[1].toLowerCase();
|
||||
let newProgress = currentProgress;
|
||||
let newStage = "analyzing";
|
||||
|
||||
if (msg.includes("initialize config")) {
|
||||
newProgress = Math.max(newProgress, 15);
|
||||
newStage = "setup";
|
||||
} else if (msg.includes("resolve artifact definitions")) {
|
||||
newProgress = Math.max(newProgress, 20);
|
||||
newStage = "setup";
|
||||
} else if (msg.includes("gather phase")) {
|
||||
newProgress = Math.max(newProgress, 25);
|
||||
newStage = "analyzing";
|
||||
} else if (msg.includes("connecting to browser")) {
|
||||
newProgress = Math.max(newProgress, 28);
|
||||
newStage = "analyzing";
|
||||
} else if (msg.includes("navigating to")) {
|
||||
newProgress = Math.max(newProgress, 30);
|
||||
newStage = "analyzing";
|
||||
} else if (msg.includes("benchmarking machine")) {
|
||||
newProgress = Math.max(newProgress, 35);
|
||||
newStage = "analyzing";
|
||||
} else if (msg.includes("getting artifact")) {
|
||||
newProgress = Math.max(newProgress, 40);
|
||||
newStage = "analyzing";
|
||||
} else if (msg.includes("computing artifact")) {
|
||||
newProgress = Math.max(newProgress, 50);
|
||||
newStage = "analyzing";
|
||||
} else if (msg.includes("audit phase")) {
|
||||
newProgress = Math.max(newProgress, 60);
|
||||
newStage = "auditing";
|
||||
} else if (msg.includes("auditing:")) {
|
||||
auditCount++;
|
||||
totalAudits = Math.max(totalAudits, auditCount);
|
||||
newProgress = Math.max(
|
||||
newProgress,
|
||||
60 + (auditCount / totalAudits) * 30,
|
||||
);
|
||||
newStage = "auditing";
|
||||
} else if (msg.includes("generating results")) {
|
||||
newProgress = Math.max(newProgress, 95);
|
||||
newStage = "finishing";
|
||||
}
|
||||
|
||||
// Add some randomness to the progress increments
|
||||
newProgress += Math.random() * 2;
|
||||
|
||||
// Only send progress if it actually increased
|
||||
if (newProgress > currentProgress) {
|
||||
currentProgress = newProgress;
|
||||
sendProgress(clientId, {
|
||||
status: lhStatus.message || "Processing...",
|
||||
progress: currentProgress,
|
||||
stage: newStage,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Run Lighthouse
|
||||
const runnerResult = await lighthouse(url, {
|
||||
port: chrome.port,
|
||||
output: "json",
|
||||
logLevel: "info",
|
||||
onlyCategories: ["performance", "seo", "accessibility", "best-practices"],
|
||||
});
|
||||
|
||||
if (!runnerResult) {
|
||||
throw new Error("Lighthouse returned no result");
|
||||
}
|
||||
|
||||
// Finish progress
|
||||
sendProgress(clientId, {
|
||||
status: "Analysis complete",
|
||||
progress: 100,
|
||||
stage: "complete",
|
||||
report: runnerResult.lhr,
|
||||
});
|
||||
|
||||
await chrome.kill();
|
||||
} catch (error) {
|
||||
console.error("Lighthouse error:", error);
|
||||
sendProgress(clientId, {
|
||||
status: "Error occurred",
|
||||
progress: 0,
|
||||
stage: "error",
|
||||
error:
|
||||
error instanceof Error ? error.message : "An unknown error occurred",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user