Files
nyaterm/.github/scripts/web-release-smoke.mjs
Kang 29be430b6a feat(web-release): implement web release workflow and testing scripts
- Added a GitHub Actions workflow for building and releasing the web application on version tag pushes.
- Introduced Python and JavaScript scripts for release metadata generation, validation, and smoke testing of the web release image.
- Updated .gitignore to include new artifacts and Python cache directories.
- Created Docker Compose configuration for deploying the web application with environment variable management.
- Enhanced documentation for deployment instructions and usage of the official web release image.
2026-10-07 20:56:55 +08:00

114 lines
3.7 KiB
JavaScript

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const image = process.env.NYATERM_SMOKE_IMAGE;
assert(image, "NYATERM_SMOKE_IMAGE is required");
const name = "nyaterm-web-release-smoke";
const artifacts = resolve("artifacts/web-release-smoke");
mkdirSync(artifacts, { recursive: true });
const password = randomBytes(32).toString("base64");
const encryptionKey = randomBytes(32).toString("base64");
const docker = (args, optional = false) => {
const result = spawnSync("docker", args, {
encoding: "utf8",
env: {
...process.env,
NYATERM_WEB_PASSWORD: password,
NYATERM_WEB_ENCRYPTION_KEY: encryptionKey,
},
});
if (!optional && (result.error || result.status !== 0))
throw new Error(`Docker ${args[0]} failed: ${result.stderr}`);
return result;
};
const fetchWithTimeout = (url, options = {}) =>
fetch(url, { ...options, signal: AbortSignal.timeout(5000) });
try {
docker([
"run",
"-d",
"--name",
name,
"--init",
"-p",
"127.0.0.1::8080",
"-e",
"NYATERM_WEB_PASSWORD",
"-e",
"NYATERM_WEB_ENCRYPTION_KEY",
"--read-only",
"--tmpfs",
"/tmp",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges:true",
image,
]);
const binding = docker(["port", name, "8080/tcp"]).stdout.trim();
const base = `http://${binding}/`;
const deadline = Date.now() + 60_000;
let response;
while (Date.now() < deadline) {
try {
response = await fetchWithTimeout(base);
if (response.ok) break;
} catch {}
await delay(500);
}
assert(response?.ok, "Container readiness timed out");
const html = await response.text();
assert(html.includes('<div id="root">'), "Missing application HTML");
const assets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"\s]+)"/g)];
assert(assets.length > 0, "No built assets found");
for (const [, path] of assets) {
const asset = await fetchWithTimeout(new URL(path, base));
assert.equal(asset.status, 200, `Asset unavailable: ${path}`);
assert(
!asset.headers.get("content-type")?.includes("text/html"),
"Asset returned SPA fallback",
);
assert((await asset.arrayBuffer()).byteLength > 0, "Empty asset");
}
const sessionUrl = new URL("api/auth/session", base);
assert.equal((await fetchWithTimeout(sessionUrl)).status, 401);
const headers = {
Origin: new URL(base).origin,
"X-Nyaterm-Request": "1",
"Content-Type": "application/json",
};
const loginUrl = new URL("api/auth/login", base);
const rejected = await fetchWithTimeout(loginUrl, {
method: "POST",
headers,
body: JSON.stringify({ password: "invalid" }),
});
assert.equal(rejected.status, 401);
const login = await fetchWithTimeout(loginUrl, {
method: "POST",
headers,
body: JSON.stringify({ password }),
});
assert.equal(login.status, 200, "Login failed");
const { csrf } = await login.json();
assert(csrf, "Missing CSRF token");
const cookie = login.headers.get("set-cookie")?.split(";")[0];
assert(cookie, "Missing session cookie");
const session = await fetchWithTimeout(sessionUrl, {
headers: { Cookie: cookie },
});
assert.equal(session.status, 200);
assert.equal((await session.json()).csrf, csrf);
console.log("Web release image smoke checks passed.");
} finally {
const logs = docker(["logs", name], true);
writeFileSync(resolve(artifacts, "web.log"), logs.stdout + logs.stderr);
// -v removes the anonymous /data volume created by the Dockerfile.
docker(["rm", "-f", "-v", name], true);
}