diff --git a/.github/workflows/pr-verify.yml b/.github/workflows/pr-verify.yml index ddc2202258e..c89ed189e98 100644 --- a/.github/workflows/pr-verify.yml +++ b/.github/workflows/pr-verify.yml @@ -124,6 +124,37 @@ jobs: repo: eclipse/rdf4j workflow_id: ${{ github.run_id }} access_token: ${{ github.token }} + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up JDK + uses: actions/setup-java@v1 + with: + java-version: 11 + - name: Cache local Maven repository + uses: actions/cache@v2 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-jdk11-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-jdk11-maven- + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y libxml2-utils + - name: Install Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + - name: Run end-to-end tests of RDF4J Server and Workbench + working-directory: ./e2e + run: ./run.sh + - name: Cancel workflow on failure + uses: vishnudxb/cancel-workflow@v1.2 + if: failure() + with: + repo: eclipse/rdf4j + workflow_id: ${{ github.run_id }} + access_token: ${{ github.token }} copyright-check: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 95c4b2558ed..75b520bf014 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ compliance/*/overlays docker/ignore /core/queryparser/sparql/JavaCC/javacc/ /scripts/temp/ +org.eclipse.dash.licenses-1.0.2.jar e2e/node_modules e2e/playwright-report e2e/test-results diff --git a/core/queryparser/sparql/src/main/java/org/eclipse/rdf4j/query/parser/sparql/GraphPattern.java b/core/queryparser/sparql/src/main/java/org/eclipse/rdf4j/query/parser/sparql/GraphPattern.java index fb3c20a8b76..a2f9eff342f 100644 --- a/core/queryparser/sparql/src/main/java/org/eclipse/rdf4j/query/parser/sparql/GraphPattern.java +++ b/core/queryparser/sparql/src/main/java/org/eclipse/rdf4j/query/parser/sparql/GraphPattern.java @@ -103,6 +103,7 @@ public void addRequiredTE(TupleExpr te) { */ void clearRequiredTEs() { requiredTEs.clear(); + optionalTEs.clear(); } public void addRequiredSP(Var subjVar, Var predVar, Var objVar) { @@ -173,6 +174,18 @@ public void clear() { public TupleExpr buildTupleExpr() { TupleExpr result = buildJoinFromRequiredTEs(); + result = buildOptionalTE(result); + + for (ValueExpr constraint : constraints) { + result = new Filter(result, constraint); + } + return result; + } + + /** + * Build optionals to the supplied TE + */ + public TupleExpr buildOptionalTE(TupleExpr result) { for (Map.Entry> entry : optionalTEs) { List constraints = entry.getValue(); if (constraints != null && !constraints.isEmpty()) { @@ -186,11 +199,6 @@ public TupleExpr buildTupleExpr() { result = new LeftJoin(result, entry.getKey()); } } - - for (ValueExpr constraint : constraints) { - result = new Filter(result, constraint); - } - return result; } diff --git a/core/queryparser/sparql/src/main/java/org/eclipse/rdf4j/query/parser/sparql/TupleExprBuilder.java b/core/queryparser/sparql/src/main/java/org/eclipse/rdf4j/query/parser/sparql/TupleExprBuilder.java index 6ab57a6c64c..35adb2164b0 100644 --- a/core/queryparser/sparql/src/main/java/org/eclipse/rdf4j/query/parser/sparql/TupleExprBuilder.java +++ b/core/queryparser/sparql/src/main/java/org/eclipse/rdf4j/query/parser/sparql/TupleExprBuilder.java @@ -2376,6 +2376,8 @@ public Object visit(ASTBind node, Object data) throws VisitorException { // get a tupleExpr that represents the basic graph pattern, sofar. TupleExpr arg = graphPattern.buildJoinFromRequiredTEs(); + // apply optionals, if any + arg = graphPattern.buildOptionalTE(arg); // check if alias is not previously used in the BGP if (arg.getBindingNames().contains(alias)) { diff --git a/core/queryparser/sparql/src/test/java/org/eclipse/rdf4j/query/parser/sparql/TupleExprBuilderTest.java b/core/queryparser/sparql/src/test/java/org/eclipse/rdf4j/query/parser/sparql/TupleExprBuilderTest.java index b1d24aa3ac5..0ebea2524dc 100644 --- a/core/queryparser/sparql/src/test/java/org/eclipse/rdf4j/query/parser/sparql/TupleExprBuilderTest.java +++ b/core/queryparser/sparql/src/test/java/org/eclipse/rdf4j/query/parser/sparql/TupleExprBuilderTest.java @@ -266,6 +266,41 @@ public void testServiceGraphPatternChopping() { } + @Test + public void testOtionalBindCoalesce() throws Exception { + StringBuilder qb = new StringBuilder(); + qb.append("SELECT ?result \n"); + qb.append("WHERE { \n"); + qb.append("OPTIONAL {\n" + + " OPTIONAL {\n" + + " BIND(\"value\" AS ?foo)\n" + + " }\n" + + " BIND(COALESCE(?foo, \"no value\") AS ?result)\n" + + " }"); + qb.append(" } "); + + ASTQueryContainer qc = SyntaxTreeBuilder.parseQuery(qb.toString()); + TupleExpr result = builder.visit(qc, null); + String expected = "Projection\n" + + " ProjectionElemList\n" + + " ProjectionElem \"result\"\n" + + " LeftJoin\n" + + " SingletonSet\n" + + " Extension\n" + + " LeftJoin\n" + + " SingletonSet\n" + + " Extension\n" + + " SingletonSet\n" + + " ExtensionElem (foo)\n" + + " ValueConstant (value=\"value\")\n" + + " ExtensionElem (result)\n" + + " Coalesce\n" + + " Var (name=foo)\n" + + " ValueConstant (value=\"no value\")\n"; + assertEquals(expected.replace("\r\n", "\n"), result.toString().replace("\r\n", "\n")); +// System.out.println(result); + } + private class ServiceNodeFinder extends AbstractASTVisitor { private final List graphPatterns = new ArrayList<>(); diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbRecordIterator.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbRecordIterator.java index de8be81884b..ec77d5d044b 100644 --- a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbRecordIterator.java +++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbRecordIterator.java @@ -54,7 +54,7 @@ class LmdbRecordIterator implements RecordIterator { private final int dbi; - private boolean closed = false; + private volatile boolean closed = false; private final MDBVal keyData; @@ -72,6 +72,8 @@ class LmdbRecordIterator implements RecordIterator { private final StampedLock txnLock; + private final Thread ownerThread = Thread.currentThread(); + LmdbRecordIterator(Pool pool, TripleIndex index, boolean rangeSearch, long subj, long pred, long obj, long context, boolean explicit, Txn txnRef) throws IOException { this.pool = pool; @@ -140,7 +142,7 @@ public long[] next() { lastResult = mdb_cursor_get(cursor, keyData, valueData, MDB_SET_RANGE); } if (lastResult != 0) { - close(); + closeInternal(false); return null; } } @@ -177,30 +179,45 @@ public long[] next() { return quad; } } - close(); + closeInternal(false); return null; } finally { txnLock.unlockRead(stamp); } } - @Override - public void close() { + private void closeInternal(boolean maybeCalledAsync) { if (!closed) { + long stamp; + if (maybeCalledAsync && ownerThread != Thread.currentThread()) { + stamp = txnLock.writeLock(); + } else { + stamp = 0; + } try { - mdb_cursor_close(cursor); - pool.free(keyData); - pool.free(valueData); - if (minKeyBuf != null) { - pool.free(minKeyBuf); - } - if (maxKey != null) { - pool.free(maxKeyBuf); - pool.free(maxKey); + if (!closed) { + mdb_cursor_close(cursor); + pool.free(keyData); + pool.free(valueData); + if (minKeyBuf != null) { + pool.free(minKeyBuf); + } + if (maxKey != null) { + pool.free(maxKeyBuf); + pool.free(maxKey); + } } } finally { closed = true; + if (stamp != 0) { + txnLock.unlockWrite(stamp); + } } } } + + @Override + public void close() { + closeInternal(true); + } } diff --git a/core/sail/shacl/src/main/java/org/eclipse/rdf4j/sail/shacl/wrapper/shape/Rdf4jShaclShapeGraphShapeSource.java b/core/sail/shacl/src/main/java/org/eclipse/rdf4j/sail/shacl/wrapper/shape/Rdf4jShaclShapeGraphShapeSource.java index 86456ee0a12..06bc4cea4c0 100644 --- a/core/sail/shacl/src/main/java/org/eclipse/rdf4j/sail/shacl/wrapper/shape/Rdf4jShaclShapeGraphShapeSource.java +++ b/core/sail/shacl/src/main/java/org/eclipse/rdf4j/sail/shacl/wrapper/shape/Rdf4jShaclShapeGraphShapeSource.java @@ -18,6 +18,8 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import org.eclipse.rdf4j.common.exception.RDF4JException; +import org.eclipse.rdf4j.common.iteration.CloseableIteration; import org.eclipse.rdf4j.common.transaction.IsolationLevels; import org.eclipse.rdf4j.model.IRI; import org.eclipse.rdf4j.model.Model; @@ -82,48 +84,34 @@ private Rdf4jShaclShapeGraphShapeSource(Repository repository, RepositoryConnect private SailRepository forwardChain(RepositoryConnection shapesRepoConnection) { try (var statements = shapesRepoConnection.getStatements(null, null, null, false, RDF4J.SHACL_SHAPE_GRAPH)) { - if (!statements.hasNext()) { - return new SailRepository(new MemoryStore()); - } - - SailRepository shapesRepoWithReasoning = new SailRepository( - SchemaCachingRDFSInferencer.fastInstantiateFrom(shaclVocabulary, new MemoryStore(), false)); - - try (var shapesRepoWithReasoningConnection = shapesRepoWithReasoning.getConnection()) { - shapesRepoWithReasoningConnection.begin(IsolationLevels.NONE); - - shapesRepoWithReasoningConnection.add(statements); - enrichShapes(shapesRepoWithReasoningConnection); - - shapesRepoWithReasoningConnection.commit(); - } - - return shapesRepoWithReasoning; - + return forwardChain(statements); } } private SailRepository forwardChain(SailConnection shapesSailConnection) { try (var statements = shapesSailConnection.getStatements(null, null, null, false, RDF4J.SHACL_SHAPE_GRAPH)) { - if (!statements.hasNext()) { - return new SailRepository(new MemoryStore()); - } - - SailRepository shapesRepoWithReasoning = new SailRepository( - SchemaCachingRDFSInferencer.fastInstantiateFrom(shaclVocabulary, new MemoryStore(), false)); + return forwardChain(statements); + } + } - try (var shapesRepoWithReasoningConnection = shapesRepoWithReasoning.getConnection()) { - shapesRepoWithReasoningConnection.begin(IsolationLevels.NONE); + private SailRepository forwardChain(CloseableIteration statements) { + if (!statements.hasNext()) { + return new SailRepository(new MemoryStore()); + } - shapesRepoWithReasoningConnection.add(statements); - enrichShapes(shapesRepoWithReasoningConnection); + SailRepository shapesRepoWithReasoning = new SailRepository( + SchemaCachingRDFSInferencer.fastInstantiateFrom(shaclVocabulary, new MemoryStore(), false)); - shapesRepoWithReasoningConnection.commit(); - } + try (var shapesRepoWithReasoningConnection = shapesRepoWithReasoning.getConnection()) { + shapesRepoWithReasoningConnection.begin(IsolationLevels.NONE); - return shapesRepoWithReasoning; + shapesRepoWithReasoningConnection.add(statements); + enrichShapes(shapesRepoWithReasoningConnection); + shapesRepoWithReasoningConnection.commit(); } + + return shapesRepoWithReasoning; } private static SchemaCachingRDFSInferencer createShaclVocabulary() { diff --git a/core/sail/shacl/src/main/java/org/eclipse/rdf4j/sail/shacl/wrapper/shape/ShapeSource.java b/core/sail/shacl/src/main/java/org/eclipse/rdf4j/sail/shacl/wrapper/shape/ShapeSource.java index 5d4caed49b3..b4c0f61dfef 100644 --- a/core/sail/shacl/src/main/java/org/eclipse/rdf4j/sail/shacl/wrapper/shape/ShapeSource.java +++ b/core/sail/shacl/src/main/java/org/eclipse/rdf4j/sail/shacl/wrapper/shape/ShapeSource.java @@ -47,10 +47,6 @@ private static Model resourceAsModel(String filename) { static Stream getRsxDataAndShapesGraphLink(SailConnection connection, Resource[] context) { Stream rsxDataAndShapesGraphLink; - List collect1 = connection.getStatements(null, null, null, false) - .stream() - .collect(Collectors.toList()); - try (var stream = connection.getStatements(null, RDF.TYPE, RSX.DataAndShapesGraphLink, false, context) .stream()) { @@ -108,9 +104,6 @@ static Stream getRsxDataAndShapesGraphLink(SailConnection connectio } static Stream getRsxDataAndShapesGraphLink(RepositoryConnection connection, Resource[] context) { - List collect1 = connection.getStatements(null, null, null, false) - .stream() - .collect(Collectors.toList()); Stream rsxDataAndShapesGraphLink; try (var stream = connection.getStatements(null, RDF.TYPE, RSX.DataAndShapesGraphLink, false, context) diff --git a/docker/waitForDocker.sh b/docker/waitForDocker.sh new file mode 100755 index 00000000000..46b05737f15 --- /dev/null +++ b/docker/waitForDocker.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Initial sleep to make sure docker has started +sleep 5 + + +while : ; do + STARTING=`docker ps -f "health=starting" --format "{{.Names}}"` + if [ -z "$STARTING" ] #if STARTING is empty + then + break + fi + echo "Waiting for containers to finish starting" + sleep 1 +done + + +while : ; do + + # Get cpu % from docker stats, remove '%' and then sum all the values into one number + CPU=`docker stats --no-stream --format "{{.CPUPerc}}" | awk '{gsub ( "[%]","" ) ; print $0 }' | awk '{s+=$1} END {print s}'` + echo "CPU: $CPU%" + + # Do floating point comparison, if $CPU is bigger than 15, WAIT will be 1 + WAIT=`echo $CPU'>'15 | bc -l` + echo "WAIT (0/1): $WAIT" + + sleep 1 + + # Get cpu % from docker stats, remove '%' and then sum all the values into one number + CPU2=`docker stats --no-stream --format "{{.CPUPerc}}" | awk '{gsub ( "[%]","" ) ; print $0 }' | awk '{s+=$1} END {print s}'` + echo "CPU2: $CPU2%" + + # Do floating point comparison, if $CPU is bigger than 15, WAIT will be 1 + WAIT2=`echo $CPU'>'15 | bc -l` + echo "WAIT2 (0/1): $WAIT2" + + # Break from loop if WAIT is 0, which is when the sum of the cpu usage is smaller than 15% + [[ "$WAIT" -eq 0 ]] && [[ "$WAIT2" -eq 0 ]] && break + + # Else sleep and loop + echo "Waiting for docker" + sleep 1 + +done + +while : ; do + STARTING=`docker ps -f "health=starting" --format "{{.Names}}"` + if [ -z "$STARTING" ] #if STARTING is empty + then + break + fi + echo "Waiting for containers to finish starting" + sleep 1 +done diff --git a/e2e/.github/workflows/playwright.yml b/e2e/.github/workflows/playwright.yml new file mode 100644 index 00000000000..90b6b700d4f --- /dev/null +++ b/e2e/.github/workflows/playwright.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v3 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 00000000000..75e854d8dcf --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000000..65309abc05d --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,20 @@ +# End-to-end tests + +This directory contains end-to-end tests for the project. These tests use docker to run the RDF4J server and workbench. + +The tests are written using Microsoft Playwright and interact with the server and workbench using the browser. + +## Running the tests + +Requirements: + - docker + - java + - maven + - npm + - npx + +The tests can be run using the `run.sh` script. This script will build the project, start the server and workbench and run the tests. + +To run the tests interactively use `npx playwright test --ui` + +The RDF4J server and workbench can be started independently using the `run.sh` script in the `docker` directory. diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 00000000000..4f552816923 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,140 @@ +{ + "name": "e2e", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "e2e", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.39.0", + "@types/node": "^20.8.7" + } + }, + "node_modules/@playwright/test": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.39.0.tgz", + "integrity": "sha512-3u1iFqgzl7zr004bGPYiN/5EZpRUSFddQBra8Rqll5N0/vfpqlP9I9EXqAoGacuAbX6c9Ulg/Cjqglp5VkK6UQ==", + "dev": true, + "dependencies": { + "playwright": "1.39.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@types/node": { + "version": "20.8.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.7.tgz", + "integrity": "sha512-21TKHHh3eUHIi2MloeptJWALuCu5H7HQTdTrWIFReA8ad+aggoX+lRes3ex7/FtpC+sVUpFMQ+QTfYr74mruiQ==", + "dev": true, + "dependencies": { + "undici-types": "~5.25.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.39.0.tgz", + "integrity": "sha512-naE5QT11uC/Oiq0BwZ50gDmy8c8WLPRTEWuSSFVG2egBka/1qMoSqYQcROMT9zLwJ86oPofcTH2jBY/5wWOgIw==", + "dev": true, + "dependencies": { + "playwright-core": "1.39.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.39.0.tgz", + "integrity": "sha512-+k4pdZgs1qiM+OUkSjx96YiKsXsmb59evFoqv8SKO067qBA+Z2s/dCzJij/ZhdQcs2zlTAgRKfeiiLm8PQ2qvw==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/undici-types": { + "version": "5.25.3", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz", + "integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==", + "dev": true + } + }, + "dependencies": { + "@playwright/test": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.39.0.tgz", + "integrity": "sha512-3u1iFqgzl7zr004bGPYiN/5EZpRUSFddQBra8Rqll5N0/vfpqlP9I9EXqAoGacuAbX6c9Ulg/Cjqglp5VkK6UQ==", + "dev": true, + "requires": { + "playwright": "1.39.0" + } + }, + "@types/node": { + "version": "20.8.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.7.tgz", + "integrity": "sha512-21TKHHh3eUHIi2MloeptJWALuCu5H7HQTdTrWIFReA8ad+aggoX+lRes3ex7/FtpC+sVUpFMQ+QTfYr74mruiQ==", + "dev": true, + "requires": { + "undici-types": "~5.25.1" + } + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, + "playwright": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.39.0.tgz", + "integrity": "sha512-naE5QT11uC/Oiq0BwZ50gDmy8c8WLPRTEWuSSFVG2egBka/1qMoSqYQcROMT9zLwJ86oPofcTH2jBY/5wWOgIw==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.39.0" + } + }, + "playwright-core": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.39.0.tgz", + "integrity": "sha512-+k4pdZgs1qiM+OUkSjx96YiKsXsmb59evFoqv8SKO067qBA+Z2s/dCzJij/ZhdQcs2zlTAgRKfeiiLm8PQ2qvw==", + "dev": true + }, + "undici-types": { + "version": "5.25.3", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz", + "integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==", + "dev": true + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000000..5d2cea305d4 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,14 @@ +{ + "name": "e2e", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": {}, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.39.0", + "@types/node": "^20.8.7" + } +} diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js new file mode 100644 index 00000000000..3b576ac9d71 --- /dev/null +++ b/e2e/playwright.config.js @@ -0,0 +1,78 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test'); + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * @see https://playwright.dev/docs/test-configuration + */ +module.exports = defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); + diff --git a/e2e/run.sh b/e2e/run.sh new file mode 100755 index 00000000000..130ed050b92 --- /dev/null +++ b/e2e/run.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# Copyright (c) 2023 Eclipse RDF4J contributors. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Distribution License v1.0 +# which accompanies this distribution, and is available at +# http://www.eclipse.org/org/documents/edl-v10.php. +# +# SPDX-License-Identifier: BSD-3-Clause +# + +set -e + +cd .. +cd docker +./run.sh +./waitForDocker.sh +cd .. +cd e2e + +sleep 10 + +if [ ! -d 'node_modules' ]; then + echo "npm ci" + npm ci +fi + +docker ps + +npx playwright install --with-deps # install browsers +npx playwright test +exit $? + diff --git a/e2e/tests-examples/demo-todo-app.spec.js b/e2e/tests-examples/demo-todo-app.spec.js new file mode 100644 index 00000000000..e2eb87ce1b0 --- /dev/null +++ b/e2e/tests-examples/demo-todo-app.spec.js @@ -0,0 +1,449 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +test.beforeEach(async ({ page }) => { + await page.goto('https://demo.playwright.dev/todomvc'); +}); + +const TODO_ITEMS = [ + 'buy some cheese', + 'feed the cat', + 'book a doctors appointment' +]; + +test.describe('New Todo', () => { + test('should allow me to add todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create 1st todo. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Make sure the list only has one todo item. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0] + ]); + + // Create 2nd todo. + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + + // Make sure the list now has two todo items. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[1] + ]); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); + + test('should clear text input field when an item is added', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create one todo item. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Check that input is empty. + await expect(newTodo).toBeEmpty(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); + + test('should append new items to the bottom of the list', async ({ page }) => { + // Create 3 items. + await createDefaultTodos(page); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + // Check test using different methods. + await expect(page.getByText('3 items left')).toBeVisible(); + await expect(todoCount).toHaveText('3 items left'); + await expect(todoCount).toContainText('3'); + await expect(todoCount).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); +}); + +test.describe('Mark all as completed', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test.afterEach(async ({ page }) => { + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should allow me to mark all items as completed', async ({ page }) => { + // Complete all todos. + await page.getByLabel('Mark all as complete').check(); + + // Ensure all todos have 'completed' class. + await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test('should allow me to clear the complete state of all items', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + // Check and then immediately uncheck. + await toggleAll.check(); + await toggleAll.uncheck(); + + // Should be no completed classes. + await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']); + }); + + test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Uncheck first todo. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Assert the toggle all is checked again. + await expect(toggleAll).toBeChecked(); + }); +}); + +test.describe('Item', () => { + + test('should allow me to mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + // Check first item. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').check(); + await expect(firstTodo).toHaveClass('completed'); + + // Check second item. + const secondTodo = page.getByTestId('todo-item').nth(1); + await expect(secondTodo).not.toHaveClass('completed'); + await secondTodo.getByRole('checkbox').check(); + + // Assert completed class. + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).toHaveClass('completed'); + }); + + test('should allow me to un-mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const firstTodo = page.getByTestId('todo-item').nth(0); + const secondTodo = page.getByTestId('todo-item').nth(1); + const firstTodoCheckbox = firstTodo.getByRole('checkbox'); + + await firstTodoCheckbox.check(); + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodoCheckbox.uncheck(); + await expect(firstTodo).not.toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 0); + }); + + test('should allow me to edit an item', async ({ page }) => { + await createDefaultTodos(page); + + const todoItems = page.getByTestId('todo-item'); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]); + await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter'); + + // Explicitly assert the new text value. + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); +}); + +test.describe('Editing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should hide other controls when editing', async ({ page }) => { + const todoItem = page.getByTestId('todo-item').nth(1); + await todoItem.dblclick(); + await expect(todoItem.getByRole('checkbox')).not.toBeVisible(); + await expect(todoItem.locator('label', { + hasText: TODO_ITEMS[1], + })).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should save edits on blur', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should trim entered text', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages '); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should remove the item if an empty text string was entered', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(''); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[2], + ]); + }); + + test('should cancel edits on escape', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape'); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe('Counter', () => { + test('should display the current number of todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + await expect(todoCount).toContainText('1'); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + await expect(todoCount).toContainText('2'); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); +}); + +test.describe('Clear completed button', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + }); + + test('should display the correct text', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible(); + }); + + test('should remove completed items when clicked', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).getByRole('checkbox').check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(todoItems).toHaveCount(2); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should be hidden when there are no items that are completed', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden(); + }); +}); + +test.describe('Persistence', () => { + test('should persist its data', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const todoItems = page.getByTestId('todo-item'); + const firstTodoCheck = todoItems.nth(0).getByRole('checkbox'); + await firstTodoCheck.check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + + // Ensure there is 1 completed item. + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + // Now reload. + await page.reload(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + }); +}); + +test.describe('Routing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + // make sure the app had a chance to save updated todos in storage + // before navigating to a new view, otherwise the items can get lost :( + // in some frameworks like Durandal + await checkTodosInLocalStorage(page, TODO_ITEMS[0]); + }); + + test('should allow me to display active items', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await expect(todoItem).toHaveCount(2); + await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should respect the back button', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step('Showing all items', async () => { + await page.getByRole('link', { name: 'All' }).click(); + await expect(todoItem).toHaveCount(3); + }); + + await test.step('Showing active items', async () => { + await page.getByRole('link', { name: 'Active' }).click(); + }); + + await test.step('Showing completed items', async () => { + await page.getByRole('link', { name: 'Completed' }).click(); + }); + + await expect(todoItem).toHaveCount(1); + await page.goBack(); + await expect(todoItem).toHaveCount(2); + await page.goBack(); + await expect(todoItem).toHaveCount(3); + }); + + test('should allow me to display completed items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Completed' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(1); + }); + + test('should allow me to display all items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await page.getByRole('link', { name: 'Completed' }).click(); + await page.getByRole('link', { name: 'All' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(3); + }); + + test('should highlight the currently applied filter', async ({ page }) => { + await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected'); + + //create locators for active and completed links + const activeLink = page.getByRole('link', { name: 'Active' }); + const completedLink = page.getByRole('link', { name: 'Completed' }); + await activeLink.click(); + + // Page change - active items. + await expect(activeLink).toHaveClass('selected'); + await completedLink.click(); + + // Page change - completed items. + await expect(completedLink).toHaveClass('selected'); + }); +}); + +async function createDefaultTodos(page) { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } +} + +/** + * @param {import('@playwright/test').Page} page + * @param {number} expected + */ + async function checkNumberOfTodosInLocalStorage(page, expected) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).length === e; + }, expected); +} + +/** + * @param {import('@playwright/test').Page} page + * @param {number} expected + */ + async function checkNumberOfCompletedTodosInLocalStorage(page, expected) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).filter(i => i.completed).length === e; + }, expected); +} + +/** + * @param {import('@playwright/test').Page} page + * @param {string} title + */ +async function checkTodosInLocalStorage(page, title) { + return await page.waitForFunction(t => { + return JSON.parse(localStorage['react-todos']).map(i => i.title).includes(t); + }, title); +} diff --git a/e2e/tests/server.spec.js b/e2e/tests/server.spec.js new file mode 100644 index 00000000000..8feed04d92a --- /dev/null +++ b/e2e/tests/server.spec.js @@ -0,0 +1,10 @@ +// @ts-check +const {test, expect} = require('@playwright/test'); + + +test('RDF4J Server has correct title', async ({page}) => { + await page.goto('http://localhost:8080/rdf4j-server/'); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle("RDF4J Server - Home"); +}); diff --git a/e2e/tests/workbench.spec.js b/e2e/tests/workbench.spec.js new file mode 100644 index 00000000000..877b332b8bd --- /dev/null +++ b/e2e/tests/workbench.spec.js @@ -0,0 +1,85 @@ +// @ts-check +const {test, expect} = require('@playwright/test'); + +test.beforeEach(async ({page}, testInfo) => { + await page.goto('http://localhost:8080/rdf4j-workbench/'); + await page.getByText("Delete repository").click(); + await page.waitForSelector('#id'); + const optionExists = await page.locator('#id option[value="testrepo1"]').count() > 0; + + if (optionExists) { + await page.locator("#id").selectOption("testrepo1"); + await page.getByRole('button', {name: 'Delete'}).click(); + await page.getByText("List of Repositories").click(); + } +}); + +test('RDF4J Workbench has correct title', async ({page}) => { + await page.goto('http://localhost:8080/rdf4j-workbench/'); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle("RDF4J Workbench - List of Repositories"); +}); + +async function createRepo(page) { + await page.getByText('New repository').click(); + + await page.locator("#id").fill('testrepo1'); + await page.getByText('Next').click(); + + await page.getByText('Create').click(); + let titleHeading = await page.getByText('Repository Location'); + await expect(titleHeading).toHaveText('Repository Location') + +} + +test('Create repo', async ({page}) => { + await page.goto('http://localhost:8080/rdf4j-workbench/'); + page.on('dialog', dialog => { + console.log(dialog.message()); + dialog.dismiss(); + }); + + await page.getByText('New repository').click(); + + await page.locator("#id").fill('testrepo1'); + await page.getByText('Next').click(); + + await page.getByText('Create').click(); + let titleHeading = await page.getByText('Repository Location'); + await expect(titleHeading).toHaveText('Repository Location') + +}); + + +test('SPARQL update', async ({page}) => { + await page.goto('http://localhost:8080/rdf4j-workbench/'); + page.on('dialog', dialog => { + console.log(dialog.message()); + dialog.dismiss(); + }); + + await createRepo(page); + + await page.getByText('SPARQL Update').click(); + await page.waitForSelector('.CodeMirror > div > textarea'); + + // magic code that lets us type in the CodeMirror editor + await page.evaluate(() => { + let CM = document.getElementsByClassName("CodeMirror")[0]; + CM.CodeMirror.setValue("INSERT DATA {\n" + + "\t a .\n" + + "}"); + }); + + + await page.getByRole('button', { name: 'Execute' }).click(); + await page.waitForSelector('table'); + + await page.getByText('Types').click(); + + let type = await page.getByText(''); + await expect(type).toHaveText(''); + +}); + diff --git a/tools/server/src/main/webapp/WEB-INF/lib/jstl-1.2.jar b/tools/server/src/main/webapp/WEB-INF/lib/jstl-1.2.jar new file mode 100644 index 00000000000..0fd275e9466 Binary files /dev/null and b/tools/server/src/main/webapp/WEB-INF/lib/jstl-1.2.jar differ