mirror of
https://github.com/penpot/penpot.git
synced 2026-09-25 05:16:14 +00:00
⚡ 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:
parent
d68531b783
commit
5c22f5bfb7
67
.github/workflows/tests-composable-suite.yml
vendored
67
.github/workflows/tests-composable-suite.yml
vendored
@ -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
|
|
||||||
@ -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:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@ -30,12 +52,18 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
default: '2'
|
default: '2'
|
||||||
|
|
||||||
|
base_url:
|
||||||
|
description: 'Penpot base URL for the (disabled) live Plugin API suite'
|
||||||
|
required: false
|
||||||
|
default: 'https://localhost:3449'
|
||||||
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'frontend/**'
|
- 'frontend/**'
|
||||||
- 'common/**'
|
- 'common/**'
|
||||||
- 'render-wasm/**'
|
- 'render-wasm/**'
|
||||||
- '.github/workflows/tests-integration.yml'
|
- 'plugins/**'
|
||||||
|
- '.github/workflows/tests-e2e.yml'
|
||||||
|
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
@ -51,18 +79,21 @@ on:
|
|||||||
- 'frontend/**'
|
- 'frontend/**'
|
||||||
- 'common/**'
|
- 'common/**'
|
||||||
- 'render-wasm/**'
|
- 'render-wasm/**'
|
||||||
- '.github/workflows/tests-integration.yml'
|
- 'plugins/**'
|
||||||
|
- '.github/workflows/tests-e2e.yml'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.gh_ref || github.ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.gh_ref || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-integration:
|
# ── 1. Build the frontend bundle once ──────────────────────────────────
|
||||||
|
build-bundle:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Build Integration Bundle"
|
name: "Build Integration Bundle"
|
||||||
runs-on: penpot-extended-runner
|
runs-on: penpot-extended-runner
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: penpotapp/devenv:latest
|
image: penpotapp/devenv:latest
|
||||||
volumes:
|
volumes:
|
||||||
@ -85,26 +116,36 @@ jobs:
|
|||||||
- name: Extract cache key
|
- name: Extract cache key
|
||||||
id: vars
|
id: vars
|
||||||
run: |
|
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
|
- name: Build Bundle
|
||||||
|
if: steps.restore.outputs.cache-hit != 'true'
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: |
|
run: |
|
||||||
./scripts/build
|
./scripts/build
|
||||||
|
|
||||||
- name: Store Bundle Cache
|
- name: Store Bundle Cache
|
||||||
uses: actions/cache@v5
|
if: steps.restore.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v5
|
||||||
with:
|
with:
|
||||||
key: ${{ steps.vars.outputs.bundle_key }}
|
key: ${{ steps.vars.outputs.bundle_key }}
|
||||||
path: frontend/resources/public
|
path: frontend/resources/public
|
||||||
|
|
||||||
|
# ── 2. Consumers: restore the bundle, never rebuild it ─────────────────
|
||||||
test-integration:
|
test-integration:
|
||||||
if: ${{ !github.event.pull_request.draft }}
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
name: "Integration Tests (${{ matrix.shard }})"
|
name: "Integration Tests (${{ matrix.shard }})"
|
||||||
runs-on: penpot-extended-runner
|
runs-on: penpot-extended-runner
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
|
|
||||||
needs: build-integration
|
needs: build-bundle
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@ -129,8 +170,9 @@ jobs:
|
|||||||
- name: Restore Cache
|
- name: Restore Cache
|
||||||
uses: actions/cache/restore@v5
|
uses: actions/cache/restore@v5
|
||||||
with:
|
with:
|
||||||
key: ${{ needs.build-integration.outputs.bundle_key }}
|
key: ${{ needs.build-bundle.outputs.bundle_key }}
|
||||||
path: frontend/resources/public
|
path: frontend/resources/public
|
||||||
|
fail-on-cache-miss: true
|
||||||
|
|
||||||
- name: Install deps
|
- name: Install deps
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
@ -175,6 +217,89 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 3
|
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:
|
merge-reports:
|
||||||
if: ${{ !cancelled() && !github.event.pull_request.draft && needs.test-integration.result != 'skipped' }}
|
if: ${{ !cancelled() && !github.event.pull_request.draft && needs.test-integration.result != 'skipped' }}
|
||||||
name: "Merge Integration Reports"
|
name: "Merge Integration Reports"
|
||||||
@ -241,8 +366,53 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload HTML report
|
- name: Upload HTML report
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: integration-html-report
|
name: integration-html-report
|
||||||
path: frontend/playwright-report/
|
path: frontend/playwright-report/
|
||||||
overwrite: true
|
overwrite: true
|
||||||
retention-days: 7
|
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
|
||||||
129
.github/workflows/tests-plugin-api-suite.yml
vendored
129
.github/workflows/tests-plugin-api-suite.yml
vendored
@ -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
|
|
||||||
@ -138,13 +138,15 @@ Distinguishing abstractions (the OOP articulation of the shared principles):
|
|||||||
control via Playwright; recipe in the README.
|
control via Playwright; recipe in the README.
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
Headless per-PR gate: `.github/workflows/tests-composable-suite.yml` runs
|
Headless per-PR gate: the `composable-test-suite` job in
|
||||||
`pnpm --filter composable-test-suite run test:ci` — mocked backend (frontend e2e static server +
|
`.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`,
|
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
|
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
|
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;
|
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 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".
|
Details: README, "Running in CI".
|
||||||
|
|
||||||
## Substrate
|
## Substrate
|
||||||
|
|||||||
@ -53,6 +53,7 @@ Diagnostics and validation:
|
|||||||
- Source-edit compile/hot-reload diagnostics: `mem:frontend/compile-diagnostics`.
|
- Source-edit compile/hot-reload diagnostics: `mem:frontend/compile-diagnostics`.
|
||||||
- Runtime crash recovery: `mem:frontend/handling-crashes`.
|
- Runtime crash recovery: `mem:frontend/handling-crashes`.
|
||||||
- Tests and live verification: `mem:frontend/testing`.
|
- 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`.
|
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
|
||||||
- Real pointer/keyboard gesture reproduction: `mem:frontend/playwright-gestures`.
|
- Real pointer/keyboard gesture reproduction: `mem:frontend/playwright-gestures`.
|
||||||
|
|
||||||
|
|||||||
43
.serena/memories/frontend/e2e-ci-workflow.md
Normal file
43
.serena/memories/frontend/e2e-ci-workflow.md
Normal 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.
|
||||||
@ -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.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@ -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
|
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
|
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),
|
intercepts every backend RPC with Playwright fixtures (no backend, no login),
|
||||||
opens the mocked workspace file, injects the bundle directly into the plugin
|
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
|
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.
|
response.
|
||||||
|
|
||||||
Prerequisites: the frontend bundle must exist at `frontend/resources/public`
|
Prerequisites: the frontend bundle must exist at `frontend/resources/public`
|
||||||
(the devenv watch build suffices; CI builds it via `frontend/scripts/build`),
|
(the devenv watch build suffices), and the Playwright browser must be installed
|
||||||
and the Playwright browser must be installed
|
|
||||||
(`pnpm --filter composable-test-suite exec playwright install chromium`).
|
(`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:
|
Options via environment variables:
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { spawn, type ChildProcess } from "node:child_process";
|
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { chromium, type Page } from "playwright";
|
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,
|
// 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
|
// 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
|
// (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
|
// file. If workspace loading changes and this driver times out waiting for the
|
||||||
// viewport, diff against those two first. It serves the prebuilt
|
// 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),
|
// RPC with Playwright `page.route` (reusing the frontend e2e mock fixtures),
|
||||||
// injects the prebuilt `headless.js` bundle into the plugin sandbox via
|
// injects the prebuilt `headless.js` bundle into the plugin sandbox via
|
||||||
// `globalThis.ɵloadPlugin`, and captures the results from the page console.
|
// `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
|
// here = <root>/plugins/apps/composable-test-suite/ci
|
||||||
const repoRoot = resolve(here, "../../../../");
|
const repoRoot = resolve(here, "../../../../");
|
||||||
const frontendDir = resolve(repoRoot, "frontend");
|
const frontendDir = resolve(repoRoot, "frontend");
|
||||||
|
const staticRoot = resolve(frontendDir, "resources/public");
|
||||||
const e2eDataDir = resolve(frontendDir, "playwright/data");
|
const e2eDataDir = resolve(frontendDir, "playwright/data");
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:3000";
|
const BASE_URL = "http://localhost:3000";
|
||||||
@ -96,14 +98,15 @@ async function waitForServer(url: string, timeoutMs = 30000): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startE2eServer(): ChildProcess {
|
function startE2eServer(): Promise<StaticServer> {
|
||||||
// Reuse the frontend e2e static server: it serves frontend/resources/public
|
// Serve the prebuilt frontend bundle from `frontend/resources/public` on
|
||||||
// on port 3000, which is also the host the app opens its notifications
|
// port 3000, which is also the host the app opens its notifications
|
||||||
// WebSocket against — so the WS mock below matches without extra config.
|
// WebSocket against — so the WS mock below matches without extra config.
|
||||||
return spawn("node", ["scripts/e2e-server.js"], {
|
// This used to shell out to the express-based
|
||||||
cwd: frontendDir,
|
// `frontend/scripts/e2e-server.js`, but that resolves `express` from
|
||||||
stdio: "inherit",
|
// `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
|
// Install the frontend e2e WebSocket mock so the workspace's notifications
|
||||||
@ -204,7 +207,7 @@ function printReport(results: ReportedResult[]) {
|
|||||||
async function main() {
|
async function main() {
|
||||||
const bundle = readFileSync(headlessBundlePath, "utf-8");
|
const bundle = readFileSync(headlessBundlePath, "utf-8");
|
||||||
|
|
||||||
const server = startE2eServer();
|
const server = await startE2eServer();
|
||||||
await waitForServer(BASE_URL);
|
await waitForServer(BASE_URL);
|
||||||
|
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
@ -280,7 +283,7 @@ async function main() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
server.kill();
|
await server.close();
|
||||||
|
|
||||||
printReport(results);
|
printReport(results);
|
||||||
|
|
||||||
|
|||||||
79
plugins/apps/composable-test-suite/ci/static-server.test.ts
Normal file
79
plugins/apps/composable-test-suite/ci/static-server.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
141
plugins/apps/composable-test-suite/ci/static-server.ts
Normal file
141
plugins/apps/composable-test-suite/ci/static-server.ts
Normal 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()));
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@
|
|||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"build:headless": "vite build --config vite.config.headless.ts",
|
"build:headless": "vite build --config vite.config.headless.ts",
|
||||||
"test:ci": "pnpm run build:headless && tsx ci/run-ci.ts",
|
"test:ci": "pnpm run build:headless && tsx ci/run-ci.ts",
|
||||||
|
"test:unit": "tsx --test ci/static-server.test.ts",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"bootstrap": "pnpm install --ignore-workspace && pnpm run build && pnpm run start",
|
"bootstrap": "pnpm install --ignore-workspace && pnpm run build && pnpm run start",
|
||||||
"types:check": "tsc --noEmit",
|
"types:check": "tsc --noEmit",
|
||||||
|
|||||||
@ -75,7 +75,9 @@ them the same way the plugin does).
|
|||||||
### Mocked-backend mode
|
### Mocked-backend mode
|
||||||
|
|
||||||
The same runner can run without a live instance — it serves the prebuilt
|
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:
|
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.
|
(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
|
This validates the frontend Plugin API binding + in-memory store only, so it
|
||||||
can't faithfully reproduce results that depend on real backend behaviour
|
can't faithfully reproduce results that depend on real backend behaviour
|
||||||
(validation, persistence, generated ids, …). Tests that need the real backend
|
(validation, persistence, generated ids, …). Tests that need the real backend
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { spawn, type ChildProcess } from 'node:child_process';
|
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { chromium, type Page } from 'playwright';
|
import { chromium, type Page } from 'playwright';
|
||||||
import type { CoverageReport, TestResult } from '../src/framework/types';
|
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
|
// Out-of-sandbox CI driver (Node + Playwright). Injects the prebuilt
|
||||||
// `headless.js` bundle (built from the in-sandbox entry `src/ci/headless.ts` —
|
// `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).
|
// the given substring (case-insensitive).
|
||||||
// Optional env: RENDER_WASM — force the workspace renderer (`true`/`false`).
|
// Optional env: RENDER_WASM — force the workspace renderer (`true`/`false`).
|
||||||
//
|
//
|
||||||
// - MOCKED (`MOCK_BACKEND=1`): serves the prebuilt frontend bundle via the e2e
|
// - MOCKED (`MOCK_BACKEND=1`): serves the prebuilt frontend bundle with the
|
||||||
// static server and intercepts every backend RPC with Playwright `page.route`,
|
// 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
|
// reusing the frontend e2e mock fixtures. No backend/login needed. Validates
|
||||||
// the frontend Plugin API binding + in-memory store only; results that depend
|
// the frontend Plugin API binding + in-memory store only; results that depend
|
||||||
// on real backend behaviour are not faithfully reproduced, so those tests are
|
// 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
|
// here = <root>/plugins/apps/plugin-api-test-suite/ci
|
||||||
const repoRoot = resolve(here, '../../../../');
|
const repoRoot = resolve(here, '../../../../');
|
||||||
const frontendDir = resolve(repoRoot, 'frontend');
|
const frontendDir = resolve(repoRoot, 'frontend');
|
||||||
|
const staticRoot = resolve(frontendDir, 'resources/public');
|
||||||
const e2eDataDir = resolve(frontendDir, 'playwright/data');
|
const e2eDataDir = resolve(frontendDir, 'playwright/data');
|
||||||
|
|
||||||
const MOCKED = !!process.env['MOCK_BACKEND'];
|
const MOCKED = !!process.env['MOCK_BACKEND'];
|
||||||
@ -179,16 +181,15 @@ async function waitForServer(url: string, timeoutMs = 30000): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startE2eServer(): ChildProcess {
|
function startE2eServer(): Promise<StaticServer> {
|
||||||
// Reuse the frontend e2e static server: it serves frontend/resources/public
|
// Serve the prebuilt bundle from `frontend/resources/public` on port 3000,
|
||||||
// on port 3000, which is also the host the app opens its notifications
|
// which is also the host the app opens its notifications WebSocket against
|
||||||
// WebSocket against (ws://localhost:3000/ws/notifications) — so the WS mock
|
// (ws://localhost:3000/ws/notifications) — so the WS mock below matches
|
||||||
// below matches without extra config.
|
// without extra config. This used to shell out to the express-based
|
||||||
const child = spawn('node', ['scripts/e2e-server.js'], {
|
// `frontend/scripts/e2e-server.js`, but that resolves `express` from
|
||||||
cwd: frontendDir,
|
// `frontend/node_modules`, which the CI jobs never install (only
|
||||||
stdio: 'inherit',
|
// `plugins/` deps), so the driver crashed before serving anything.
|
||||||
});
|
return startStaticServer(staticRoot, 3000);
|
||||||
return child;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install the frontend e2e WebSocket mock so the workspace's notifications
|
// Install the frontend e2e WebSocket mock so the workspace's notifications
|
||||||
@ -337,12 +338,12 @@ function printReport(
|
|||||||
async function main() {
|
async function main() {
|
||||||
const bundle = readFileSync(headlessBundlePath, 'utf-8');
|
const bundle = readFileSync(headlessBundlePath, 'utf-8');
|
||||||
|
|
||||||
let server: ChildProcess | undefined;
|
let server: StaticServer | undefined;
|
||||||
let fileUrl: string;
|
let fileUrl: string;
|
||||||
let authToken: string | undefined;
|
let authToken: string | undefined;
|
||||||
|
|
||||||
if (MOCKED) {
|
if (MOCKED) {
|
||||||
server = startE2eServer();
|
server = await startE2eServer();
|
||||||
await waitForServer(MOCK_BASE_URL);
|
await waitForServer(MOCK_BASE_URL);
|
||||||
fileUrl = mockedFileUrl();
|
fileUrl = mockedFileUrl();
|
||||||
} else {
|
} else {
|
||||||
@ -463,7 +464,7 @@ async function main() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
server?.kill();
|
await server?.close();
|
||||||
|
|
||||||
printReport(results, coverage, skipped);
|
printReport(results, coverage, skipped);
|
||||||
|
|
||||||
|
|||||||
79
plugins/apps/plugin-api-test-suite/ci/static-server.test.ts
Normal file
79
plugins/apps/plugin-api-test-suite/ci/static-server.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
147
plugins/apps/plugin-api-test-suite/ci/static-server.ts
Normal file
147
plugins/apps/plugin-api-test-suite/ci/static-server.ts
Normal 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()));
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -14,7 +14,8 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"gen:api": "tsx tools/gen-api-surface.ts",
|
"gen:api": "tsx tools/gen-api-surface.ts",
|
||||||
"test:ci": "pnpm run build:headless && tsx ci/run-ci.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": {
|
"devDependencies": {
|
||||||
"playwright": "^1.62.1"
|
"playwright": "^1.62.1"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user