e2e #52
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # SPDX-License-Identifier: Apache-2.0 | |
| # | |
| # e2e.yml - the REAL headless-browser end-to-end gate for pyspark-connect-web. | |
| # | |
| # This is the one thing that cannot be verified in the maintainer's local sandbox | |
| # (no network, no docker there). GitHub-hosted ubuntu-latest runners have both, so | |
| # this workflow exercises the entire browser layer that unit/lint CI cannot: | |
| # | |
| # * SAB/Atomics blocking bridge (needs crossOriginIsolated === true) | |
| # * JupyterLite Pyodide kernel (real Pyodide >=0.28 / Python 3.13 in Chromium) | |
| # * real Envoy grpc-web proxy (grpc-web <-> gRPC translation, long streams) | |
| # * real Spark 4.1.2 Connect server (ExecutePlan/ReattachExecute/ReleaseExecute) | |
| # * real Chromium (Playwright) | |
| # | |
| # It asserts the v0 matrix matrix in a real browser, with | |
| # E2E_REQUIRE_STACK=1 so the checklist items HARD-FAIL (not skip) in CI: | |
| # 1. crossOriginIsolated === true | |
| # 2. spark.range(10).collect() == 10 rows | |
| # 3. filter/select/groupBy/agg toPandas == native reference | |
| # 4. createDataFrame(pandas_df) round-trips | |
| # 5. spark.sql("select 1 as x").collect() works | |
| # 6. mid-stream disconnect recovers via ReattachExecute | |
| # | |
| # It also drives the embedded BI query cell demo (demo/index.html) through its UI | |
| # - seed the retail dataset, list tables, DESCRIBE, run SQL, render the grid - | |
| # proving the "embed pyspark-connect-web as a query cell" use case end-to-end | |
| # (tests/e2e/demo.spec.ts; the page is staged into the site at /demo/ below). | |
| # | |
| # This is a SEPARATE workflow from ci.yml (unit/lint/grpcio-guard/headers/wheel); | |
| # ci.yml stays the cheap, always-on gate. This one is heavier (docker + browser) | |
| # and is expected to be flaky on its FIRST real runs - see the candid notes at the | |
| # bottom of the project notes. | |
| name: e2e | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| # Allow manual runs while the browser layer is being stabilised. | |
| workflow_dispatch: | |
| # One in-flight e2e run per ref; a newer push cancels the older run (saves runner | |
| # minutes - this job is expensive). Does NOT touch ci.yml's concurrency. | |
| concurrency: | |
| group: e2e-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| jobs: | |
| e2e: | |
| name: headless-browser e2e | |
| runs-on: ubuntu-latest | |
| # Cold Spark Connect boot (Ivy --packages download ~1 min) + chromium + | |
| # Pyodide cold start, which reruns per test (each test reloads the harness), | |
| # all add up; give it room but cap it so a hung stream cannot burn an hour. | |
| timeout-minutes: 45 | |
| env: | |
| PCW_OUTPUT_DIR: _output | |
| # The standalone harness page boots the worker + binds `spark` + exposes | |
| # window.__pcwRunPython (no JupyterLite app/kernel needed). Served at the | |
| # site root by build_site.sh. | |
| E2E_BASE_URL: http://localhost:8000/harness.html | |
| E2E_SPARK_REMOTE: sc://localhost:8081/;transport=grpcweb | |
| # Native reference client talks plain gRPC straight to Spark Connect. | |
| E2E_REFERENCE_REMOTE: sc://localhost:15002 | |
| E2E_REFERENCE: ${{ github.workspace }}/tests/e2e/reference.json | |
| # HARD-FAIL the checklist instead of skipping - this is the whole point. | |
| E2E_REQUIRE_STACK: "1" | |
| # The embedded BI query cell demo (demo/index.html), staged into the built | |
| # site at /demo/ below and driven end-to-end by tests/e2e/demo.spec.ts. | |
| E2E_DEMO_URL: http://localhost:8000/demo/ | |
| steps: | |
| # ------------------------------------------------------------------- | |
| # 0. toolchain | |
| # ------------------------------------------------------------------- | |
| - uses: actions/checkout@v4 | |
| - name: Set up Python 3.11 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Set up Java 17 (Temurin) | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: "17" | |
| - name: Set up Node 20 | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: "20" | |
| - name: Tool versions (debug) | |
| run: | | |
| set -euxo pipefail | |
| python --version | |
| java -version | |
| node --version | |
| docker --version | |
| docker compose version | |
| # ------------------------------------------------------------------- | |
| # 1. build the wheel + JupyterLite site into _output | |
| # (reuses scripts/build_site.sh - same path a maintainer runs locally) | |
| # ------------------------------------------------------------------- | |
| - name: Install build tooling (pinned) | |
| run: | | |
| set -euxo pipefail | |
| python -m pip install --upgrade pip | |
| # jupyterlite 0.7.x kernel uses MODULE workers (0.6.1 used a CLASSIC | |
| # worker that Pyodide rejects). Pins MUST match scripts/build_site.sh; | |
| # build_site.sh vendors the Pyodide this kernel expects (0.29.3). | |
| python -m pip install \ | |
| "build==1.2.2" \ | |
| "jupyterlite-core==0.7.6" \ | |
| "jupyterlite-pyodide-kernel==0.7.2" \ | |
| "jupyter-server>=2,<3" | |
| # jupyter-server is required by jupyterlite's `contents` addon to add | |
| # custom content (the demo notebook via `jupyter lite build --contents`). | |
| python -m pip install -e ".[dev]" | |
| # Reference generator (tests/e2e/reference.py) uses the NATIVE PySpark | |
| # Connect client to produce ground-truth results -> it needs real | |
| # grpcio + grpcio-status. That is fine here: reference.py lives under | |
| # tests/, outside the package; only forbids grpcio | |
| # *inside* pyspark_connect_web/. | |
| # | |
| # The reference generator is a NATIVE gRPC client (real grpcio), so it | |
| # uses full pyspark - identical Connect-client code to the browser's | |
| # pyspark-client, and it avoids a pyspark/pyspark-client namespace clash | |
| # with .[dev] above. Pin to EXACTLY the server image (apache/spark:4.1.2): | |
| # a mismatched client reads configs the server lacks (e.g. SPARK-53525's | |
| # spark.sql.session.localRelationChunkSizeRows) -> SQL_CONF_NOT_FOUND. | |
| python -m pip install "pyspark==4.1.2" grpcio grpcio-status | |
| - name: Build wheel + JupyterLite site (make site) | |
| run: make site | |
| - name: Build + host the pyspark-client wheel for the browser | |
| run: | | |
| set -euxo pipefail | |
| # The browser loads the SLIM Spark Connect client (`pyspark-client`, | |
| # pure-Python, no JVM/py4j) - not full pyspark. micropip needs a WHEEL; | |
| # build one (--no-deps: its grpcio/grpcio-status base deps have no | |
| # Pyodide wheel and are shimmed at runtime) and host it same-origin. The | |
| # worker installs it with deps=False to match. | |
| pip wheel "pyspark-client==4.1.2" --no-deps -w "${PCW_OUTPUT_DIR}/" | |
| built="$(ls "${PCW_OUTPUT_DIR}"/pyspark_client-4.1.2-*.whl | head -1)" | |
| # Normalize to the fixed name the worker references statically. | |
| want="${PCW_OUTPUT_DIR}/pyspark_client-4.1.2-py3-none-any.whl" | |
| if [ "$built" != "$want" ]; then | |
| mv "$built" "$want" | |
| fi | |
| test -f "$want" || { echo "::error::pyspark-client wheel not built"; exit 1; } | |
| # Pyodide is vendored same-origin into _output/pyodide/ by build_site.sh | |
| # (`make site` above), so the worker + the JupyterLite kernel both load it | |
| # from /pyodide/ rather than a cross-origin CDN (blocked under COEP). | |
| - name: Verify _output contains the COOP/COEP _headers and the wheel | |
| run: | | |
| set -euxo pipefail | |
| test -d "${PCW_OUTPUT_DIR}" || { echo "::error::${PCW_OUTPUT_DIR} not built"; exit 1; } | |
| ls -la "${PCW_OUTPUT_DIR}" | |
| # COOP/COEP _headers - without these the browser is | |
| # not cross-origin isolated and SharedArrayBuffer is unavailable. | |
| test -f "${PCW_OUTPUT_DIR}/_headers" || { echo "::error::_headers missing from ${PCW_OUTPUT_DIR}"; exit 1; } | |
| grep -q 'Cross-Origin-Opener-Policy: same-origin' "${PCW_OUTPUT_DIR}/_headers" \ | |
| || { echo "::error::COOP missing from ${PCW_OUTPUT_DIR}/_headers"; exit 1; } | |
| grep -q 'Cross-Origin-Embedder-Policy: credentialless' "${PCW_OUTPUT_DIR}/_headers" \ | |
| || { echo "::error::COEP missing from ${PCW_OUTPUT_DIR}/_headers"; exit 1; } | |
| # The wheel micropip installs in the browser. | |
| ls "${PCW_OUTPUT_DIR}"/pyspark_connect_web-*.whl >/dev/null 2>&1 \ | |
| || { echo "::error::wheel not copied into ${PCW_OUTPUT_DIR}"; exit 1; } | |
| # The JupyterLite app itself must have built (index.html at the root). | |
| test -f "${PCW_OUTPUT_DIR}/index.html" \ | |
| || echo "::warning::no index.html at ${PCW_OUTPUT_DIR} root; jupyter lite may emit it under a subpath - the static host serves the dir verbatim" | |
| - name: Stage the embedded BI demo into the site (/demo/) | |
| run: | | |
| set -euxo pipefail | |
| # The demo page references the site-root-absolute /worker/, /pyodide/ and | |
| # *.whl assets `make site` produced, so it only needs to live under the | |
| # same (cross-origin-isolated) origin. Lay it in at /demo/ for the static | |
| # host to serve. tests/e2e/demo.spec.ts then drives it via E2E_DEMO_URL. | |
| mkdir -p "${PCW_OUTPUT_DIR}/demo" | |
| cp demo/index.html "${PCW_OUTPUT_DIR}/demo/index.html" | |
| test -f "${PCW_OUTPUT_DIR}/demo/index.html" \ | |
| || { echo "::error::BI demo not staged into ${PCW_OUTPUT_DIR}/demo"; exit 1; } | |
| # ------------------------------------------------------------------- | |
| # 2. bring up the real stack (Spark Connect + Envoy grpc-web + static) | |
| # ------------------------------------------------------------------- | |
| - name: docker compose up (real stack) | |
| run: | | |
| set -euxo pipefail | |
| # spark-connect has a TCP healthcheck and envoy depends_on it | |
| # (service_healthy), so --wait blocks until Spark Connect is up. | |
| # static + envoy use scratch images (no shell/curl) so they cannot | |
| # carry a CMD healthcheck; we host-poll them in the next step. | |
| docker compose -f deploy/compose.yaml up -d --wait || { | |
| echo "::error::docker compose up --wait failed (Spark Connect never became healthy)"; | |
| docker compose -f deploy/compose.yaml ps || true; | |
| docker compose -f deploy/compose.yaml logs --no-color || true; | |
| exit 1; | |
| } | |
| docker compose -f deploy/compose.yaml ps | |
| - name: Wait for stack health (static page + Envoy admin + Connect gRPC) | |
| run: | | |
| set -uo pipefail | |
| fail() { | |
| echo "::error::$1" | |
| echo "===== docker compose ps =====" | |
| docker compose -f deploy/compose.yaml ps || true | |
| echo "===== docker compose logs =====" | |
| docker compose -f deploy/compose.yaml logs --no-color || true | |
| exit 1 | |
| } | |
| # 2a. Spark Connect raw gRPC port (native reference + Envoy upstream). | |
| echo "waiting for Spark Connect gRPC on :15002 ..." | |
| for i in $(seq 1 60); do | |
| if (exec 3<>/dev/tcp/127.0.0.1/15002) 2>/dev/null; then | |
| echo " Spark Connect :15002 accepting TCP (after ${i}s)"; break | |
| fi | |
| [ "$i" -eq 60 ] && fail "Spark Connect :15002 never opened" | |
| sleep 1 | |
| done | |
| # 2b. Envoy admin /ready - 200 only when Envoy's listeners are warmed | |
| # and the spark-connect cluster is reachable. | |
| echo "waiting for Envoy admin /ready on :9901 ..." | |
| for i in $(seq 1 60); do | |
| if curl -fsS -o /dev/null "http://localhost:9901/ready"; then | |
| echo " Envoy /ready (after ${i}s)"; break | |
| fi | |
| [ "$i" -eq 60 ] && fail "Envoy admin /ready never returned 200" | |
| sleep 1 | |
| done | |
| # 2c. Static JupyterLite page through Envoy :8000 (the page the browser | |
| # loads) AND that the COOP/COEP headers survive the proxy. | |
| echo "waiting for JupyterLite static page on :8000 ..." | |
| for i in $(seq 1 60); do | |
| if curl -fsS -o /dev/null "http://localhost:8000/"; then | |
| echo " static page :8000 serving (after ${i}s)"; break | |
| fi | |
| [ "$i" -eq 60 ] && fail "JupyterLite static page :8000 never served 2xx" | |
| sleep 1 | |
| done | |
| echo "===== COOP/COEP headers on :8000 (must be present for SAB) =====" | |
| curl -sI "http://localhost:8000/" | grep -i 'cross-origin' || \ | |
| fail "COOP/COEP headers absent on :8000 - crossOriginIsolated would be false" | |
| # 2d. Envoy answers the grpc-web CORS preflight on :8081 (the endpoint | |
| # the in-browser client posts ExecutePlan to). | |
| echo "checking grpc-web CORS preflight on :8081 ..." | |
| curl -fsS -o /dev/null -X OPTIONS \ | |
| "http://localhost:8081/spark.connect.SparkConnectService/ExecutePlan" \ | |
| -H "Origin: http://localhost:8000" \ | |
| -H "Access-Control-Request-Method: POST" \ | |
| -H "Access-Control-Request-Headers: x-grpc-web,content-type" \ | |
| || fail "Envoy did not answer the grpc-web CORS preflight on :8081" | |
| echo "stack healthy." | |
| # ------------------------------------------------------------------- | |
| # 3. ground-truth reference from the NATIVE Spark Connect client | |
| # ------------------------------------------------------------------- | |
| - name: Generate native reference (tests/e2e/reference.py) | |
| run: | | |
| set -euxo pipefail | |
| python tests/e2e/reference.py \ | |
| --remote "${E2E_REFERENCE_REMOTE}" \ | |
| --out "${E2E_REFERENCE}" | |
| echo "===== reference.json =====" | |
| cat "${E2E_REFERENCE}" | |
| - name: Smoke-test the examples against the live Spark Connect server | |
| run: | | |
| set -euxo pipefail | |
| # examples/*.py are plain PySpark Connect scripts (the same code that | |
| # runs in the browser, minus pcw.install()). Run each against the | |
| # compose stack's raw gRPC endpoint with the NATIVE client already | |
| # installed above, to prove every example executes end-to-end. Each | |
| # script reads $SPARK_REMOTE (default sc://localhost:15002). | |
| export SPARK_REMOTE="sc://localhost:15002" | |
| for ex in examples/*.py; do | |
| echo "::group::run ${ex}" | |
| python "${ex}" | |
| echo "::endgroup::" | |
| done | |
| echo "all examples ran successfully against Spark Connect" | |
| # ------------------------------------------------------------------- | |
| # 4. install Playwright + chromium and run the e2e (HARD-FAIL mode) | |
| # ------------------------------------------------------------------- | |
| - name: Install Playwright deps + chromium | |
| working-directory: tests/e2e | |
| run: | | |
| set -euxo pipefail | |
| npm install | |
| npx playwright install --with-deps chromium | |
| - name: Run e2e (E2E_REQUIRE_STACK=1 - enforce the v0 matrix) | |
| working-directory: tests/e2e | |
| env: | |
| CI: "true" | |
| run: | | |
| set -euxo pipefail | |
| # Emit BOTH the GitHub annotations reporter (inline failures) and the | |
| # HTML report (uploaded as an artifact for offline debugging). The | |
| # config's default reporter is "github" under CI; override on the CLI | |
| # so we also get the HTML report dir. | |
| npx playwright test --reporter=github,html | |
| # ------------------------------------------------------------------- | |
| # 5. ALWAYS: collect rich debug artifacts, then tear down | |
| # ------------------------------------------------------------------- | |
| - name: Dump docker compose logs | |
| if: always() | |
| run: | | |
| mkdir -p "${{ github.workspace }}/artifacts-e2e" | |
| docker compose -f deploy/compose.yaml ps \ | |
| > "${{ github.workspace }}/artifacts-e2e/compose-ps.txt" 2>&1 || true | |
| docker compose -f deploy/compose.yaml logs --no-color --timestamps \ | |
| > "${{ github.workspace }}/artifacts-e2e/compose-logs.txt" 2>&1 || true | |
| # Per-service logs split out for easier reading. | |
| for svc in spark-connect envoy static; do | |
| docker compose -f deploy/compose.yaml logs --no-color --timestamps "$svc" \ | |
| > "${{ github.workspace }}/artifacts-e2e/${svc}.log" 2>&1 || true | |
| done | |
| # Envoy stats/clusters snapshot (why a route/cluster was unhealthy). | |
| curl -fsS "http://localhost:9901/clusters" \ | |
| > "${{ github.workspace }}/artifacts-e2e/envoy-clusters.txt" 2>&1 || true | |
| curl -fsS "http://localhost:9901/stats" \ | |
| > "${{ github.workspace }}/artifacts-e2e/envoy-stats.txt" 2>&1 || true | |
| - name: Upload Playwright HTML report | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: playwright-report | |
| path: tests/e2e/playwright-report | |
| if-no-files-found: warn | |
| retention-days: 14 | |
| - name: Upload Playwright traces/screenshots (test-results) | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: playwright-test-results | |
| path: tests/e2e/test-results | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| # The recorded BI demo session (tests/e2e/demo.spec.ts) - a REAL run against | |
| # the live stack. Downloaded and converted to the README/docs demo GIF. | |
| - name: Upload BI demo recording | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: demo-video | |
| path: tests/e2e/demo-video | |
| if-no-files-found: warn | |
| retention-days: 14 | |
| - name: Upload docker compose logs + Envoy snapshots + reference.json | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: stack-debug | |
| path: | | |
| artifacts-e2e/** | |
| tests/e2e/reference.json | |
| if-no-files-found: warn | |
| retention-days: 14 | |
| - name: docker compose down | |
| if: always() | |
| run: docker compose -f deploy/compose.yaml down -v || true |