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
This commit is contained in:
Andrey Antukh 2026-09-22 10:23:15 +02:00 committed by GitHub
parent d68531b783
commit 5c22f5bfb7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 722 additions and 238 deletions

View File

@ -1,67 +0,0 @@
name: "CI: Composable Test Suite"
# Runs the composable component test suite (it exercises component semantics
# through the real Plugin API against the full frontend, so it needs the
# frontend bundle + the plugin runtime, but no backend): the driver serves the
# prebuilt frontend bundle and intercepts every backend RPC with Playwright
# fixtures. See plugins/apps/composable-test-suite/README.md ("Running in CI").
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'plugins/**'
- 'frontend/**'
- 'common/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'plugins/**'
- 'frontend/**'
- 'common/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
composable-test-suite:
if: ${{ !github.event.pull_request.draft }}
name: "Run composable test suite (mocked backend)"
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- uses: actions/checkout@v6
# The driver serves the prebuilt bundle from frontend/resources/public.
- name: Build frontend bundle
working-directory: ./frontend
run: ./scripts/build
- name: Install deps
working-directory: ./plugins
run: |
pnpm install;
- name: Install Playwright Chromium
working-directory: ./plugins
run: pnpm --filter composable-test-suite exec playwright install --with-deps chromium
- name: Run composable test suite (mocked)
working-directory: ./plugins
run: pnpm --filter composable-test-suite run test:ci

View File

@ -1,4 +1,26 @@
name: "CI: Integration"
name: "CI: E2E"
# Single entry point for every suite that drives a real frontend bundle.
# The bundle is built ONCE by `build-bundle` and restored by every consumer,
# so adding a suite no longer adds another `frontend/scripts/build` run.
#
# Suites and what they need from the bundle:
#
# - Integration Tests: Playwright specs, backend faked with frontend mocks.
# - Composable test suite: Plugin runtime, backend faked with Playwright RPC
# fixtures. See plugins/apps/composable-test-suite/README.md ("Running in CI").
# - Plugin API Test Suite (mocked): Plugin runtime, backend faked with
# Playwright RPC fixtures and MOCK_BACKEND=1.
# See plugins/apps/plugin-api-test-suite/README.md.
#
# Hand-off: `build-bundle` restores `actions/cache` keyed by the checked-out
# SHA, builds only on a miss, and saves it; every consumer restores the same
# key after `needs: build-bundle` completes. A re-run of the same SHA reuses
# the cached bundle instead of rebuilding it.
#
# NOTE: the job `name:` values are the GitHub check contexts, so they are kept
# stable on purpose (branch protection may require them by name). Only the
# workflow file/name changed.
defaults:
run:
@ -30,12 +52,18 @@ on:
required: true
default: '2'
base_url:
description: 'Penpot base URL for the (disabled) live Plugin API suite'
required: false
default: 'https://localhost:3449'
pull_request:
paths:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
- '.github/workflows/tests-integration.yml'
- 'plugins/**'
- '.github/workflows/tests-e2e.yml'
types:
- opened
@ -51,18 +79,21 @@ on:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
- '.github/workflows/tests-integration.yml'
- 'plugins/**'
- '.github/workflows/tests-e2e.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.gh_ref || github.ref }}
cancel-in-progress: true
jobs:
build-integration:
# ── 1. Build the frontend bundle once ──────────────────────────────────
build-bundle:
if: ${{ !github.event.pull_request.draft }}
name: "Build Integration Bundle"
runs-on: penpot-extended-runner
timeout-minutes: 30
container:
image: penpotapp/devenv:latest
volumes:
@ -85,26 +116,36 @@ jobs:
- name: Extract cache key
id: vars
run: |
echo "bundle_key=integration-bundle-$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "bundle_key=frontend-bundle-$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Restore Bundle Cache
id: restore
uses: actions/cache/restore@v5
with:
key: ${{ steps.vars.outputs.bundle_key }}
path: frontend/resources/public
- name: Build Bundle
if: steps.restore.outputs.cache-hit != 'true'
working-directory: ./frontend
run: |
./scripts/build
- name: Store Bundle Cache
uses: actions/cache@v5
if: steps.restore.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
with:
key: ${{ steps.vars.outputs.bundle_key }}
path: frontend/resources/public
# ── 2. Consumers: restore the bundle, never rebuild it ─────────────────
test-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests (${{ matrix.shard }})"
runs-on: penpot-extended-runner
timeout-minutes: 40
needs: build-integration
needs: build-bundle
strategy:
fail-fast: false
@ -129,8 +170,9 @@ jobs:
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: ${{ needs.build-integration.outputs.bundle_key }}
key: ${{ needs.build-bundle.outputs.bundle_key }}
path: frontend/resources/public
fail-on-cache-miss: true
- name: Install deps
working-directory: ./frontend
@ -175,6 +217,89 @@ jobs:
if-no-files-found: ignore
retention-days: 3
composable-test-suite:
if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }}
name: "Run composable test suite (mocked backend)"
runs-on: penpot-extended-runner
timeout-minutes: 30
needs: build-bundle
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- uses: actions/checkout@v6
# The driver serves the prebuilt bundle from frontend/resources/public.
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: ${{ needs.build-bundle.outputs.bundle_key }}
path: frontend/resources/public
fail-on-cache-miss: true
- name: Install deps
working-directory: ./plugins
run: |
pnpm install;
- name: Install Playwright Chromium
working-directory: ./plugins
run: pnpm --filter composable-test-suite exec playwright install --with-deps chromium
- name: Run composable test suite (mocked)
working-directory: ./plugins
run: pnpm --filter composable-test-suite run test:ci
api-test-suite-mocked:
if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }}
name: "Run Plugin API Test Suite (mocked)"
runs-on: penpot-extended-runner
timeout-minutes: 30
needs: build-bundle
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- uses: actions/checkout@v6
# Mocked mode serves the prebuilt bundle from frontend/resources/public.
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: ${{ needs.build-bundle.outputs.bundle_key }}
path: frontend/resources/public
fail-on-cache-miss: true
- name: Install deps
working-directory: ./plugins
run: |
pnpm install;
- name: Install Playwright Chromium
working-directory: ./plugins
run: pnpm --filter plugin-api-test-suite exec playwright install --with-deps chromium
- name: Generate API surface
working-directory: ./plugins
run: pnpm --filter plugin-api-test-suite run gen:api
- name: Run API test suite (mocked)
working-directory: ./plugins
env:
MOCK_BACKEND: "1"
run: pnpm --filter plugin-api-test-suite run test:ci
# ── 3. Merge the per-shard integration reports ─────────────────────────
merge-reports:
if: ${{ !cancelled() && !github.event.pull_request.draft && needs.test-integration.result != 'skipped' }}
name: "Merge Integration Reports"
@ -241,8 +366,53 @@ jobs:
- name: Upload HTML report
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-html-report
path: frontend/playwright-report/
overwrite: true
retention-days: 7
# ── 4. Live Plugin API suite (disabled) ────────────────────────────────
#
# True end-to-end against a LIVE instance: point PENPOT_BASE_URL at a
# reachable instance and provide login credentials via repo secrets. Manual
# because the CI runner has no Docker to stand up a full stack.
#
# api-test-suite-live:
# if: ${{ github.event_name == 'workflow_dispatch' }}
# name: Run Plugin API Test Suite (live)
# runs-on: penpot-extended-runner
# container:
# image: penpotapp/devenv:latest
#
# env:
# PENPOT_BASE_URL: ${{ inputs.base_url }}
# E2E_LOGIN_EMAIL: ${{ secrets.E2E_LOGIN_EMAIL }}
# E2E_LOGIN_PASSWORD: ${{ secrets.E2E_LOGIN_PASSWORD }}
#
# steps:
# - uses: actions/checkout@v6
#
# - name: Setup Node
# uses: actions/setup-node@v6
# with:
# node-version-file: .nvmrc
#
# - name: Install deps
# working-directory: ./plugins
# run: |
# pnpm install;
#
# - name: Install Playwright Chromium
# working-directory: ./plugins
# run: pnpm --filter plugin-api-test-suite exec playwright install --with-deps chromium
#
# - name: Generate API surface
# working-directory: ./plugins
# run: pnpm --filter plugin-api-test-suite run gen:api
#
# # Note: requires a running Penpot instance reachable at PENPOT_BASE_URL.
# - name: Run API test suite
# working-directory: ./plugins
# run: pnpm --filter plugin-api-test-suite run test:ci

View File

@ -1,129 +0,0 @@
name: "CI: Plugin API Test Suite"
# Runs the Plugin API Test Suite (it exercises the real Penpot Plugin API, so it
# needs a running frontend + the plugin runtime). Two jobs:
#
# - api-test-suite-mocked (pull_request / push): the per-PR gate. Serves the
# prebuilt frontend bundle and intercepts every backend RPC with Playwright
# (MOCK_BACKEND=1). No backend / no login. Validates the frontend Plugin API
# binding + in-memory store; backend-result-dependent tests are skipped via the
# `skipIfMocked` tag. See plugins/apps/plugin-api-test-suite/README.md.
#
# - api-test-suite-live (workflow_dispatch): true end-to-end against a LIVE
# instance. Point PENPOT_BASE_URL at a reachable instance and provide login
# credentials via repo secrets. Manual because the CI runner has no Docker to
# stand up a full stack.
defaults:
run:
shell: bash
on:
workflow_dispatch:
inputs:
base_url:
description: "Penpot base URL (e.g. https://localhost:3449)"
required: false
default: "https://localhost:3449"
pull_request:
paths:
- 'plugins/**'
- 'frontend/**'
- 'common/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'plugins/**'
- 'frontend/src/app/plugins/**'
- 'common/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
api-test-suite-mocked:
if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }}
name: "Run Plugin API Test Suite (mocked)"
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- uses: actions/checkout@v6
# Mocked mode serves the prebuilt bundle from frontend/resources/public.
- name: Build frontend bundle
working-directory: ./frontend
run: ./scripts/build
- name: Install deps
working-directory: ./plugins
run: |
pnpm install;
- name: Install Playwright Chromium
working-directory: ./plugins
run: pnpm --filter plugin-api-test-suite exec playwright install --with-deps chromium
- name: Generate API surface
working-directory: ./plugins
run: pnpm --filter plugin-api-test-suite run gen:api
- name: Run API test suite (mocked)
working-directory: ./plugins
env:
MOCK_BACKEND: "1"
run: pnpm --filter plugin-api-test-suite run test:ci
## The following job will launch the whole suite of tests but we need
## to have a full environment in the CI for this to work.
# api-test-suite-live:
# if: ${{ github.event_name == 'workflow_dispatch' }}
# name: Run Plugin API Test Suite (live)
# runs-on: penpot-extended-runner
# container:
# image: penpotapp/devenv:latest
#
# env:
# PENPOT_BASE_URL: ${{ github.event.inputs.base_url }}
# E2E_LOGIN_EMAIL: ${{ secrets.E2E_LOGIN_EMAIL }}
# E2E_LOGIN_PASSWORD: ${{ secrets.E2E_LOGIN_PASSWORD }}
#
# steps:
# - uses: actions/checkout@v6
#
# - name: Setup Node
# uses: actions/setup-node@v6
# with:
# node-version-file: .nvmrc
#
# - name: Install deps
# working-directory: ./plugins
# run: |
# pnpm install;
#
# - name: Install Playwright Chromium
# working-directory: ./plugins
# run: pnpm --filter plugin-api-test-suite exec playwright install --with-deps chromium
#
# - name: Generate API surface
# working-directory: ./plugins
# run: pnpm --filter plugin-api-test-suite run gen:api
#
# # Note: requires a running Penpot instance reachable at PENPOT_BASE_URL.
# - name: Run API test suite
# working-directory: ./plugins
# run: pnpm --filter plugin-api-test-suite run test:ci

View File

@ -138,13 +138,15 @@ Distinguishing abstractions (the OOP articulation of the shared principles):
control via Playwright; recipe in the README.
## CI
Headless per-PR gate: `.github/workflows/tests-composable-suite.yml` runs
`pnpm --filter composable-test-suite run test:ci` — mocked backend (frontend e2e static server +
Headless per-PR gate: the `composable-test-suite` job in
`.github/workflows/tests-e2e.yml` runs `pnpm --filter composable-test-suite run test:ci` — mocked
backend (frontend e2e static server +
Playwright RPC fixtures, no backend/login), the in-sandbox bundle injected via `ɵloadPlugin`,
results streamed via console markers, `TEST_FILTER` by identifier substring. The mocked backend is
NOT a limitation for this suite (everything asserted is frontend store logic; empirically
confirmed against the interactive runs). Architecture mirrors `plugin-api-test-suite`'s CI driver;
the mock harness exists in THREE places that must stay in sync (provenance note in `ci/run-ci.ts`).
The frontend bundle is built once per SHA and restored by this job (`mem:frontend/e2e-ci-workflow`).
Details: README, "Running in CI".
## Substrate

View File

@ -53,6 +53,7 @@ Diagnostics and validation:
- Source-edit compile/hot-reload diagnostics: `mem:frontend/compile-diagnostics`.
- Runtime crash recovery: `mem:frontend/handling-crashes`.
- Tests and live verification: `mem:frontend/testing`.
- CI end-to-end workflow (build-once frontend bundle, check names): `mem:frontend/e2e-ci-workflow`.
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
- Real pointer/keyboard gesture reproduction: `mem:frontend/playwright-gestures`.

View File

@ -0,0 +1,43 @@
# E2E CI workflow (build-once frontend bundle)
`.github/workflows/tests-e2e.yml` ("CI: E2E") is the single workflow for every
suite that drives a real frontend bundle:
- `Integration Tests` — Playwright specs under `frontend/playwright` (sharded).
- `Run composable test suite (mocked backend)``mem:frontend/composable-component-tests`.
- `Run Plugin API Test Suite (mocked)``plugins/apps/plugin-api-test-suite`.
Triggers: PR/push touching `frontend/**`, `common/**`, `render-wasm/**`,
`plugins/**` (or the workflow file), plus `workflow_dispatch` (integration only).
A `plugins/**` change runs the whole set on purpose: the bundle embeds the
built plugins.
## Invariants
- ONE `frontend/scripts/build` per SHA. The `build-bundle` job restores
`actions/cache` key `frontend-bundle-<sha>`, builds only on a miss, and saves
the key before the job ends. A re-run of the same SHA reuses the cache.
- Consumer jobs (`needs: build-bundle`) restore the same key with
`fail-on-cache-miss: true` and NEVER run `frontend/scripts/build`.
- The bundle is `frontend/resources/public`. The integration specs serve it
with `frontend/scripts/e2e-server.js`; each mocked plugin driver serves it
with its own zero-dependency `ci/static-server.ts` (duplicated in both
suites — keep the copies in sync).
- Mocked plugin jobs install only `plugins/` deps, so their drivers must not
import anything from `frontend/node_modules` at runtime (e.g. no
`frontend/scripts/e2e-server.js`, which needs `express`).
- Cache key comes from `git rev-parse HEAD` (the checked-out ref), not
`github.sha`, because `workflow_dispatch` can target a different ref.
- Job `name:` values are the GitHub check contexts. Keep them stable: branch
protection may match them by name. Renaming the workflow file/name is safe.
## Adding a bundle-consuming suite
Add a job with `needs: build-bundle`, a `Restore Cache` step
(`actions/cache/restore@v5`, key `needs.build-bundle.outputs.bundle_key`,
`fail-on-cache-miss: true`), then that suite's own deps. Never add a build step.
## Scope
Distinct from `Bundles Builder` (`.github/workflows/build-bundle.yml`), the
release path that zips the bundle (`manage.sh build-bundle`) and uploads it to S3.

View File

@ -31,6 +31,10 @@ Integration tests fake backend behavior by intercepting network/websocket traffi
Locator priority should follow user-facing semantics: `getByRole`, `getByLabel`, `getByPlaceholder`, `getByText`, then semantic alternatives such as alt/title, with `getByTestId` as the last resort. Name tests from the user's perspective and prefer positive, single-purpose assertions.
## CI (E2E)
`.github/workflows/tests-e2e.yml` runs the integration specs, the composable component suite, and the mocked Plugin API suite from one workflow that builds the frontend bundle once per SHA. Before adding a job that needs the bundle, read `mem:frontend/e2e-ci-workflow` (build-once contract, cache key, stable check names).
## Live browser verification
Because CLJC compiles to both JVM and CLJS, JVM/common tests can miss frontend-only state caused by browser runtime, WASM modifier math, or real pointer events. Use `mem:frontend/cljs-repl` to inspect live app state and `mem:frontend/playwright-gestures` when real input is needed.

View File

@ -172,7 +172,9 @@ pnpm --filter composable-test-suite run test:ci
This builds the in-sandbox entry (`src/ci/headless.ts`) as a single
self-executing bundle and hands it to the driver (`ci/run-ci.ts`), which
serves the prebuilt frontend bundle via the frontend e2e static server,
serves the prebuilt frontend bundle with a zero-dependency static server
built into the driver (`ci/static-server.ts`, same bundle on the same port —
no `frontend/` install needed),
intercepts every backend RPC with Playwright fixtures (no backend, no login),
opens the mocked workspace file, injects the bundle directly into the plugin
sandbox, and streams each test's result from the page console — failing the
@ -182,9 +184,10 @@ backend's only role is persistence, which the mock answers with a canned
response.
Prerequisites: the frontend bundle must exist at `frontend/resources/public`
(the devenv watch build suffices; CI builds it via `frontend/scripts/build`),
and the Playwright browser must be installed
(the devenv watch build suffices), and the Playwright browser must be installed
(`pnpm --filter composable-test-suite exec playwright install chromium`).
In CI the shared E2E workflow (`.github/workflows/tests-e2e.yml`) builds that
bundle once per commit and this job restores it; do not add a build step.
Options via environment variables:

View File

@ -1,8 +1,8 @@
import { spawn, type ChildProcess } from "node:child_process";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium, type Page } from "playwright";
import { startStaticServer, type StaticServer } from "./static-server.ts";
// Out-of-sandbox CI driver (Node + Playwright) for the composable test suite,
// following the plugin-api-test-suite's CI driver. NOTE on provenance: the mock
@ -11,7 +11,8 @@ import { chromium, type Page } from "playwright";
// (frontend/playwright, the origin), the plugin-api-test-suite driver, and this
// file. If workspace loading changes and this driver times out waiting for the
// viewport, diff against those two first. It serves the prebuilt
// frontend bundle via the frontend e2e static server, intercepts every backend
// frontend bundle with the zero-dependency static server in
// `ci/static-server.ts`, intercepts every backend
// RPC with Playwright `page.route` (reusing the frontend e2e mock fixtures),
// injects the prebuilt `headless.js` bundle into the plugin sandbox via
// `globalThis.ɵloadPlugin`, and captures the results from the page console.
@ -30,6 +31,7 @@ const here = dirname(fileURLToPath(import.meta.url));
// here = <root>/plugins/apps/composable-test-suite/ci
const repoRoot = resolve(here, "../../../../");
const frontendDir = resolve(repoRoot, "frontend");
const staticRoot = resolve(frontendDir, "resources/public");
const e2eDataDir = resolve(frontendDir, "playwright/data");
const BASE_URL = "http://localhost:3000";
@ -96,14 +98,15 @@ async function waitForServer(url: string, timeoutMs = 30000): Promise<void> {
}
}
function startE2eServer(): ChildProcess {
// Reuse the frontend e2e static server: it serves frontend/resources/public
// on port 3000, which is also the host the app opens its notifications
function startE2eServer(): Promise<StaticServer> {
// Serve the prebuilt frontend bundle from `frontend/resources/public` on
// port 3000, which is also the host the app opens its notifications
// WebSocket against — so the WS mock below matches without extra config.
return spawn("node", ["scripts/e2e-server.js"], {
cwd: frontendDir,
stdio: "inherit",
});
// This used to shell out to the express-based
// `frontend/scripts/e2e-server.js`, but that resolves `express` from
// `frontend/node_modules`, which the CI jobs never install (only
// `plugins/` deps), so the driver crashed before serving anything.
return startStaticServer(staticRoot, 3000);
}
// Install the frontend e2e WebSocket mock so the workspace's notifications
@ -204,7 +207,7 @@ function printReport(results: ReportedResult[]) {
async function main() {
const bundle = readFileSync(headlessBundlePath, "utf-8");
const server = startE2eServer();
const server = await startE2eServer();
await waitForServer(BASE_URL);
const browser = await chromium.launch();
@ -280,7 +283,7 @@ async function main() {
]);
await browser.close();
server.kill();
await server.close();
printReport(results);

View File

@ -0,0 +1,79 @@
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);
});
});

View File

@ -0,0 +1,141 @@
import { createServer, type Server } from "node:http";
import { readFile, stat } from "node:fs/promises";
import { extname, join, resolve, sep } from "node:path";
// Zero-dependency static file server for the mocked-backend CI driver.
//
// It replaces `frontend/scripts/e2e-server.js` (express-based) on purpose:
// that script resolves `express`/`compression` from `frontend/node_modules`,
// which the CI jobs never install (they only run `pnpm install` inside
// `plugins/` and restore the prebuilt bundle), so the driver crashed with
// ERR_MODULE_NOT_FOUND and timed out waiting for localhost:3000. Serving the
// bundle from here keeps the suite runnable with only `plugins/`
// dependencies — the documented local workflow — and identical in CI.
//
// NOTE on provenance: this file is duplicated in
// `plugins/apps/plugin-api-test-suite/ci/static-server.ts`. Keep the two in
// sync (same as the mock harness in `run-ci.ts`).
const MIME_TYPES: Record<string, string> = {
".css": "text/css; charset=utf-8",
".gif": "image/gif",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".otf": "font/otf",
".png": "image/png",
".svg": "image/svg+xml",
".ttf": "font/ttf",
".txt": "text/plain; charset=utf-8",
".wasm": "application/wasm",
".webmanifest": "application/manifest+json",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
".xml": "application/xml; charset=utf-8",
};
const INDEX = "index.html";
export interface StaticServer {
/** Base URL the server listens on (e.g. `http://localhost:3000`). */
url: string;
/** Stop accepting connections; safe to call more than once. */
close: () => Promise<void>;
}
/**
* Serve `root` over HTTP on `port` (`0` picks a free port, reported in
* `url`). Directory requests fall back to `index.html`; the app uses hash
* routing, so no other fallback is needed.
*/
export function startStaticServer(root: string, port: number): Promise<StaticServer> {
const docRoot = resolve(root);
const server: Server = createServer(async (req, res) => {
try {
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(405, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Method Not Allowed");
return;
}
const rawPath = (req.url ?? "/").split(/[?#]/, 1)[0] ?? "/";
let pathname: string;
try {
pathname = decodeURIComponent(rawPath);
} catch {
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Bad Request");
return;
}
const resolved = resolve(docRoot, `.${sep}${pathname}`);
if (resolved !== docRoot && !resolved.startsWith(docRoot + sep)) {
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Forbidden");
return;
}
let filePath = resolved;
const info = await stat(filePath).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
});
if (info === null) {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not Found");
return;
}
if (info.isDirectory()) {
filePath = join(filePath, INDEX);
}
const body = await readFile(filePath).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
});
if (body === null) {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not Found");
return;
}
const contentType = MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
res.writeHead(200, {
"Content-Type": contentType,
"Content-Length": body.length,
});
res.end(req.method === "GET" ? body : undefined);
} catch {
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
}
res.end("Internal Server Error");
}
});
return new Promise((fulfill, reject) => {
server.once("error", reject);
server.listen(port, "0.0.0.0", () => {
server.off("error", reject);
const address = server.address();
const actualPort = typeof address === "object" && address !== null ? address.port : port;
fulfill({
url: `http://localhost:${actualPort}`,
close: () =>
new Promise<void>((done, fail) => {
if (!server.listening) {
done();
return;
}
server.close((error) => (error ? fail(error) : done()));
}),
});
});
});
}

View File

@ -9,6 +9,7 @@
"build": "tsc && vite build",
"build:headless": "vite build --config vite.config.headless.ts",
"test:ci": "pnpm run build:headless && tsx ci/run-ci.ts",
"test:unit": "tsx --test ci/static-server.test.ts",
"preview": "vite preview",
"bootstrap": "pnpm install --ignore-workspace && pnpm run build && pnpm run start",
"types:check": "tsc --noEmit",

View File

@ -75,7 +75,9 @@ them the same way the plugin does).
### Mocked-backend mode
The same runner can run without a live instance — it serves the prebuilt
frontend via the frontend e2e static server and intercepts every backend RPC
frontend with a zero-dependency static server built into the driver
(`ci/static-server.ts`, same bundle on the same port 3000 — no `frontend/`
install needed) and intercepts every backend RPC
with Playwright `page.route`, reusing the frontend e2e mock fixtures:
```
@ -83,6 +85,9 @@ pnpm --filter plugin-api-test-suite run test:ci:mocked
```
(equivalently `MOCK_BACKEND=1 … run test:ci`). No login or backend is needed.
This is the per-PR CI gate: the `Run Plugin API Test Suite (mocked)` job in
`.github/workflows/tests-e2e.yml` restores the frontend bundle that the shared
workflow builds once per commit (never build it in the job).
This validates the frontend Plugin API binding + in-memory store only, so it
can't faithfully reproduce results that depend on real backend behaviour
(validation, persistence, generated ids, …). Tests that need the real backend

View File

@ -1,9 +1,9 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium, type Page } from 'playwright';
import type { CoverageReport, TestResult } from '../src/framework/types';
import { startStaticServer, type StaticServer } from './static-server.ts';
// Out-of-sandbox CI driver (Node + Playwright). Injects the prebuilt
// `headless.js` bundle (built from the in-sandbox entry `src/ci/headless.ts` —
@ -19,8 +19,9 @@ import type { CoverageReport, TestResult } from '../src/framework/types';
// the given substring (case-insensitive).
// Optional env: RENDER_WASM — force the workspace renderer (`true`/`false`).
//
// - MOCKED (`MOCK_BACKEND=1`): serves the prebuilt frontend bundle via the e2e
// static server and intercepts every backend RPC with Playwright `page.route`,
// - MOCKED (`MOCK_BACKEND=1`): serves the prebuilt frontend bundle with the
// zero-dependency static server in `ci/static-server.ts` and intercepts
// every backend RPC with Playwright `page.route`,
// reusing the frontend e2e mock fixtures. No backend/login needed. Validates
// the frontend Plugin API binding + in-memory store only; results that depend
// on real backend behaviour are not faithfully reproduced, so those tests are
@ -30,6 +31,7 @@ const here = dirname(fileURLToPath(import.meta.url));
// here = <root>/plugins/apps/plugin-api-test-suite/ci
const repoRoot = resolve(here, '../../../../');
const frontendDir = resolve(repoRoot, 'frontend');
const staticRoot = resolve(frontendDir, 'resources/public');
const e2eDataDir = resolve(frontendDir, 'playwright/data');
const MOCKED = !!process.env['MOCK_BACKEND'];
@ -179,16 +181,15 @@ async function waitForServer(url: string, timeoutMs = 30000): Promise<void> {
}
}
function startE2eServer(): ChildProcess {
// Reuse the frontend e2e static server: it serves frontend/resources/public
// on port 3000, which is also the host the app opens its notifications
// WebSocket against (ws://localhost:3000/ws/notifications) — so the WS mock
// below matches without extra config.
const child = spawn('node', ['scripts/e2e-server.js'], {
cwd: frontendDir,
stdio: 'inherit',
});
return child;
function startE2eServer(): Promise<StaticServer> {
// Serve the prebuilt bundle from `frontend/resources/public` on port 3000,
// which is also the host the app opens its notifications WebSocket against
// (ws://localhost:3000/ws/notifications) — so the WS mock below matches
// without extra config. This used to shell out to the express-based
// `frontend/scripts/e2e-server.js`, but that resolves `express` from
// `frontend/node_modules`, which the CI jobs never install (only
// `plugins/` deps), so the driver crashed before serving anything.
return startStaticServer(staticRoot, 3000);
}
// Install the frontend e2e WebSocket mock so the workspace's notifications
@ -337,12 +338,12 @@ function printReport(
async function main() {
const bundle = readFileSync(headlessBundlePath, 'utf-8');
let server: ChildProcess | undefined;
let server: StaticServer | undefined;
let fileUrl: string;
let authToken: string | undefined;
if (MOCKED) {
server = startE2eServer();
server = await startE2eServer();
await waitForServer(MOCK_BASE_URL);
fileUrl = mockedFileUrl();
} else {
@ -463,7 +464,7 @@ async function main() {
]);
await browser.close();
server?.kill();
await server?.close();
printReport(results, coverage, skipped);

View File

@ -0,0 +1,79 @@
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);
});
});

View File

@ -0,0 +1,147 @@
import { createServer, type Server } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, resolve, sep } from 'node:path';
// Zero-dependency static file server for the mocked-backend CI driver.
//
// It replaces `frontend/scripts/e2e-server.js` (express-based) on purpose:
// that script resolves `express`/`compression` from `frontend/node_modules`,
// which the CI jobs never install (they only run `pnpm install` inside
// `plugins/` and restore the prebuilt bundle), so the driver crashed with
// ERR_MODULE_NOT_FOUND and timed out waiting for localhost:3000. Serving the
// bundle from here keeps the suite runnable with only `plugins/`
// dependencies — the documented local workflow — and identical in CI.
//
// NOTE on provenance: this file is duplicated in
// `plugins/apps/composable-test-suite/ci/static-server.ts`. Keep the two in
// sync (same as the mock harness in `run-ci.ts`).
const MIME_TYPES: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.mjs': 'application/javascript; charset=utf-8',
'.otf': 'font/otf',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ttf': 'font/ttf',
'.txt': 'text/plain; charset=utf-8',
'.wasm': 'application/wasm',
'.webmanifest': 'application/manifest+json',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.xml': 'application/xml; charset=utf-8',
};
const INDEX = 'index.html';
export interface StaticServer {
/** Base URL the server listens on (e.g. `http://localhost:3000`). */
url: string;
/** Stop accepting connections; safe to call more than once. */
close: () => Promise<void>;
}
/**
* Serve `root` over HTTP on `port` (`0` picks a free port, reported in
* `url`). Directory requests fall back to `index.html`; the app uses hash
* routing, so no other fallback is needed.
*/
export function startStaticServer(
root: string,
port: number,
): Promise<StaticServer> {
const docRoot = resolve(root);
const server: Server = createServer(async (req, res) => {
try {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Method Not Allowed');
return;
}
const rawPath = (req.url ?? '/').split(/[?#]/, 1)[0] ?? '/';
let pathname: string;
try {
pathname = decodeURIComponent(rawPath);
} catch {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Bad Request');
return;
}
const resolved = resolve(docRoot, `.${sep}${pathname}`);
if (resolved !== docRoot && !resolved.startsWith(docRoot + sep)) {
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Forbidden');
return;
}
let filePath = resolved;
const info = await stat(filePath).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
});
if (info === null) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not Found');
return;
}
if (info.isDirectory()) {
filePath = join(filePath, INDEX);
}
const body = await readFile(filePath).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
});
if (body === null) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not Found');
return;
}
const contentType =
MIME_TYPES[extname(filePath).toLowerCase()] ??
'application/octet-stream';
res.writeHead(200, {
'Content-Type': contentType,
'Content-Length': body.length,
});
res.end(req.method === 'GET' ? body : undefined);
} catch {
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
}
res.end('Internal Server Error');
}
});
return new Promise((fulfill, reject) => {
server.once('error', reject);
server.listen(port, '0.0.0.0', () => {
server.off('error', reject);
const address = server.address();
const actualPort =
typeof address === 'object' && address !== null ? address.port : port;
fulfill({
url: `http://localhost:${actualPort}`,
close: () =>
new Promise<void>((done, fail) => {
if (!server.listening) {
done();
return;
}
server.close((error) => (error ? fail(error) : done()));
}),
});
});
});
}

View File

@ -14,7 +14,8 @@
"lint": "eslint .",
"gen:api": "tsx tools/gen-api-surface.ts",
"test:ci": "pnpm run build:headless && tsx ci/run-ci.ts",
"test:ci:mocked": "pnpm run build:headless && MOCK_BACKEND=1 tsx ci/run-ci.ts"
"test:ci:mocked": "pnpm run build:headless && MOCK_BACKEND=1 tsx ci/run-ci.ts",
"test:unit": "tsx --test ci/static-server.test.ts"
},
"devDependencies": {
"playwright": "^1.62.1"