diff --git a/.github/workflows/pr-verify.yml b/.github/workflows/pr-verify.yml index 470825c93e..322c9d08c7 100644 --- a/.github/workflows/pr-verify.yml +++ b/.github/workflows/pr-verify.yml @@ -142,8 +142,17 @@ jobs: e2e: + name: e2e (${{ matrix.runtime.label }}) needs: formatting-and-quick-compile runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + runtime: + - label: Spring Boot + argument: spring-boot + - label: Docker Tomcat + argument: docker-tomcat steps: - uses: actions/checkout@v4 - name: Register JVM thread dump on cancel @@ -160,9 +169,9 @@ jobs: uses: actions/setup-node@v3 with: node-version: 18 - - name: Run end-to-end tests of RDF4J Server and Workbench + - name: Run end-to-end tests of RDF4J Server and Workbench (${{ matrix.runtime.label }}) working-directory: ./e2e - run: exec ../scripts/ci/run-with-thread-dump.sh ./run.sh + run: exec ../scripts/ci/run-with-thread-dump.sh ./run.sh ${{ matrix.runtime.argument }} frontend-unit-tests: needs: formatting-and-quick-compile diff --git a/e2e/README.md b/e2e/README.md index f36654202b..781aa3c75e 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,6 +1,6 @@ # End-to-end tests -This directory contains end-to-end tests for the project. The suite now boots the RDF4J Server and Workbench using a Spring Boot wrapper with an embedded Tomcat instance, so Docker is no longer required. +This directory contains end-to-end tests for the project. The suite can run against either the Spring Boot wrapper with embedded Tomcat or the Docker image that deploys the regular WAR files to Tomcat. The tests are written using Microsoft Playwright and interact with the server and workbench in a real browser. @@ -11,7 +11,24 @@ Requirements: - maven - npm - npx + - docker (for `docker-tomcat`) -The tests can be run using the `run.sh` script. The script builds the Spring Boot runner, launches it in the background, waits until the HTTP endpoints are reachable, and then executes the Playwright test suite. +The tests can be run using the `run.sh` script. The script builds the selected runtime, waits until the HTTP endpoints are reachable, and then executes the Playwright test suite. + +Run against the Spring Boot implementation: + +```bash +./run.sh spring-boot +``` + +Run against the Docker/Tomcat image: + +```bash +./run.sh docker-tomcat +``` + +The default runtime is `spring-boot`, so `./run.sh` keeps the original local behavior. + +If Playwright browsers are already installed locally, set `E2E_SKIP_PLAYWRIGHT_INSTALL=true` to skip the browser installer. To run the tests interactively use `npx playwright test --ui` diff --git a/e2e/run.sh b/e2e/run.sh index a25107756f..9058091fc6 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -10,25 +10,29 @@ # SPDX-License-Identifier: BSD-3-Clause # -set -e +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +E2E_DIR="${ROOT_DIR}/e2e" +DOCKER_DIR="${ROOT_DIR}/docker" +SERVER_RUNTIME="${1:-${E2E_SERVER_RUNTIME:-spring-boot}}" SERVER_PID="" +DOCKER_STARTED="false" +SPRING_BOOT_DATA_DIR="" -cleanup() { +stop_spring_boot() { if [ -z "${SERVER_PID:-}" ]; then return fi - # If the process is already gone, nothing to do if ! kill -0 "$SERVER_PID" 2>/dev/null; then return fi - echo "Sending SIGINT to server-boot module (pid=$SERVER_PID)" + echo "Sending SIGINT to server-boot module (pid=${SERVER_PID})" kill -s INT "$SERVER_PID" 2>/dev/null || true - # Wait for graceful shutdown after SIGINT - for i in 1 2 3 4 5 6 7 8 9 10; do + for _ in 1 2 3 4 5 6 7 8 9 10; do if ! kill -0 "$SERVER_PID" 2>/dev/null; then echo "server-boot module stopped gracefully after SIGINT" wait "$SERVER_PID" 2>/dev/null || true @@ -38,12 +42,10 @@ cleanup() { sleep 0.5 done - # Still alive: send a more aggressive TERM - echo "Sending SIGTERM to server-boot module (pid=$SERVER_PID)" + echo "Sending SIGTERM to server-boot module (pid=${SERVER_PID})" kill "$SERVER_PID" 2>/dev/null || true - # Wait for graceful shutdown after SIGTERM - for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do if ! kill -0 "$SERVER_PID" 2>/dev/null; then echo "server-boot module stopped after SIGTERM" wait "$SERVER_PID" 2>/dev/null || true @@ -52,47 +54,145 @@ cleanup() { sleep 0.5 done - # Still alive after: kill definitively - echo "Sending SIGKILL to server-boot module (pid=$SERVER_PID)" + echo "Sending SIGKILL to server-boot module (pid=${SERVER_PID})" kill -9 "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true } -trap cleanup EXIT +stop_docker_tomcat() { + if [ "${DOCKER_STARTED}" != "true" ]; then + return + fi + + echo "Stopping Docker/Tomcat RDF4J stack" + (cd "$DOCKER_DIR" && APP_SERVER=tomcat docker compose down -v) || true +} + +cleanup() { + local status="${1:-$?}" + trap - EXIT INT TERM + stop_spring_boot + stop_docker_tomcat + exit "$status" +} + +usage() { + echo "Usage: $0 [spring-boot|docker-tomcat]" >&2 +} + +install_e2e_dependencies() { + cd "$E2E_DIR" + + if [ ! -d "node_modules" ]; then + echo "Installing E2E npm dependencies" + npm ci + fi + + if [ "${E2E_SKIP_PLAYWRIGHT_INSTALL:-false}" = "true" ]; then + echo "Skipping Playwright browser install" + else + echo "Installing Playwright browsers" + npx playwright install --with-deps + fi +} -npm install +wait_for_url() { + local label="$1" + local url="$2" -cd .. + printf 'Waiting for %s at %s' "$label" "$url" + for _ in $(seq 1 90); do + if curl --fail --location --silent --output /dev/null "$url"; then + echo "" + echo "${label} is ready" + return + fi + ensure_runtime_running + printf '.' + sleep 1 + done -mvn -q install -Pquick + echo "" + echo "Timed out waiting for ${label} at ${url}" >&2 + return 1 +} -mvn -pl tools/server-boot spring-boot:run & -SERVER_PID=$! -# server-boot module will be stopped automatically on script exit (see cleanup trap above). +ensure_runtime_running() { + if [ -n "${SERVER_PID:-}" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "" + echo "server-boot module exited before RDF4J became ready" >&2 + wait "$SERVER_PID" 2>/dev/null || true + SERVER_PID="" + return 1 + fi -cd e2e + if [ "${DOCKER_STARTED}" = "true" ]; then + local container_id + container_id="$(cd "$DOCKER_DIR" && APP_SERVER=tomcat docker compose ps -q rdf4j 2>/dev/null || true)" + if [ -n "$container_id" ] && [ "$(docker inspect -f '{{.State.Running}}' "$container_id" 2>/dev/null || echo false)" != "true" ]; then + echo "" + echo "Docker/Tomcat container exited before RDF4J became ready" >&2 + (cd "$DOCKER_DIR" && APP_SERVER=tomcat docker compose logs --tail=200 rdf4j) || true + return 1 + fi + fi +} -sleep 10 +wait_for_rdf4j() { + wait_for_url "RDF4J Server" "http://localhost:8080/rdf4j-server/" + wait_for_url "RDF4J Workbench" "http://localhost:8080/rdf4j-workbench/" +} -if [ ! -d 'node_modules' ]; then - echo "npm ci" - npm ci -fi +start_spring_boot() { + echo "Building RDF4J for Spring Boot E2E" + (cd "$ROOT_DIR" && mvn install -Pquick) -npx playwright install --with-deps # install browsers -npx playwright test + SPRING_BOOT_DATA_DIR="${E2E_DATA_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/rdf4j-e2e.XXXXXX")}" + echo "Using RDF4J app data directory ${SPRING_BOOT_DATA_DIR}" -status_npx=$? + echo "Starting RDF4J Server and Workbench with Spring Boot" + ( + cd "$ROOT_DIR" + mvn -pl tools/server-boot spring-boot:run \ + -Dspring-boot.run.jvmArguments="-Dorg.eclipse.rdf4j.appdata.basedir=${SPRING_BOOT_DATA_DIR}" + ) & + SERVER_PID=$! +} -cd .. +start_docker_tomcat() { + echo "Building Docker/Tomcat RDF4J image" + (cd "$DOCKER_DIR" && APP_SERVER=tomcat ./build.sh) -# test for error code -if [ $status_npx -ne 0 ]; then - echo "Error in E2E test" - exit $status_npx -fi + echo "Starting Docker/Tomcat RDF4J container" + (cd "$DOCKER_DIR" && APP_SERVER=tomcat docker compose up --force-recreate -d) + DOCKER_STARTED="true" +} -echo "E2E test OK" +run_playwright() { + cd "$E2E_DIR" + npx playwright test +} -# don't redo the whole build process just for making another docker image -export SKIP_BUILD="skip" +trap 'cleanup $?' EXIT +trap 'cleanup 130' INT +trap 'cleanup 143' TERM + +case "$SERVER_RUNTIME" in + spring-boot) + install_e2e_dependencies + start_spring_boot + ;; + docker | docker-tomcat | tomcat) + install_e2e_dependencies + start_docker_tomcat + ;; + *) + usage + exit 2 + ;; +esac + +wait_for_rdf4j +run_playwright + +echo "E2E test OK (${SERVER_RUNTIME})" diff --git a/e2e/tests/workbench.spec.js b/e2e/tests/workbench.spec.js index d3f4897c23..8d55a32c2d 100644 --- a/e2e/tests/workbench.spec.js +++ b/e2e/tests/workbench.spec.js @@ -82,6 +82,34 @@ function getTrackedRequestIds(requests) { return [...new Set(requests.map(request => request.requestId).filter(Boolean))].sort(); } +const LOADING_EXPLANATION_TEXT = 'Loading explanation...'; + +async function waitForStableExplanation(page, selector = '#query-explanation') { + await page.waitForFunction(({ selector, loadingText }) => { + const explanation = document.querySelector(selector); + const text = explanation && explanation.textContent.trim(); + return text && text.length > 0 && text !== loadingText; + }, { selector, loadingText: LOADING_EXPLANATION_TEXT }); + + return (await page.locator(selector).textContent()).trim(); +} + +async function waitForChangedStableExplanation(page, selector, previousExplanation) { + await page.waitForFunction(({ selector, loadingText, previousExplanation }) => { + const explanation = document.querySelector(selector); + const text = explanation && explanation.textContent.trim(); + return text && text.length > 0 && text !== loadingText && text !== previousExplanation; + }, { selector, loadingText: LOADING_EXPLANATION_TEXT, previousExplanation }); + + return (await page.locator(selector).textContent()).trim(); +} + +async function waitForStableExplanations(page, selectors) { + for (const selector of selectors) { + await waitForStableExplanation(page, selector); + } +} + test('Create repo', async ({page}) => { await page.goto('http://localhost:8080/rdf4j-workbench/'); page.on('dialog', dialog => { @@ -225,10 +253,7 @@ test('Query compare mode diffs query and explanation', async ({page}) => { }); await page.locator('#explain-trigger').click(); - await page.waitForFunction(() => { - const explanation = document.getElementById('query-explanation'); - return explanation && explanation.textContent.trim().length > 0; - }); + await waitForStableExplanation(page); await expect(page.locator('#compare-toggle')).toBeVisible(); await expect.poll(async () => page.evaluate(() => { const compareButton = document.getElementById('compare-toggle'); @@ -253,10 +278,7 @@ test('Query compare mode diffs query and explanation', async ({page}) => { const compareCode = document.querySelectorAll('.CodeMirror-code')[1]; return compareCode ? compareCode.textContent.replace(/\s+/g, ' ').trim() : ''; })).toContain('SELECT * WHERE { ?s ?p ?o } LIMIT 10'); - await page.waitForFunction(() => { - const compareExplanation = document.getElementById('query-explanation-compare'); - return compareExplanation && compareExplanation.textContent.trim().length > 0; - }); + await waitForStableExplanation(page, '#query-explanation-compare'); const collapsedLayout = await page.evaluate(() => { const queryForm = document.querySelector('.query-form'); @@ -286,11 +308,7 @@ test('Query compare mode diffs query and explanation', async ({page}) => { }); await page.locator('#explain-compare-trigger').click(); - await page.waitForFunction(() => { - const primary = document.getElementById('query-explanation'); - const compare = document.getElementById('query-explanation-compare'); - return primary && compare && primary.textContent.trim().length > 0 && compare.textContent.trim().length > 0; - }); + await waitForStableExplanations(page, ['#query-explanation', '#query-explanation-compare']); await page.locator('#query-diff-trigger').click(); await expect(page.locator('#query-diff-modal')).toHaveClass(/query-diff-modal--open/); @@ -318,18 +336,12 @@ test('Explain wait state shows spinner and cancel for primary and compare action await page.locator('#explain-trigger').click(); await expect(page.locator('#explain-trigger-spinner')).toBeVisible(); await expect(page.locator('#explain-trigger-cancel')).toBeVisible(); - await page.waitForFunction(() => { - const explanation = document.getElementById('query-explanation'); - return explanation && explanation.textContent.trim().length > 0; - }); + await waitForStableExplanation(page); await page.locator('#rerun-explanation').click(); await expect(page.locator('#rerun-explanation-spinner')).toBeVisible(); await expect(page.locator('#rerun-explanation-cancel')).toBeVisible(); - await page.waitForFunction(() => { - const explanation = document.getElementById('query-explanation'); - return explanation && explanation.textContent.trim().length > 0; - }); + await waitForStableExplanation(page); await page.locator('#compare-toggle').click(); await page.waitForFunction(() => document.querySelectorAll('.CodeMirror').length === 2); @@ -339,20 +351,14 @@ test('Explain wait state shows spinner and cancel for primary and compare action await expect(page.locator('#explain-trigger-cancel')).toBeVisible(); await expect(page.locator('#explain-compare-cancel')).toBeVisible(); await expect(page.locator('#explain-compare-trigger')).toHaveClass(/query-compare-action--spinning/); - await page.waitForFunction(() => { - const explanation = document.getElementById('query-explanation-compare'); - return explanation && explanation.textContent.trim().length > 0; - }); + await waitForStableExplanation(page, '#query-explanation-compare'); await page.locator('#rerun-explanation').click(); await expect(page.locator('#rerun-explanation-spinner')).toBeVisible(); await expect(page.locator('#rerun-explanation-cancel')).toBeVisible(); await expect(page.locator('#explain-compare-cancel')).toBeVisible(); await expect(page.locator('#explain-compare-trigger')).toHaveClass(/query-compare-action--spinning/); - await page.waitForFunction(() => { - const explanation = document.getElementById('query-explanation-compare'); - return explanation && explanation.textContent.trim().length > 0; - }); + await waitForStableExplanation(page, '#query-explanation-compare'); }); test('Primary cancel posts matching request id and stale responses do not repaint', async ({page}) => { @@ -368,14 +374,7 @@ test('Primary cancel posts matching request id and stale responses do not repain await typeIntoCodeMirror(page, 0, 'SELECT * WHERE { ?s ?p ?o } LIMIT 10'); await page.locator('#explain-trigger').click(); - await page.waitForFunction(() => { - const explanation = document.getElementById('query-explanation'); - return explanation && explanation.textContent.trim().length > 0; - }); - - const initialExplanation = await page.evaluate(() => { - return document.getElementById('query-explanation').textContent.trim(); - }); + const initialExplanation = await waitForStableExplanation(page); const traffic = await trackExplainTraffic(page); await page.locator('#explain-format').selectOption('json'); @@ -408,17 +407,12 @@ test('Compare-mode left cancel buttons abort explanation refresh', async ({page} await typeIntoCodeMirror(page, 0, 'SELECT * WHERE { ?s ?p ?o } LIMIT 10'); await page.locator('#explain-trigger').click(); - await page.waitForFunction(() => { - const explanation = document.getElementById('query-explanation'); - return explanation && explanation.textContent.trim().length > 0; - }); + await waitForStableExplanation(page); await page.locator('#compare-toggle').click(); await page.waitForFunction(() => document.querySelectorAll('.CodeMirror').length === 2); await typeIntoCodeMirror(page, 1, 'ASK { ?s ?p ?o }'); - const initialCompareExplanation = await page.evaluate(() => { - return document.getElementById('query-explanation-compare').textContent.trim(); - }); + const initialCompareExplanation = await waitForStableExplanation(page, '#query-explanation-compare'); const traffic = await trackExplainTraffic(page); await page.locator('#explain-trigger').click(); @@ -436,17 +430,9 @@ test('Compare-mode left cancel buttons abort explanation refresh', async ({page} })).toBe(initialCompareExplanation); await page.locator('#explain-compare-trigger').click(); - await page.waitForFunction(previousExplanation => { - const explanation = document.getElementById('query-explanation-compare'); - const text = explanation && explanation.textContent.trim(); - return text && text.length > 0 - && text !== 'Loading explanation...' - && text !== previousExplanation; - }, initialCompareExplanation); - - const refreshedCompareExplanation = await page.evaluate(() => { - return document.getElementById('query-explanation-compare').textContent.trim(); - }); + const refreshedCompareExplanation = await waitForChangedStableExplanation( + page, '#query-explanation-compare', initialCompareExplanation + ); await page.locator('#rerun-explanation').click(); await expect(page.locator('#rerun-explanation-cancel')).toBeVisible(); @@ -473,14 +459,7 @@ test('Changing explain level implicitly cancels pending explain with matching re await typeIntoCodeMirror(page, 0, 'SELECT * WHERE { ?s ?p ?o } LIMIT 10'); await page.locator('#explain-trigger').click(); - await page.waitForFunction(() => { - const explanation = document.getElementById('query-explanation'); - return explanation && explanation.textContent.trim().length > 0; - }); - - const initialExplanation = await page.evaluate(() => { - return document.getElementById('query-explanation').textContent.trim(); - }); + const initialExplanation = await waitForStableExplanation(page); const traffic = await trackExplainTraffic(page); await page.locator('#explain-format').selectOption('json'); @@ -519,10 +498,7 @@ test('Query compare mode keeps primary query on reload without persisting second await typeIntoCodeMirror(page, 0, primaryQuery); await page.locator('#explain-trigger').click(); - await page.waitForFunction(() => { - const explanation = document.getElementById('query-explanation'); - return explanation && explanation.textContent.trim().length > 0; - }); + await waitForStableExplanation(page); await page.locator('#compare-toggle').click(); await page.waitForFunction(() => document.querySelectorAll('.CodeMirror').length === 2);