playwright-archive
Version:
A lightweight CLI tool to archive and serve your Playwright test run history with a web interface. Useful for CI environments and local development.
219 lines (176 loc) • 7.04 kB
JavaScript
const express = require("express");
const path = require("path");
const fs = require("fs-extra");
const loadUserConfig = require("../utils/userConfig");
const JsonReportParser = require("../utils/jsonReportParser");
const http = require("http");
const { spawn } = require("child_process");
const WebSocket = require("ws");
const os = require("os");
async function serve() {
const app = express();
// Middleware for handling JSON in request body - must be before routes
app.use(express.json());
const userConfig = loadUserConfig();
const port = userConfig?.server?.port || 3000;
const host = userConfig?.server?.host || "localhost";
const packagePath = path.resolve(__dirname, "..");
const runHistoryDir = path.resolve("./run-history");
const playwrightReportDir = path.resolve("./playwright-report");
const frontendDir = path.join(packagePath, "frontend", "dist");
app.use("/run-history", express.static(runHistoryDir));
app.use("/playwright-report", express.static(playwrightReportDir));
app.use("/", express.static(frontendDir));
app.get("/api/runs", async (req, res) => {
try {
const runs = fs
.readdirSync(runHistoryDir)
.filter((d) => fs.statSync(path.join(runHistoryDir, d)).isDirectory());
const data = await Promise.all(runs.map(async (run) => {
const runPath = path.join(runHistoryDir, run);
const jsonParser = new JsonReportParser(runPath);
const reportIndex = `/run-history/${run}/report/index.html`;
const metadata = await jsonParser.getData(userConfig) || {};
return {
run,
report: reportIndex,
metadata,
};
}));
res.json(data);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Endpoint for the latest run from playwright-report folder
app.get("/api/last-run", async (req, res) => {
try {
const playwrightReportDir = path.resolve("./playwright-report");
if (!fs.existsSync(playwrightReportDir)) {
return res.status(404).json({ error: "No playwright report found" });
}
const jsonParser = new JsonReportParser(playwrightReportDir);
const metadata = await jsonParser.getData(userConfig) || {};
const reportIndex = `/playwright-report/index.html`;
res.json({
run: new Date().toISOString(), // current time as this is the latest run
report: reportIndex,
metadata,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get("/api/disk-space", async (req, res) => {
try {
const isWin = os.platform() === "win32";
const command = isWin
? 'powershell -command "(Get-PSDrive C).Free, (Get-PSDrive C).Used + (Get-PSDrive C).Free"'
: 'df -k / --output=size,avail';
const { stdout, stderr } = await new Promise((resolve, reject) => {
const process = spawn(isWin ? 'cmd.exe' : 'sh', [isWin ? '/c' : '-c', command], {
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
process.stdout.on('data', data => stdout += data.toString());
process.stderr.on('data', data => stderr += data.toString());
process.on('close', code => {
if (code !== 0) {
reject(new Error(`Command failed with code ${code}: ${stderr}`));
} else {
resolve({ stdout, stderr });
}
});
});
if (isWin) {
const [free, total] = stdout.trim().split('\n').map(v => parseInt(v));
res.json({
free,
total,
freeGb: Math.round(free / 1024 / 1024 / 1024 * 100) / 100,
totalGb: Math.round(total / 1024 / 1024 / 1024 * 100) / 100
});
} else {
const [, size, avail] = stdout.trim().match(/(\d+)\s+(\d+)/);
const total = parseInt(size) * 1024;
const free = parseInt(avail) * 1024;
res.json({
free,
total,
freeGb: Math.round(free / 1024 / 1024 / 1024 * 100) / 100,
totalGb: Math.round(total / 1024 / 1024 / 1024 * 100) / 100
});
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post("/api/run-tests", (req, res) => {
const testProcess = spawn("npx", ["playwright", "test"], {
stdio: "pipe",
shell: true
});
let output = "";
testProcess.stdout.on("data", (data) => {
output += data.toString();
});
testProcess.stderr.on("data", (data) => {
output += data.toString();
});
testProcess.on("close", async (code) => {
console.log(`Test process finished with code ${code}`);
try {
// Copy metadata to run directory if tests were successful
if (code === 0) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const runDir = path.join(runHistoryDir, timestamp);
// Create run directory if it doesn't exist
await fs.ensureDir(runDir);
// Copy test results to run directory
const testResultsPath = path.resolve("test-results", "pw-archive.json");
if (await fs.pathExists(testResultsPath)) {
await fs.copy(testResultsPath, path.join(runDir, "metadata.json"));
}
}
} catch (error) {
console.error("Failed to save run metadata:", error);
}
res.json({
success: code === 0,
output,
code
});
});
});
app.delete("/api/runs/:runId", async (req, res) => {
const { runId } = req.params;
const runDir = path.join(runHistoryDir, runId);
try {
if (await fs.pathExists(runDir)) {
await fs.remove(runDir);
res.json({ success: true });
} else {
res.status(404).json({ error: "Run not found" });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
const server = http.createServer(app);
const wss = new WebSocket.Server({ server, path: "/ws-terminal" });
wss.on("connection", (ws) => {
const isWin = process.platform === "win32";
const shellCmd = isWin ? "cmd.exe" : "sh";
const shell = spawn(shellCmd, [], { stdio: "pipe" });
shell.stdout.on("data", (data) => ws.send(data.toString()));
shell.stderr.on("data", (data) => ws.send(data.toString()));
ws.on("message", (msg) => shell.stdin.write(msg));
ws.on("close", () => shell.kill());
});
server.listen(port, host, () => {
console.log(`✅ Archive is running on http://${host}:${port}`);
console.log(`🖥️ Terminal available at ws://${host}:${port}/ws-terminal`);
});
}
module.exports = serve;