diff --git a/.github/workflows/tests-composable-suite.yml b/.github/workflows/tests-composable-suite.yml deleted file mode 100644 index 2ebea5ab59..0000000000 --- a/.github/workflows/tests-composable-suite.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/tests-integration.yml b/.github/workflows/tests-e2e.yml similarity index 50% rename from .github/workflows/tests-integration.yml rename to .github/workflows/tests-e2e.yml index 59aa8182f4..a70008b4e4 100644 --- a/.github/workflows/tests-integration.yml +++ b/.github/workflows/tests-e2e.yml @@ -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 diff --git a/.github/workflows/tests-plugin-api-suite.yml b/.github/workflows/tests-plugin-api-suite.yml deleted file mode 100644 index 0bc070ae30..0000000000 --- a/.github/workflows/tests-plugin-api-suite.yml +++ /dev/null @@ -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 diff --git a/.serena/memories/frontend/composable-component-tests.md b/.serena/memories/frontend/composable-component-tests.md index 865a381133..6618203d88 100644 --- a/.serena/memories/frontend/composable-component-tests.md +++ b/.serena/memories/frontend/composable-component-tests.md @@ -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 diff --git a/.serena/memories/frontend/core.md b/.serena/memories/frontend/core.md index 1233aaaf31..bd9b6c9276 100644 --- a/.serena/memories/frontend/core.md +++ b/.serena/memories/frontend/core.md @@ -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`. diff --git a/.serena/memories/frontend/e2e-ci-workflow.md b/.serena/memories/frontend/e2e-ci-workflow.md new file mode 100644 index 0000000000..ed94f51488 --- /dev/null +++ b/.serena/memories/frontend/e2e-ci-workflow.md @@ -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-`, 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. diff --git a/.serena/memories/frontend/testing.md b/.serena/memories/frontend/testing.md index c390be9755..1065d48659 100644 --- a/.serena/memories/frontend/testing.md +++ b/.serena/memories/frontend/testing.md @@ -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. diff --git a/plugins/apps/composable-test-suite/README.md b/plugins/apps/composable-test-suite/README.md index f324a490ee..aaacd9797f 100644 --- a/plugins/apps/composable-test-suite/README.md +++ b/plugins/apps/composable-test-suite/README.md @@ -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: diff --git a/plugins/apps/composable-test-suite/ci/run-ci.ts b/plugins/apps/composable-test-suite/ci/run-ci.ts index 7f5820ccd1..d9403c6793 100644 --- a/plugins/apps/composable-test-suite/ci/run-ci.ts +++ b/plugins/apps/composable-test-suite/ci/run-ci.ts @@ -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 = /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 { } } -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 { + // 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); diff --git a/plugins/apps/composable-test-suite/ci/static-server.test.ts b/plugins/apps/composable-test-suite/ci/static-server.test.ts new file mode 100644 index 0000000000..3818f72e40 --- /dev/null +++ b/plugins/apps/composable-test-suite/ci/static-server.test.ts @@ -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"), ""); + 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(), //); + }); + + 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(), //); + }); + + 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); + }); +}); diff --git a/plugins/apps/composable-test-suite/ci/static-server.ts b/plugins/apps/composable-test-suite/ci/static-server.ts new file mode 100644 index 0000000000..294eef9a35 --- /dev/null +++ b/plugins/apps/composable-test-suite/ci/static-server.ts @@ -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 = { + ".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; +} + +/** + * 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 { + 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((done, fail) => { + if (!server.listening) { + done(); + return; + } + server.close((error) => (error ? fail(error) : done())); + }), + }); + }); + }); +} diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json index f2a776ec8f..dbdc016c54 100644 --- a/plugins/apps/composable-test-suite/package.json +++ b/plugins/apps/composable-test-suite/package.json @@ -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", diff --git a/plugins/apps/plugin-api-test-suite/README.md b/plugins/apps/plugin-api-test-suite/README.md index 4de9a3adfb..af01f7e4ca 100644 --- a/plugins/apps/plugin-api-test-suite/README.md +++ b/plugins/apps/plugin-api-test-suite/README.md @@ -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 diff --git a/plugins/apps/plugin-api-test-suite/ci/run-ci.ts b/plugins/apps/plugin-api-test-suite/ci/run-ci.ts index 37f865ac0d..801d62779d 100644 --- a/plugins/apps/plugin-api-test-suite/ci/run-ci.ts +++ b/plugins/apps/plugin-api-test-suite/ci/run-ci.ts @@ -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 = /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 { } } -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 { + // 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); diff --git a/plugins/apps/plugin-api-test-suite/ci/static-server.test.ts b/plugins/apps/plugin-api-test-suite/ci/static-server.test.ts new file mode 100644 index 0000000000..b85bd8dc75 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/ci/static-server.test.ts @@ -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'), ''); + 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(), //); + }); + + 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(), //); + }); + + 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); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/ci/static-server.ts b/plugins/apps/plugin-api-test-suite/ci/static-server.ts new file mode 100644 index 0000000000..25ddee5e1e --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/ci/static-server.ts @@ -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 = { + '.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; +} + +/** + * 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 { + 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((done, fail) => { + if (!server.listening) { + done(); + return; + } + server.close((error) => (error ? fail(error) : done())); + }), + }); + }); + }); +} diff --git a/plugins/apps/plugin-api-test-suite/package.json b/plugins/apps/plugin-api-test-suite/package.json index 92eeee54fe..e49ce3dcf1 100644 --- a/plugins/apps/plugin-api-test-suite/package.json +++ b/plugins/apps/plugin-api-test-suite/package.json @@ -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"