penpot/plugins/apps/composable-test-suite/ci/static-server.test.ts
Andrey Antukh 5c22f5bfb7
Build the frontend bundle once for all E2E suites (#11792)
*  Build the frontend bundle once for all E2E suites

Merge tests-integration, tests-composable-suite and tests-plugin-api-suite
into one "CI: E2E" workflow. Each of the three ran its own full
frontend/scripts/build on every PR, so one PR paid the build three times.

The new build-bundle job restores actions/cache key frontend-bundle-<sha>,
runs frontend/scripts/build only on a miss and saves the key before the
job ends. The integration shards, the composable suite and the mocked
Plugin API suite now all need build-bundle and restore the same key with
fail-on-cache-miss, so none of them builds. A workflow re-run of the same
SHA reuses the cached bundle instead of rebuilding it.

Triggers become the union of the previous paths (frontend, common,
render-wasm, plugins): the bundle embeds the built plugins, so a plugins
change runs the whole set. workflow_dispatch keeps running the
integration job only, as before.

Job names are kept identical on purpose: they are the GitHub check
contexts and branch protection may match them by name.

Docs: new mem:frontend/e2e-ci-workflow records the build-once contract,
referenced from mem:frontend/core and mem:frontend/testing; the composable
memory and both suite READMEs are updated.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Fix mocked plugin suites crashing without frontend deps

The mocked CI drivers shelled out to frontend/scripts/e2e-server.js,
which imports express from frontend/node_modules. CI jobs install
only plugins/ deps, so the import failed with ERR_MODULE_NOT_FOUND
and the run timed out waiting for localhost:3000.

Serve the prebuilt bundle with a zero-dependency static server
built into each driver (ci/static-server.ts, kept in sync in both
suites) plus node:test coverage for it.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-22 10:23:15 +02:00

80 lines
3.1 KiB
TypeScript

import { strict as assert } from "node:assert";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, describe, it } from "node:test";
import { startStaticServer, type StaticServer } from "./static-server.ts";
describe("static-server", () => {
let dir: string = "";
let server: StaticServer | undefined;
const baseUrl = (): string => {
if (!server) throw new Error("static server not started");
return server.url;
};
before(async () => {
dir = await mkdtemp(join(tmpdir(), "penpot-static-server-"));
await mkdir(join(dir, "js"), { recursive: true });
await writeFile(join(dir, "index.html"), "<!doctype html><html></html>");
await writeFile(join(dir, "js", "app.js"), 'console.log("hi");');
await writeFile(join(dir, "data.bin"), Buffer.from([0, 1, 2]));
server = await startStaticServer(dir, 0);
});
after(async () => {
await server?.close();
// Closing twice must be safe (the driver closes unconditionally).
await server?.close();
await rm(dir, { recursive: true, force: true });
});
it("serves / as index.html", async () => {
const res = await fetch(`${baseUrl()}/`);
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
assert.match(await res.text(), /<!doctype html>/);
});
it("serves nested files with a javascript content type", async () => {
const res = await fetch(`${baseUrl()}/js/app.js`);
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /javascript/);
assert.equal(await res.text(), 'console.log("hi");');
});
it("ignores query strings", async () => {
const res = await fetch(`${baseUrl()}/index.html?rev=123`);
assert.equal(res.status, 200);
assert.match(await res.text(), /<!doctype html>/);
});
it("falls back to octet-stream for unknown extensions", async () => {
const res = await fetch(`${baseUrl()}/data.bin`);
assert.equal(res.status, 200);
assert.equal(res.headers.get("content-type"), "application/octet-stream");
});
it("answers HEAD without a body", async () => {
const res = await fetch(`${baseUrl()}/index.html`, { method: "HEAD" });
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
assert.equal(await res.text(), "");
});
it("rejects other methods", async () => {
const res = await fetch(`${baseUrl()}/index.html`, { method: "POST" });
assert.equal(res.status, 405);
});
it("returns 404 for missing files", async () => {
const res = await fetch(`${baseUrl()}/nope/missing.js`);
assert.equal(res.status, 404);
});
it("blocks path traversal outside the root", async () => {
const res = await fetch(`${baseUrl()}/..%2f..%2fsecret`);
assert.equal(res.status, 403);
});
});