diff --git a/changelog/unreleased/admin-ui-selenium-tests.yml b/changelog/unreleased/admin-ui-selenium-tests.yml new file mode 100644 index 000000000000..c948e23b903f --- /dev/null +++ b/changelog/unreleased/admin-ui-selenium-tests.yml @@ -0,0 +1,8 @@ +title: > + Added Selenium-based JUnit test coverage for the Admin UI, driving a headless Chrome against a test cluster. +type: other +authors: + - name: Jan Høydahl +links: + - name: SOLR-8474 + url: https://issues.apache.org/jira/browse/SOLR-8474 diff --git a/dev-docs/admin-ui-tests.md b/dev-docs/admin-ui-tests.md new file mode 100644 index 000000000000..1a881631364e --- /dev/null +++ b/dev-docs/admin-ui-tests.md @@ -0,0 +1,177 @@ + + +# Admin UI (AngularJS) Browser Test Plan + +This document tracks browser-based test coverage of the old AngularJS Admin UI +(`solr/webapp/web/`), driven by Selenium WebDriver with headless Chrome. + +## How the tests work + +- Tests live in `solr/webapp/src/test/org/apache/solr/webapp/` and extend + `AdminUiTestBase`, which starts a 2-node `MiniSolrCloudCluster` whose Jetty + nodes also serve the Admin UI (opt-in `JettyConfig.Builder#enableAdminUi`), + then starts a headless Chrome via Selenium WebDriver. +- A locally installed Chrome/Chromium is required; without one, tests skip via + `Assume`. Override discovery with `-Dtests.ui.chrome.binary=/path/to/chrome`. + The matching chromedriver is provisioned (and cached) by Selenium Manager. +- Display assertions compare UI text against live JSON from the same node's + admin APIs — never hardcoded values. +- Tests are grouped per screen/feature, so each screen's display and write + tests live in the same class. +- Most tests run against a 2-node cloud cluster; `AdminUiStandaloneTestBase` + additionally supports standalone (user-managed, no ZooKeeper) nodes, whose + UI differs (no Cloud/Collections/Schema Designer menus; the per-core menu + offers query/replication etc. directly). +- On failure, a screenshot, the page source and the browser console log are + saved into the test temp dir. +- Run with: `./gradlew :solr:webapp:test` + +## Coverage by screen + +### Smoke navigation — `AdminUiSmokeTest` +- [x] Every node-level route (`/`, `~logging`, `~logging/level`, + `~cloud?view=nodes|tree|zkstatus|graph`, `~cores`, `~collections`, + `~schema-designer`, `~security`, `~java-properties`, `~threads`, `login`), + every per-collection route and the per-core routes render their main + content element without severe browser console errors. + +### Dashboard — `AdminUiDashboardTest` +- [x] Versions, JVM info and memory bars vs `/admin/info/system`; security + warning when security is disabled. + +### Node-level screens — `AdminUiNodeScreensTest` +- [x] Java Properties: `java.version` value matches `/admin/info/properties` +- [x] Thread Dump: thread list non-empty, Jetty worker thread shown +- [x] Cloud > Nodes: one row per live node, ports match the cluster +- [x] Cloud > Tree: `live_nodes` and `collections` znodes shown +- [x] Cloud > Graph: d3 SVG renders circles for collection/shard/replicas +- [x] Cloud > ZK Status: status green, ensemble size shown +- [x] Security screen: "not enabled" warning without auth +- [x] Login screen: authentication info page without auth + +### Collections screen — `AdminUiCollectionsScreenTest` +- [x] Collection detail display (shards) +- [x] Create + delete collection via the dialogs (verified via API) +- [x] Create + delete alias via the dialogs (verified via LISTALIASES) +- [x] Add + delete replica via the shard detail (verified via cluster state) +- [x] Reload collection (verified via core start time reset) + +### Query screen — `AdminUiQueryScreenTest` +- [x] `*:*` and `id:` queries via the form, `numFound` in the response +- [x] `rows` and `fl` parameters affect the returned documents +- [x] Paramsets dropdown applies a paramset created via the API +- [x] defType dismax/edismax toggles reveal their parameter fields; edismax + query with `qf` returns the expected results +- [x] Raw query parameters (e.g. an extra `fq`) are applied + +### Documents screen — `AdminUiDocumentsScreenTest` +- [x] Form renders with JSON/XML/CSV document types +- [x] Index a JSON document via the form; becomes searchable + +### Schema screen — `AdminUiSchemaScreenTest` +- [x] Field list browsable, editable-schema action buttons shown +- [x] Field detail shows flags matching `/schema/fields`; term info loads +- [x] Add + delete a field via the dialogs (verified via `/schema/fields`) + +### Paramsets screen — `AdminUiParamsetsScreenTest` +- [x] Form renders +- [x] Create + delete a paramset via the form (verified via `/config/params`) + +### Logging screens — `AdminUiLoggingScreenTest` +- [x] Logger level tree renders +- [x] Set + unset a logger level via the tree (verified via API) +- [x] Events viewer shows a WARN event logged in the server JVM (skips itself + when the node's log watcher is blind due to shared-JVM log4j state, see + Known limitations) + +### Core Admin screen — `AdminUiCoreAdminScreenTest` (cloud) and +### `AdminUiCoreAdminStandaloneTest` (standalone) +- [x] Hosted core listed, matching `/admin/cores` +- [x] Reload core via the button (success indicator) +- [x] Standalone-mode menu differences (no cloud menus; core menu offers + query/replication) +- [x] Add core via the dialog (pre-created instance dir) +- [x] Rename core via the dialog +- [x] Swap cores via the dialog (verified by the indexes exchanging) +- [x] Unload core, accepting the native confirm dialog + +### Per-collection display screens — `AdminUiCollectionScreensTest` +- [x] Analysis: `text_general` tokenizes and lowercases entered text +- [x] Files: tree lists `solrconfig.xml`, file content renders +- [x] Segments: at least one segment rendered after commit +- [x] Plugins/Stats: searcher stats present (needs `metricsEnabled=true`) +- [x] Collection overview: shard info; Core overview: numDocs + +### Stream screen — `AdminUiStreamScreenTest` +- [x] A `search(...)` streaming expression executes and renders all docs + +### SQL screen — `AdminUiSqlScreenTest` +- [x] A SQL query executes through the form and the result grid lists the + documents (the sql module is a test-only dependency of `solr:webapp`) + +### Replication screen — `AdminUiReplicationScreenTest` (cloud) and +### `AdminUiReplicationStandaloneTest` (standalone leader/follower) +- [x] Renders index version info in cloud mode +- [x] Follower screen shows the leader's info +- [x] Disable polling, index on the leader, replicate-now transfers the + index, re-enable polling — verified via the replication API + +### Security with BasicAuth — `AdminUiSecurityAuthTest` +- [x] Unauthenticated visit redirects to login; login form authenticates +- [x] Security screen shows authn/authz plugins, users, roles, permissions +- [x] Add a user through the dialog (verified via `/admin/authentication`) +- [x] Add a role for the user through the dialog (verified via API) +- [x] Grant a predefined permission to the role (verified via API) + +### Schema Designer — `AdminUiSchemaDesignerTest` (`@AwaitsFix`) +- [x] Create a new schema, paste a sample doc, analyze; derived field shown — + but the designer backend is too flaky under automation + (see [SOLR-18347](https://issues.apache.org/jira/browse/SOLR-18347)), + so the test awaits a fix before running by default + +## Deliberately skipped (effort vs value) + +- **JWT/OAuth login flows**: require an external identity provider or heavy + mocking; BasicAuth covers the UI's login/session mechanics. +- **Keystroke-level entry in the security dialogs**: native clicks/keystrokes + into the absolutely-positioned dialogs proved unreliable in headless Chrome; + the dialogs are driven via the Angular controller scope instead. Keyboard + entry is covered by the login form and the other screens' forms. + +## Known limitations + +- The generated js-client bundle (`libs/solr/index.js`) only exists inside the + built WAR, not in the source tree tests serve from. `AdminUiTestBase` serves + a minimal stub defining the `solrApi` global (only `reloadCollection` is used + by the AngularJS UI); a future improvement could serve the real bundle when + it has been built. +- Every test cluster in the JVM registers a log-watcher appender under the same + name in the shared log4j config, so a later cluster's watcher can be blind; + the events-viewer test detects this via the API and skips itself. +- The shared menu code logs a benign + `TypeError: Cannot read properties of null (reading 'name')` from + `$scope.showCore` while the per-collection menu resolves (filtered in the + console-error assertion; candidate for a JIRA). +- The core overview ping widget answers 503 when the configset has no + healthcheck file (allowed in the affected tests). +- The Schema Designer's backend transiently fails its own prep/analyze calls + with "version mismatch, retry" and recovers via its retry dialog; its API + errors are excluded from the console-error assertion. +- ASF Jenkins has no Chrome, so these tests skip there; they run on developer + machines and could run in a GitHub Actions workflow (Chrome preinstalled on + `ubuntu-latest`) as a follow-up. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7f82ce16a6cb..906c625c2950 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,7 +67,7 @@ benmanes-versions = "0.54.0" bouncycastle = "1.84" # @keep Browserify version used in ref-guide browserify = "17.0.0" -bytebuddy = "1.18.9" +bytebuddy = "1.18.11" carrot2-core = "4.8.6" carrotsearch-hppc = "0.10.0" carrotsearch-randomizedtesting = "2.9.1" @@ -187,6 +187,7 @@ owasp-dependencycheck = "13.0.0" perfmark = "0.27.0" prometheus-metrics = "1.8.0" quicktheories = "0.26" +selenium = "4.47.0" semver4j = "6.0.0" slf4j = "2.0.17" spatial4j = "0.8" @@ -512,6 +513,7 @@ perfmark-api = { module = "io.perfmark:perfmark-api", version.ref = "perfmark" } prometheus-metrics-expositionformats = { module = "io.prometheus:prometheus-metrics-exposition-formats", version.ref = "prometheus-metrics" } prometheus-metrics-model = { module = "io.prometheus:prometheus-metrics-model", version.ref = "prometheus-metrics" } quicktheories-quicktheories = { module = "org.quicktheories:quicktheories", version.ref = "quicktheories" } +selenium-chromedriver = { module = "org.seleniumhq.selenium:selenium-chrome-driver", version.ref = "selenium" } semver4j-semver4j = { module = "org.semver4j:semver4j", version.ref = "semver4j" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4j-jcloverslf4j = { module = "org.slf4j:jcl-over-slf4j", version.ref = "slf4j" } diff --git a/solr/core/gradle.lockfile b/solr/core/gradle.lockfile index ddfcd99d6046..9e948f2a9e85 100644 --- a/solr/core/gradle.lockfile +++ b/solr/core/gradle.lockfile @@ -84,7 +84,7 @@ jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=compileClasspath,compileClasspathCopy,jarV jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=jarValidation,runtimeClasspath,runtimeClasspathCopy,runtimeLibs,testRuntimeClasspath,testRuntimeClasspathCopy javax.inject:javax.inject:1=annotationProcessor,errorprone,testAnnotationProcessor junit:junit:4.13.2=jarValidation,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy -net.bytebuddy:byte-buddy:1.18.9=jarValidation,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +net.bytebuddy:byte-buddy:1.18.11=jarValidation,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy org.antlr:antlr4-runtime:4.13.2=jarValidation,runtimeClasspath,runtimeClasspathCopy,runtimeLibs,testRuntimeClasspath,testRuntimeClasspathCopy org.apache.commons:commons-exec:1.6.0=compileClasspath,compileClasspathCopy,jarValidation,runtimeClasspath,runtimeClasspathCopy,runtimeLibs,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy org.apache.commons:commons-lang3:3.20.0=compileClasspath,compileClasspathCopy,jarValidation,runtimeClasspath,runtimeClasspathCopy,runtimeLibs,testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy diff --git a/solr/cross-dc-manager/gradle.lockfile b/solr/cross-dc-manager/gradle.lockfile index a60d7f62fdd8..0893d8e825c6 100644 --- a/solr/cross-dc-manager/gradle.lockfile +++ b/solr/cross-dc-manager/gradle.lockfile @@ -100,8 +100,8 @@ jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=jarValidation,runtimeClasspath,runtimeLibs jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,errorprone,testAnnotationProcessor junit:junit:4.13.2=jarValidation,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy-agent:1.18.9=jarValidation,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.18.9=jarValidation,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.18.11=jarValidation,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.11=jarValidation,testRuntimeClasspath net.sf.jopt-simple:jopt-simple:5.0.4=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath net.sourceforge.argparse4j:argparse4j:0.7.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath org.antlr:antlr4-runtime:4.13.2=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath diff --git a/solr/licenses/byte-buddy-1.18.11.jar.sha1 b/solr/licenses/byte-buddy-1.18.11.jar.sha1 new file mode 100644 index 000000000000..643df148bb78 --- /dev/null +++ b/solr/licenses/byte-buddy-1.18.11.jar.sha1 @@ -0,0 +1 @@ +8fcc3779ff85fae5164cd1b977798ca1af388e06 diff --git a/solr/licenses/byte-buddy-1.18.9.jar.sha1 b/solr/licenses/byte-buddy-1.18.9.jar.sha1 deleted file mode 100644 index 094aa7bb39bd..000000000000 --- a/solr/licenses/byte-buddy-1.18.9.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -70ba178486bc4d539fa63364ffa30d6b96015cff diff --git a/solr/licenses/byte-buddy-agent-1.18.11.jar.sha1 b/solr/licenses/byte-buddy-agent-1.18.11.jar.sha1 new file mode 100644 index 000000000000..f5c15c8124c7 --- /dev/null +++ b/solr/licenses/byte-buddy-agent-1.18.11.jar.sha1 @@ -0,0 +1 @@ +82212e5b3633e7a65fca8bec525762cfb6c9bae4 diff --git a/solr/licenses/byte-buddy-agent-1.18.9.jar.sha1 b/solr/licenses/byte-buddy-agent-1.18.9.jar.sha1 deleted file mode 100644 index f06d122b97db..000000000000 --- a/solr/licenses/byte-buddy-agent-1.18.9.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -0f0a6f61528233035d7fd167e5ed3a711dfd0ebb diff --git a/solr/licenses/selenium-LICENSE-ASL.txt b/solr/licenses/selenium-LICENSE-ASL.txt new file mode 100644 index 000000000000..1a9893b43e8e --- /dev/null +++ b/solr/licenses/selenium-LICENSE-ASL.txt @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/solr/licenses/selenium-NOTICE.txt b/solr/licenses/selenium-NOTICE.txt new file mode 100644 index 000000000000..146d948e632b --- /dev/null +++ b/solr/licenses/selenium-NOTICE.txt @@ -0,0 +1,2 @@ +Copyright 2011-2025 Software Freedom Conservancy +Copyright 2004-2011 Selenium committers diff --git a/solr/licenses/selenium-api-4.47.0.jar.sha1 b/solr/licenses/selenium-api-4.47.0.jar.sha1 new file mode 100644 index 000000000000..a9dad164a08e --- /dev/null +++ b/solr/licenses/selenium-api-4.47.0.jar.sha1 @@ -0,0 +1 @@ +2c7f4b4d126fe8b6d8d7051179d7169b2c08a267 diff --git a/solr/licenses/selenium-chrome-driver-4.47.0.jar.sha1 b/solr/licenses/selenium-chrome-driver-4.47.0.jar.sha1 new file mode 100644 index 000000000000..9a548e4ab025 --- /dev/null +++ b/solr/licenses/selenium-chrome-driver-4.47.0.jar.sha1 @@ -0,0 +1 @@ +f88771402b909b60596bf7ace95b9f95747dc9b4 diff --git a/solr/licenses/selenium-chromium-driver-4.47.0.jar.sha1 b/solr/licenses/selenium-chromium-driver-4.47.0.jar.sha1 new file mode 100644 index 000000000000..fc6355ca26bf --- /dev/null +++ b/solr/licenses/selenium-chromium-driver-4.47.0.jar.sha1 @@ -0,0 +1 @@ +a47e90b53289e484d7ecc4131106fa3a05bde93b diff --git a/solr/licenses/selenium-http-4.47.0.jar.sha1 b/solr/licenses/selenium-http-4.47.0.jar.sha1 new file mode 100644 index 000000000000..4a0b5e66b823 --- /dev/null +++ b/solr/licenses/selenium-http-4.47.0.jar.sha1 @@ -0,0 +1 @@ +1195ab7dc6d47fcb03f9d03bee02eba52c9bfe46 diff --git a/solr/licenses/selenium-json-4.47.0.jar.sha1 b/solr/licenses/selenium-json-4.47.0.jar.sha1 new file mode 100644 index 000000000000..a0771021c272 --- /dev/null +++ b/solr/licenses/selenium-json-4.47.0.jar.sha1 @@ -0,0 +1 @@ +d784a24fb2d3acd6514550b02cb5a102ba856dbd diff --git a/solr/licenses/selenium-manager-4.47.0.jar.sha1 b/solr/licenses/selenium-manager-4.47.0.jar.sha1 new file mode 100644 index 000000000000..06eb8e8bf913 --- /dev/null +++ b/solr/licenses/selenium-manager-4.47.0.jar.sha1 @@ -0,0 +1 @@ +1dd28f2d82374c212023584aaa2e006c69104d1b diff --git a/solr/licenses/selenium-os-4.47.0.jar.sha1 b/solr/licenses/selenium-os-4.47.0.jar.sha1 new file mode 100644 index 000000000000..b092eed9e80e --- /dev/null +++ b/solr/licenses/selenium-os-4.47.0.jar.sha1 @@ -0,0 +1 @@ +d6edd474f2dac25168382cf68322b116593f8ade diff --git a/solr/licenses/selenium-remote-driver-4.47.0.jar.sha1 b/solr/licenses/selenium-remote-driver-4.47.0.jar.sha1 new file mode 100644 index 000000000000..843a3ffbd8f5 --- /dev/null +++ b/solr/licenses/selenium-remote-driver-4.47.0.jar.sha1 @@ -0,0 +1 @@ +8a3fa091e488c5a91e1581f6e38ce1c36fd3ec40 diff --git a/solr/modules/analysis-extras/gradle.lockfile b/solr/modules/analysis-extras/gradle.lockfile index 87c45ec45920..ff0665d90d76 100644 --- a/solr/modules/analysis-extras/gradle.lockfile +++ b/solr/modules/analysis-extras/gradle.lockfile @@ -80,7 +80,7 @@ jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=jarValidation,runtimeClasspath,runtimeLibs jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,errorprone,testAnnotationProcessor junit:junit:4.13.2=jarValidation,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.18.9=jarValidation,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.11=jarValidation,testCompileClasspath,testRuntimeClasspath org.antlr:antlr4-runtime:4.13.2=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath org.apache.commons:commons-exec:1.6.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath org.apache.commons:commons-lang3:3.20.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath diff --git a/solr/modules/cross-dc/gradle.lockfile b/solr/modules/cross-dc/gradle.lockfile index 0044e890b0b1..833e59e730bf 100644 --- a/solr/modules/cross-dc/gradle.lockfile +++ b/solr/modules/cross-dc/gradle.lockfile @@ -80,8 +80,8 @@ jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=jarValidation,runtimeClasspath,runtimeLibs jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,errorprone,testAnnotationProcessor junit:junit:4.13.2=jarValidation,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy-agent:1.18.9=jarValidation,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.18.9=jarValidation,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.18.11=jarValidation,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.11=jarValidation,testRuntimeClasspath org.antlr:antlr4-runtime:4.13.2=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath org.apache.commons:commons-exec:1.6.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath org.apache.commons:commons-lang3:3.20.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath diff --git a/solr/modules/jwt-auth/gradle.lockfile b/solr/modules/jwt-auth/gradle.lockfile index b23540782e98..9c67ba29783e 100644 --- a/solr/modules/jwt-auth/gradle.lockfile +++ b/solr/modules/jwt-auth/gradle.lockfile @@ -94,7 +94,7 @@ jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=jarValidation,runtimeClasspath,runtimeLibs jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,errorprone,testAnnotationProcessor junit:junit:4.13.2=jarValidation,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.18.9=jarValidation,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.11=jarValidation,testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.5.2=jarValidation,testCompileClasspath,testRuntimeClasspath net.minidev:json-smart:2.5.2=jarValidation,testCompileClasspath,testRuntimeClasspath no.nav.security:mock-oauth2-server:5.0.1=jarValidation,testCompileClasspath,testRuntimeClasspath diff --git a/solr/modules/ltr/gradle.lockfile b/solr/modules/ltr/gradle.lockfile index 83645788f9d9..31e71c8985d4 100644 --- a/solr/modules/ltr/gradle.lockfile +++ b/solr/modules/ltr/gradle.lockfile @@ -78,7 +78,7 @@ jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=jarValidation,runtimeClasspath,runtimeLibs jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,errorprone,testAnnotationProcessor junit:junit:4.13.2=jarValidation,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.18.9=jarValidation,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.11=jarValidation,testCompileClasspath,testRuntimeClasspath org.antlr:antlr4-runtime:4.13.2=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath org.apache.commons:commons-exec:1.6.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath org.apache.commons:commons-lang3:3.20.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath diff --git a/solr/modules/s3-repository/gradle.lockfile b/solr/modules/s3-repository/gradle.lockfile index e954715ad597..b823fedae1b6 100644 --- a/solr/modules/s3-repository/gradle.lockfile +++ b/solr/modules/s3-repository/gradle.lockfile @@ -92,7 +92,7 @@ jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=jarValidation,runtimeClasspath,runti javax.inject:javax.inject:1=annotationProcessor,errorprone,testAnnotationProcessor joda-time:joda-time:2.14.2=jarValidation,testCompileClasspath,testRuntimeClasspath junit:junit:4.13.2=jarValidation,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.18.9=jarValidation,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.11=jarValidation,testCompileClasspath,testRuntimeClasspath org.antlr:antlr4-runtime:4.13.2=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath org.apache.commons:commons-exec:1.6.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath org.apache.commons:commons-lang3:3.20.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath diff --git a/solr/solrj/gradle.lockfile b/solr/solrj/gradle.lockfile index 3336889d484a..5715d8d99999 100644 --- a/solr/solrj/gradle.lockfile +++ b/solr/solrj/gradle.lockfile @@ -78,7 +78,7 @@ jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=jarValidation,runtimeClasspath,testRuntime jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=jarValidation,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,errorprone,testAnnotationProcessor junit:junit:4.13.2=jarValidation,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.18.9=jarValidation,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.11=jarValidation,testCompileClasspath,testRuntimeClasspath org.antlr:antlr4-runtime:4.13.2=jarValidation,testRuntimeClasspath org.apache.commons:commons-exec:1.6.0=jarValidation,testRuntimeClasspath org.apache.commons:commons-lang3:3.20.0=jarValidation,testRuntimeClasspath diff --git a/solr/test-framework/src/java/org/apache/solr/embedded/JettyConfig.java b/solr/test-framework/src/java/org/apache/solr/embedded/JettyConfig.java index 601c6b5f2b58..20516d7c007b 100644 --- a/solr/test-framework/src/java/org/apache/solr/embedded/JettyConfig.java +++ b/solr/test-framework/src/java/org/apache/solr/embedded/JettyConfig.java @@ -37,6 +37,9 @@ public class JettyConfig { public final boolean enableV2; public final boolean enableGracefulShutdown; + /** If true, serve the Admin UI static files and index.html like the production web.xml does. */ + public final boolean enableAdminUi; + private JettyConfig( boolean onlyHttp1, int port, @@ -47,7 +50,8 @@ private JettyConfig( Map, String> extraFilters, SSLConfig sslConfig, boolean enableV2, - boolean enableGracefulShutdown) { + boolean enableGracefulShutdown, + boolean enableAdminUi) { this.onlyHttp1 = onlyHttp1; this.port = port; this.portRetryTime = portRetryTime; @@ -58,6 +62,7 @@ private JettyConfig( this.sslConfig = sslConfig; this.enableV2 = enableV2; this.enableGracefulShutdown = enableGracefulShutdown; + this.enableAdminUi = enableAdminUi; } public static Builder builder() { @@ -77,6 +82,7 @@ public static Builder builder(JettyConfig other) { builder.sslConfig = other.sslConfig; builder.enableV2 = other.enableV2; builder.enableGracefulShutdown = other.enableGracefulShutdown; + builder.enableAdminUi = other.enableAdminUi; return builder; } @@ -86,6 +92,7 @@ public static class Builder { int port = 0; boolean enableV2 = true; boolean enableGracefulShutdown = false; + boolean enableAdminUi = false; boolean stopAtShutdown = true; Long waitForLoadingCoresToFinishMs = 300000L; Map extraServlets = new TreeMap<>(); @@ -109,6 +116,11 @@ public Builder enableGracefulShutdown(boolean flag) { return this; } + public Builder enableAdminUi(boolean flag) { + this.enableAdminUi = flag; + return this; + } + public Builder setPort(int port) { this.port = port; return this; @@ -165,7 +177,8 @@ public JettyConfig build() { extraFilters, sslConfig, enableV2, - enableGracefulShutdown); + enableGracefulShutdown, + enableAdminUi); } } } diff --git a/solr/test-framework/src/java/org/apache/solr/embedded/JettySolrRunner.java b/solr/test-framework/src/java/org/apache/solr/embedded/JettySolrRunner.java index b27651251f3d..0a0f8554da0c 100644 --- a/solr/test-framework/src/java/org/apache/solr/embedded/JettySolrRunner.java +++ b/solr/test-framework/src/java/org/apache/solr/embedded/JettySolrRunner.java @@ -30,6 +30,8 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Arrays; import java.util.EnumSet; import java.util.List; @@ -54,10 +56,12 @@ import org.apache.solr.metrics.SolrMetricManager; import org.apache.solr.servlet.AuthenticationFilter; import org.apache.solr.servlet.CoreContainerProvider; +import org.apache.solr.servlet.LoadAdminUiServlet; import org.apache.solr.servlet.RateLimitFilter; import org.apache.solr.servlet.RequiredSolrRequestFilter; import org.apache.solr.servlet.SolrServlet; import org.apache.solr.servlet.TracingFilter; +import org.apache.solr.util.ExternalPaths; import org.apache.solr.util.RestTestHarness; import org.apache.solr.util.SocketProxy; import org.apache.solr.util.TimeOut; @@ -65,6 +69,7 @@ import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory; import org.eclipse.jetty.ee10.servlet.FilterHolder; import org.eclipse.jetty.ee10.servlet.FilterMapping; +import org.eclipse.jetty.ee10.servlet.ResourceServlet; import org.eclipse.jetty.ee10.servlet.ServletContextHandler; import org.eclipse.jetty.ee10.servlet.ServletHolder; import org.eclipse.jetty.ee10.servlet.Source; @@ -293,7 +298,19 @@ private void init(int port) { final ServletContextHandler root = new ServletContextHandler("/solr", ServletContextHandler.NO_SESSIONS); root.setServer(server); - root.setBaseResource(ResourceFactory.of(server).newResource(".")); + if (config.enableAdminUi) { + Path webappDir = ExternalPaths.WEBAPP_HOME; + if (webappDir == null || !Files.exists(webappDir.resolve("index.html"))) { + throw new IllegalStateException( + "enableAdminUi requires the Admin UI webapp sources at /solr/webapp/web, " + + "but they could not be located (ExternalPaths.WEBAPP_HOME=" + + webappDir + + ")"); + } + root.setBaseResource(ResourceFactory.of(server).newResource(webappDir)); + } else { + root.setBaseResource(ResourceFactory.of(server).newResource(".")); + } root.addEventListener( // Install CCP first. Subclass CCP to do some pre-initialization new CoreContainerProvider() { @@ -327,6 +344,25 @@ public void contextInitialized(ServletContextEvent event) { // TODO: This needs to be driven by a parsing of web.xml eventually // though we still want to avoid classpath scanning. + if (config.enableAdminUi) { + // Serve the Admin UI like production web.xml does: static assets + LoadAdminUiServlet + ServletHolder staticHolder = root.getServletHandler().newServletHolder(Source.EMBEDDED); + staticHolder.setName("static"); + staticHolder.setHeldClass(ResourceServlet.class); + staticHolder.setInitParameter("pathInfoOnly", "false"); + staticHolder.setInitParameter("dirAllowed", "false"); + for (String pathSpec : + new String[] { + "/partials/*", "/libs/*", "/css/*", "/js/*", "/img/*", "/templates/*", "/ui/*" + }) { + root.addServlet(staticHolder, pathSpec); + } + ServletHolder adminUiHolder = root.getServletHandler().newServletHolder(Source.EMBEDDED); + adminUiHolder.setName("LoadAdminUI"); + adminUiHolder.setHeldClass(LoadAdminUiServlet.class); + root.addServlet(adminUiHolder, "/index.html"); + } + // This is our main workhorse - now a servlet instead of filter solrServlet = root.getServletHandler().newServletHolder(Source.EMBEDDED); solrServlet.setName("SolrServlet"); diff --git a/solr/webapp/build.gradle b/solr/webapp/build.gradle index da2ba2cda085..76dfe22ebf71 100644 --- a/solr/webapp/build.gradle +++ b/solr/webapp/build.gradle @@ -22,6 +22,17 @@ plugins { description = 'Solr webapp' +ext { + // The Selenium-based Admin UI tests execute external binaries (selenium-manager, + // chromedriver) and probe well-known browser install locations plus $PATH and + // $CHROME_BIN for a Chrome binary. Expressing this in the shared solr-tests.policy + // would require FilePermission execute grants on arbitrary, machine-dependent paths + // (effectively <> execute), weakening the sandbox for every test module. + // Following the precedent of the extraction and s3-repository modules, the security + // manager is disabled for this module's tests instead. + useSecurityManager = false +} + configurations { war {} serverLib @@ -43,6 +54,39 @@ dependencies { if (gradle.ext.withUiModule) { generatedUIBundle project(path: ":solr:ui", configuration: "wasmJsUIBundle") } + + // Browser-based tests of the AngularJS Admin UI (see src/test) + testImplementation project(':solr:core') + testImplementation project(':solr:solrj') + testImplementation project(':solr:test-framework') + // puts the /sql handler on the in-JVM server classpath so the SQL screen works + testRuntimeOnly project(':solr:modules:sql') + testImplementation libs.carrotsearch.randomizedtesting.runner + testImplementation libs.eclipse.jetty.ee10.servlet + testImplementation libs.jakarta.servlet.api + testImplementation libs.junit.junit + testImplementation(libs.selenium.chromedriver, { + // solr-core already provides the opentelemetry api/sdk that selenium's optional + // tracing uses; the remaining excludes are compile-time-only annotation jars + exclude group: 'io.opentelemetry' + exclude group: 'io.opentelemetry.instrumentation' + exclude group: 'io.opentelemetry.semconv' + exclude group: 'com.google.auto.service' + exclude group: 'org.jspecify' + }) +} + +// Configure all Test tasks (including the generated beast test_N tasks, which do not +// inherit the default test suite wiring in this war project): +// - test classes/classpath so `gradlew -p solr/webapp beast -Ptests.dups=N` works +// - forward the browser-binary override for the Admin UI tests to the forked test JVM +tasks.withType(Test).configureEach { + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + def chromeBinary = providers.systemProperty('tests.ui.chrome.binary').orNull + if (chromeBinary != null) { + systemProperty 'tests.ui.chrome.binary', chromeBinary + } } war { diff --git a/solr/webapp/gradle.lockfile b/solr/webapp/gradle.lockfile index a562330bd664..2809adaeac87 100644 --- a/solr/webapp/gradle.lockfile +++ b/solr/webapp/gradle.lockfile @@ -2,165 +2,199 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. # To regenerate this file, run: ./gradlew :solr:webapp:dependencies --write-locks -com.carrotsearch:hppc:0.10.0=solrCore -com.fasterxml.jackson.core:jackson-annotations:2.22=solrCore -com.fasterxml.jackson.core:jackson-core:2.22.0=solrCore -com.fasterxml.jackson.core:jackson-databind:2.22.0=solrCore -com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.22.0=solrCore -com.fasterxml.jackson.dataformat:jackson-dataformat-smile:2.22.0=solrCore -com.fasterxml.jackson.module:jackson-module-jakarta-xmlbind-annotations:2.22.0=solrCore -com.fasterxml.jackson:jackson-bom:2.22.0=solrCore -com.fasterxml.woodstox:woodstox-core:7.2.1=solrCore -com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,errorprone,solrCore,testAnnotationProcessor +com.carrotsearch.randomizedtesting:randomizedtesting-runner:2.9.1=jarValidation,testCompileClasspath,testRuntimeClasspath +com.carrotsearch:hppc:0.10.0=jarValidation,solrCore,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.22=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.22.0=jarValidation,solrCore,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.22.0=jarValidation,solrCore,testRuntimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.22.0=jarValidation,solrCore,testRuntimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-smile:2.22.0=jarValidation,solrCore,testRuntimeClasspath +com.fasterxml.jackson.module:jackson-module-jakarta-xmlbind-annotations:2.22.0=jarValidation,solrCore,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.22.0=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +com.fasterxml.woodstox:woodstox-core:7.2.1=jarValidation,solrCore,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,errorprone,jarValidation,solrCore,testAnnotationProcessor,testRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,errorprone,testAnnotationProcessor com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,errorprone,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.11.1=annotationProcessor,errorprone,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,errorprone,testAnnotationProcessor com.google.errorprone:error_prone_annotation:2.41.0=annotationProcessor,errorprone,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.47.0=solrCore +com.google.errorprone:error_prone_annotations:2.47.0=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,errorprone,testAnnotationProcessor com.google.errorprone:error_prone_check_api:2.41.0=annotationProcessor,errorprone,testAnnotationProcessor com.google.errorprone:error_prone_core:2.41.0=annotationProcessor,errorprone,testAnnotationProcessor com.google.googlejavaformat:google-java-format:1.27.0=annotationProcessor,errorprone,testAnnotationProcessor -com.google.guava:failureaccess:1.0.3=annotationProcessor,errorprone,solrCore,testAnnotationProcessor -com.google.guava:guava:33.6.0-jre=annotationProcessor,errorprone,solrCore,testAnnotationProcessor -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,errorprone,solrCore,testAnnotationProcessor -com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,errorprone,solrCore,testAnnotationProcessor -com.google.protobuf:protobuf-java:4.35.1=annotationProcessor,errorprone,testAnnotationProcessor -com.j256.simplemagic:simplemagic:1.17=solrCore -com.jayway.jsonpath:json-path:3.0.0=solrCore +com.google.guava:failureaccess:1.0.3=annotationProcessor,errorprone,jarValidation,solrCore,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:33.6.0-jre=annotationProcessor,errorprone,jarValidation,solrCore,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,errorprone,jarValidation,solrCore,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,errorprone,jarValidation,solrCore,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.protobuf:protobuf-java:4.35.1=annotationProcessor,errorprone,jarValidation,testAnnotationProcessor,testRuntimeClasspath +com.googlecode.json-simple:json-simple:1.1.1=jarValidation,testRuntimeClasspath +com.j256.simplemagic:simplemagic:1.17=jarValidation,solrCore,testRuntimeClasspath +com.jayway.jsonpath:json-path:3.0.0=jarValidation,solrCore,testRuntimeClasspath com.lmax:disruptor:4.0.0=serverLib -com.tdunning:t-digest:3.3=solrCore -commons-cli:commons-cli:1.11.0=solrCore -commons-codec:commons-codec:1.22.0=solrCore -commons-io:commons-io:2.22.0=solrCore -io.dropwizard.metrics:metrics-core:4.2.39=solrCore +com.tdunning:t-digest:3.3=jarValidation,solrCore,testRuntimeClasspath +commons-cli:commons-cli:1.11.0=jarValidation,solrCore,testRuntimeClasspath +commons-codec:commons-codec:1.22.0=jarValidation,solrCore,testRuntimeClasspath +commons-io:commons-io:2.22.0=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,solrCore,testRuntimeClasspath io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor -io.netty:netty-buffer:4.2.15.Final=solrCore -io.netty:netty-codec-base:4.2.15.Final=solrCore -io.netty:netty-common:4.2.15.Final=solrCore -io.netty:netty-handler:4.2.15.Final=solrCore -io.netty:netty-resolver:4.2.15.Final=solrCore -io.netty:netty-tcnative-boringssl-static:2.0.79.Final=solrCore -io.netty:netty-tcnative-classes:2.0.79.Final=solrCore -io.netty:netty-transport-classes-epoll:4.2.15.Final=solrCore -io.netty:netty-transport-native-epoll:4.2.15.Final=solrCore -io.netty:netty-transport-native-unix-common:4.2.15.Final=solrCore -io.netty:netty-transport:4.2.15.Final=solrCore -io.opentelemetry.instrumentation:opentelemetry-instrumentation-api-incubator:2.27.0-alpha=solrCore -io.opentelemetry.instrumentation:opentelemetry-instrumentation-api:2.27.0=solrCore -io.opentelemetry.instrumentation:opentelemetry-runtime-telemetry-java17:2.27.0-alpha=solrCore -io.opentelemetry.instrumentation:opentelemetry-runtime-telemetry:2.27.0-alpha=solrCore -io.opentelemetry.semconv:opentelemetry-semconv:1.40.0=solrCore -io.opentelemetry:opentelemetry-api-incubator:1.61.0-alpha=solrCore -io.opentelemetry:opentelemetry-api:1.63.0=solrCore -io.opentelemetry:opentelemetry-common:1.63.0=solrCore -io.opentelemetry:opentelemetry-context:1.63.0=solrCore -io.opentelemetry:opentelemetry-exporter-prometheus:1.63.0-alpha=solrCore -io.opentelemetry:opentelemetry-sdk-common:1.63.0=solrCore -io.opentelemetry:opentelemetry-sdk-metrics:1.63.0=solrCore -io.opentelemetry:opentelemetry-sdk-trace:1.63.0=solrCore -io.opentelemetry:opentelemetry-sdk:1.63.0=solrCore -io.prometheus:prometheus-metrics-config:1.8.0=solrCore -io.prometheus:prometheus-metrics-exposition-formats:1.8.0=solrCore -io.prometheus:prometheus-metrics-exposition-textformats:1.8.0=solrCore -io.prometheus:prometheus-metrics-model:1.8.0=solrCore -io.sgr:s2-geometry-library-java:1.0.0=solrCore -io.swagger.core.v3:swagger-annotations-jakarta:2.2.52=solrCore -jakarta.activation:jakarta.activation-api:2.1.3=solrCore -jakarta.annotation:jakarta.annotation-api:3.0.0=solrCore -jakarta.inject:jakarta.inject-api:2.0.1=solrCore -jakarta.servlet:jakarta.servlet-api:6.1.0=serverLib,solrCore -jakarta.validation:jakarta.validation-api:3.1.0=solrCore -jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=solrCore -jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=solrCore +io.netty:netty-buffer:4.2.15.Final=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-base:4.2.15.Final=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +io.netty:netty-common:4.2.15.Final=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +io.netty:netty-handler:4.2.15.Final=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +io.netty:netty-resolver:4.2.15.Final=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +io.netty:netty-tcnative-boringssl-static:2.0.79.Final=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +io.netty:netty-tcnative-classes:2.0.79.Final=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport-classes-epoll:4.2.15.Final=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport-native-epoll:4.2.15.Final=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.15.Final=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport:4.2.15.Final=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +io.opentelemetry.instrumentation:opentelemetry-instrumentation-api-incubator:2.27.0-alpha=jarValidation,solrCore,testRuntimeClasspath +io.opentelemetry.instrumentation:opentelemetry-instrumentation-api:2.27.0=jarValidation,solrCore,testRuntimeClasspath +io.opentelemetry.instrumentation:opentelemetry-runtime-telemetry-java17:2.27.0-alpha=jarValidation,solrCore,testRuntimeClasspath +io.opentelemetry.instrumentation:opentelemetry-runtime-telemetry:2.27.0-alpha=jarValidation,solrCore,testRuntimeClasspath +io.opentelemetry.semconv:opentelemetry-semconv:1.40.0=jarValidation,solrCore,testRuntimeClasspath +io.opentelemetry:opentelemetry-api-incubator:1.61.0-alpha=jarValidation,solrCore,testRuntimeClasspath +io.opentelemetry:opentelemetry-api:1.63.0=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-common:1.63.0=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-context:1.63.0=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-prometheus:1.63.0-alpha=jarValidation,solrCore,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-common:1.63.0=jarValidation,solrCore,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-metrics:1.63.0=jarValidation,solrCore,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-trace:1.63.0=jarValidation,solrCore,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk:1.63.0=jarValidation,solrCore,testRuntimeClasspath +io.prometheus:prometheus-metrics-config:1.8.0=jarValidation,solrCore,testRuntimeClasspath +io.prometheus:prometheus-metrics-exposition-formats:1.8.0=jarValidation,solrCore,testRuntimeClasspath +io.prometheus:prometheus-metrics-exposition-textformats:1.8.0=jarValidation,solrCore,testRuntimeClasspath +io.prometheus:prometheus-metrics-model:1.8.0=jarValidation,solrCore,testRuntimeClasspath +io.sgr:s2-geometry-library-java:1.0.0=jarValidation,solrCore,testRuntimeClasspath +io.swagger.core.v3:swagger-annotations-jakarta:2.2.52=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.3=jarValidation,solrCore,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=jarValidation,solrCore,testRuntimeClasspath +jakarta.inject:jakarta.inject-api:2.0.1=jarValidation,solrCore,testRuntimeClasspath +jakarta.servlet:jakarta.servlet-api:6.1.0=jarValidation,serverLib,solrCore,testCompileClasspath,testRuntimeClasspath +jakarta.validation:jakarta.validation-api:3.1.0=jarValidation,solrCore,testRuntimeClasspath +jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=jarValidation,solrCore,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=jarValidation,solrCore,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,errorprone,testAnnotationProcessor -org.antlr:antlr4-runtime:4.13.2=solrCore -org.apache.commons:commons-exec:1.6.0=solrCore -org.apache.commons:commons-lang3:3.20.0=solrCore -org.apache.commons:commons-math3:3.6.1=solrCore -org.apache.curator:curator-client:5.9.0=solrCore -org.apache.curator:curator-framework:5.9.0=solrCore +junit:junit:4.13.2=jarValidation,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.11=jarValidation,testCompileClasspath,testRuntimeClasspath +org.antlr:antlr4-runtime:4.13.2=jarValidation,solrCore,testRuntimeClasspath +org.apache.calcite.avatica:avatica-core:1.25.0=jarValidation,testRuntimeClasspath +org.apache.calcite.avatica:avatica-metrics:1.25.0=jarValidation,testRuntimeClasspath +org.apache.calcite:calcite-core:1.37.0=jarValidation,testRuntimeClasspath +org.apache.calcite:calcite-linq4j:1.37.0=jarValidation,testRuntimeClasspath +org.apache.commons:commons-exec:1.6.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.commons:commons-math3:3.6.1=jarValidation,solrCore,testRuntimeClasspath +org.apache.commons:commons-text:1.15.0=jarValidation,testRuntimeClasspath +org.apache.curator:curator-client:5.9.0=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +org.apache.curator:curator-framework:5.9.0=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +org.apache.curator:curator-test:5.9.0=jarValidation,testRuntimeClasspath +org.apache.httpcomponents.client5:httpclient5:5.2.1=jarValidation,testRuntimeClasspath +org.apache.httpcomponents.core5:httpcore5-h2:5.2=jarValidation,testRuntimeClasspath +org.apache.httpcomponents.core5:httpcore5:5.2.3=jarValidation,testRuntimeClasspath org.apache.logging.log4j:log4j-1.2-api:2.26.0=serverLib -org.apache.logging.log4j:log4j-api:2.26.0=serverLib,solrCore -org.apache.logging.log4j:log4j-core:2.26.0=serverLib,solrCore +org.apache.logging.log4j:log4j-api:2.26.0=jarValidation,serverLib,solrCore,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.26.0=jarValidation,serverLib,solrCore,testRuntimeClasspath org.apache.logging.log4j:log4j-layout-template-json:2.26.0=serverLib -org.apache.logging.log4j:log4j-slf4j2-impl:2.26.0=serverLib,solrCore +org.apache.logging.log4j:log4j-slf4j2-impl:2.26.0=jarValidation,serverLib,solrCore,testRuntimeClasspath org.apache.logging.log4j:log4j-web:2.26.0=serverLib -org.apache.lucene:lucene-analysis-common:10.4.0=solrCore -org.apache.lucene:lucene-analysis-kuromoji:10.4.0=solrCore -org.apache.lucene:lucene-analysis-nori:10.4.0=solrCore -org.apache.lucene:lucene-analysis-phonetic:10.4.0=solrCore -org.apache.lucene:lucene-backward-codecs:10.4.0=solrCore -org.apache.lucene:lucene-classification:10.4.0=solrCore -org.apache.lucene:lucene-codecs:10.4.0=solrCore -org.apache.lucene:lucene-core:10.4.0=solrCore -org.apache.lucene:lucene-expressions:10.4.0=solrCore -org.apache.lucene:lucene-facet:10.4.0=solrCore -org.apache.lucene:lucene-grouping:10.4.0=solrCore -org.apache.lucene:lucene-highlighter:10.4.0=solrCore -org.apache.lucene:lucene-join:10.4.0=solrCore -org.apache.lucene:lucene-memory:10.4.0=solrCore -org.apache.lucene:lucene-misc:10.4.0=solrCore -org.apache.lucene:lucene-queries:10.4.0=solrCore -org.apache.lucene:lucene-queryparser:10.4.0=solrCore -org.apache.lucene:lucene-sandbox:10.4.0=solrCore -org.apache.lucene:lucene-spatial-extras:10.4.0=solrCore -org.apache.lucene:lucene-spatial3d:10.4.0=solrCore -org.apache.lucene:lucene-suggest:10.4.0=solrCore -org.apache.zookeeper:zookeeper-jute:3.9.5=solrCore -org.apache.zookeeper:zookeeper:3.9.5=solrCore -org.codehaus.woodstox:stax2-api:4.3.0=solrCore -org.eclipse.jetty.compression:jetty-compression-common:12.1.10=solrCore -org.eclipse.jetty.compression:jetty-compression-gzip:12.1.10=solrCore -org.eclipse.jetty.ee10:jetty-ee10-servlet:12.1.10=serverLib +org.apache.lucene:lucene-analysis-common:10.4.0=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +org.apache.lucene:lucene-analysis-kuromoji:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-analysis-nori:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-analysis-phonetic:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-backward-codecs:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-classification:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-codecs:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-core:10.4.0=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +org.apache.lucene:lucene-expressions:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-facet:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-grouping:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-highlighter:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-join:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-memory:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-misc:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-queries:10.4.0=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +org.apache.lucene:lucene-queryparser:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-sandbox:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-spatial-extras:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-spatial3d:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-suggest:10.4.0=jarValidation,solrCore,testRuntimeClasspath +org.apache.lucene:lucene-test-framework:10.4.0=jarValidation,testCompileClasspath,testRuntimeClasspath +org.apache.zookeeper:zookeeper-jute:3.9.5=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +org.apache.zookeeper:zookeeper:3.9.5=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=jarValidation,testRuntimeClasspath +org.checkerframework:checker-qual:4.2.0=jarValidation,testRuntimeClasspath +org.codehaus.janino:commons-compiler:3.1.11=jarValidation,testRuntimeClasspath +org.codehaus.janino:janino:3.1.11=jarValidation,testRuntimeClasspath +org.codehaus.woodstox:stax2-api:4.3.0=jarValidation,solrCore,testRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-common:12.1.10=jarValidation,solrCore,testRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-gzip:12.1.10=jarValidation,solrCore,testRuntimeClasspath +org.eclipse.jetty.ee10:jetty-ee10-servlet:12.1.10=jarValidation,serverLib,testCompileClasspath,testRuntimeClasspath org.eclipse.jetty.ee10:jetty-ee10-servlets:12.1.10=serverLib org.eclipse.jetty.ee10:jetty-ee10-webapp:12.1.10=serverLib org.eclipse.jetty.ee:jetty-ee-webapp:12.1.10=serverLib -org.eclipse.jetty.http2:jetty-http2-client-transport:12.1.10=solrCore -org.eclipse.jetty.http2:jetty-http2-client:12.1.10=solrCore -org.eclipse.jetty.http2:jetty-http2-common:12.1.10=serverLib,solrCore -org.eclipse.jetty.http2:jetty-http2-hpack:12.1.10=serverLib,solrCore -org.eclipse.jetty.http2:jetty-http2-server:12.1.10=serverLib -org.eclipse.jetty:jetty-alpn-client:12.1.10=solrCore -org.eclipse.jetty:jetty-alpn-java-client:12.1.10=solrCore -org.eclipse.jetty:jetty-alpn-java-server:12.1.10=serverLib -org.eclipse.jetty:jetty-alpn-server:12.1.10=serverLib -org.eclipse.jetty:jetty-client:12.1.10=solrCore +org.eclipse.jetty.http2:jetty-http2-client-transport:12.1.10=jarValidation,solrCore,testRuntimeClasspath +org.eclipse.jetty.http2:jetty-http2-client:12.1.10=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty.http2:jetty-http2-common:12.1.10=jarValidation,serverLib,solrCore,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty.http2:jetty-http2-hpack:12.1.10=jarValidation,serverLib,solrCore,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty.http2:jetty-http2-server:12.1.10=jarValidation,serverLib,testRuntimeClasspath +org.eclipse.jetty:jetty-alpn-client:12.1.10=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-alpn-java-client:12.1.10=jarValidation,solrCore,testRuntimeClasspath +org.eclipse.jetty:jetty-alpn-java-server:12.1.10=jarValidation,serverLib,testRuntimeClasspath +org.eclipse.jetty:jetty-alpn-server:12.1.10=jarValidation,serverLib,testRuntimeClasspath +org.eclipse.jetty:jetty-client:12.1.10=jarValidation,solrCore,testRuntimeClasspath org.eclipse.jetty:jetty-deploy:12.1.10=serverLib -org.eclipse.jetty:jetty-http:12.1.10=serverLib,solrCore -org.eclipse.jetty:jetty-io:12.1.10=serverLib,solrCore +org.eclipse.jetty:jetty-http:12.1.10=jarValidation,serverLib,solrCore,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-io:12.1.10=jarValidation,serverLib,solrCore,testCompileClasspath,testRuntimeClasspath org.eclipse.jetty:jetty-jmx:12.1.10=serverLib -org.eclipse.jetty:jetty-rewrite:12.1.10=serverLib -org.eclipse.jetty:jetty-security:12.1.10=serverLib,solrCore -org.eclipse.jetty:jetty-server:12.1.10=serverLib,solrCore -org.eclipse.jetty:jetty-session:12.1.10=serverLib -org.eclipse.jetty:jetty-util:12.1.10=serverLib,solrCore +org.eclipse.jetty:jetty-rewrite:12.1.10=jarValidation,serverLib,testRuntimeClasspath +org.eclipse.jetty:jetty-security:12.1.10=jarValidation,serverLib,solrCore,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-server:12.1.10=jarValidation,serverLib,solrCore,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-session:12.1.10=jarValidation,serverLib,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-util:12.1.10=jarValidation,serverLib,solrCore,testCompileClasspath,testRuntimeClasspath org.eclipse.jetty:jetty-xml:12.1.10=serverLib -org.glassfish.hk2.external:aopalliance-repackaged:4.0.1=solrCore -org.glassfish.hk2:hk2-api:4.0.1=solrCore -org.glassfish.hk2:hk2-locator:4.0.1=solrCore -org.glassfish.hk2:hk2-utils:4.0.1=solrCore -org.glassfish.hk2:osgi-resource-locator:3.0.0=solrCore -org.glassfish.jersey.containers:jersey-container-jetty-http:4.0.2=solrCore -org.glassfish.jersey.core:jersey-client:4.0.2=solrCore -org.glassfish.jersey.core:jersey-common:4.0.2=solrCore -org.glassfish.jersey.core:jersey-server:4.0.2=solrCore -org.glassfish.jersey.ext:jersey-entity-filtering:4.0.2=solrCore -org.glassfish.jersey.inject:jersey-hk2:4.0.2=solrCore -org.glassfish.jersey.media:jersey-media-json-jackson:4.0.2=solrCore -org.glassfish.jersey:jersey-bom:4.0.2=solrCore -org.javassist:javassist:3.30.2-GA=solrCore -org.jspecify:jspecify:1.0.0=annotationProcessor,errorprone,solrCore,testAnnotationProcessor -org.locationtech.spatial4j:spatial4j:0.8=solrCore -org.ow2.asm:asm-commons:9.10.1=solrCore -org.ow2.asm:asm-tree:9.10.1=solrCore -org.ow2.asm:asm:9.10.1=solrCore +org.glassfish.hk2.external:aopalliance-repackaged:4.0.1=jarValidation,solrCore,testRuntimeClasspath +org.glassfish.hk2:hk2-api:4.0.1=jarValidation,solrCore,testRuntimeClasspath +org.glassfish.hk2:hk2-locator:4.0.1=jarValidation,solrCore,testRuntimeClasspath +org.glassfish.hk2:hk2-utils:4.0.1=jarValidation,solrCore,testRuntimeClasspath +org.glassfish.hk2:osgi-resource-locator:3.0.0=jarValidation,solrCore,testRuntimeClasspath +org.glassfish.jersey.containers:jersey-container-jetty-http:4.0.2=jarValidation,solrCore,testRuntimeClasspath +org.glassfish.jersey.core:jersey-client:4.0.2=jarValidation,solrCore,testRuntimeClasspath +org.glassfish.jersey.core:jersey-common:4.0.2=jarValidation,solrCore,testRuntimeClasspath +org.glassfish.jersey.core:jersey-server:4.0.2=jarValidation,solrCore,testRuntimeClasspath +org.glassfish.jersey.ext:jersey-entity-filtering:4.0.2=jarValidation,solrCore,testRuntimeClasspath +org.glassfish.jersey.inject:jersey-hk2:4.0.2=jarValidation,solrCore,testRuntimeClasspath +org.glassfish.jersey.media:jersey-media-json-jackson:4.0.2=jarValidation,solrCore,testRuntimeClasspath +org.glassfish.jersey:jersey-bom:4.0.2=jarValidation,solrCore,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=jarValidation,testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.30.2-GA=jarValidation,solrCore,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,errorprone,jarValidation,solrCore,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.6.2=jarValidation,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.6.2=jarValidation,testRuntimeClasspath +org.junit:junit-bom:5.6.2=jarValidation,testRuntimeClasspath +org.locationtech.jts.io:jts-io-common:1.19.0=jarValidation,testRuntimeClasspath +org.locationtech.jts:jts-core:1.19.0=jarValidation,testRuntimeClasspath +org.locationtech.proj4j:proj4j:1.2.2=jarValidation,testRuntimeClasspath +org.locationtech.spatial4j:spatial4j:0.8=jarValidation,solrCore,testRuntimeClasspath +org.opentest4j:opentest4j:1.2.0=jarValidation,testRuntimeClasspath +org.ow2.asm:asm-commons:9.10.1=jarValidation,solrCore,testRuntimeClasspath +org.ow2.asm:asm-tree:9.10.1=jarValidation,solrCore,testRuntimeClasspath +org.ow2.asm:asm:9.10.1=jarValidation,solrCore,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,errorprone,testAnnotationProcessor -org.semver4j:semver4j:6.0.0=solrCore -org.slf4j:jcl-over-slf4j:2.0.17=serverLib,solrCore +org.seleniumhq.selenium:selenium-api:4.47.0=jarValidation,testCompileClasspath,testRuntimeClasspath +org.seleniumhq.selenium:selenium-chrome-driver:4.47.0=jarValidation,testCompileClasspath,testRuntimeClasspath +org.seleniumhq.selenium:selenium-chromium-driver:4.47.0=jarValidation,testCompileClasspath,testRuntimeClasspath +org.seleniumhq.selenium:selenium-http:4.47.0=jarValidation,testCompileClasspath,testRuntimeClasspath +org.seleniumhq.selenium:selenium-json:4.47.0=jarValidation,testCompileClasspath,testRuntimeClasspath +org.seleniumhq.selenium:selenium-manager:4.47.0=jarValidation,testCompileClasspath,testRuntimeClasspath +org.seleniumhq.selenium:selenium-os:4.47.0=jarValidation,testCompileClasspath,testRuntimeClasspath +org.seleniumhq.selenium:selenium-remote-driver:4.47.0=jarValidation,testCompileClasspath,testRuntimeClasspath +org.semver4j:semver4j:6.0.0=jarValidation,solrCore,testRuntimeClasspath +org.slf4j:jcl-over-slf4j:2.0.17=jarValidation,serverLib,solrCore,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.17=serverLib -org.slf4j:slf4j-api:2.0.17=serverLib,solrCore -org.xerial.snappy:snappy-java:1.1.10.8=solrCore -empty=compileClasspath,generatedJSClientBundle,generatedUIBundle,jarValidation,missingdoclet,providedCompile,providedRuntime,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,war +org.slf4j:slf4j-api:2.0.17=jarValidation,serverLib,solrCore,testCompileClasspath,testRuntimeClasspath +org.xerial.snappy:snappy-java:1.1.10.8=jarValidation,solrCore,testRuntimeClasspath +empty=compileClasspath,generatedJSClientBundle,generatedUIBundle,missingdoclet,providedCompile,providedRuntime,runtimeClasspath,war diff --git a/solr/webapp/src/test-files/log4j2.xml b/solr/webapp/src/test-files/log4j2.xml new file mode 100644 index 000000000000..3e941d535daf --- /dev/null +++ b/solr/webapp/src/test-files/log4j2.xml @@ -0,0 +1,40 @@ + + + + + + + + + %maxLen{%-4r %-5p (%t) [%notEmpty{n:%X{node_name}}%notEmpty{ c:%X{collection}}%notEmpty{ s:%X{shard}}%notEmpty{ r:%X{replica}}%notEmpty{ x:%X{core}}%notEmpty{ t:%X{trace_id}}] %c{1.} %m}{10240}%n + + + + + + + + + + + + + + + + diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCollectionScreensTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCollectionScreensTest.java new file mode 100644 index 000000000000..85b813ea9d52 --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCollectionScreensTest.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.common.SolrInputDocument; +import org.junit.BeforeClass; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +/** + * Verifies the per-collection analysis/files/segments/plugins/overview screens against a fixture + * collection with indexed documents. Screens with their own write actions have dedicated test + * classes (query, documents, schema, paramsets). + */ +public class AdminUiCollectionScreensTest extends AdminUiTestBase { + + private static final String COLLECTION = "books"; + private static final int NUM_DOCS = 4; + + @BeforeClass + public static void setupCollection() throws Exception { + createFixtureCollection(COLLECTION, 1, 1); + SolrClient client = cluster.getSolrClient(COLLECTION); + for (int i = 1; i <= NUM_DOCS; i++) { + SolrInputDocument doc = new SolrInputDocument(); + doc.addField("id", Integer.toString(i)); + doc.addField("title_txt", "Book number " + i); + client.add(doc); + } + client.commit(); + } + + @Test + public void testAnalysisScreenAnalyzesText() { + openPage(COLLECTION + "/analysis", By.id("analysis-holder")); + chosenSelect("type_or_name", "text_general"); + WebElement indexText = waitFor(By.id("analysis_fieldvalue_index")); + indexText.clear(); + indexText.sendKeys("Running QUICKLY"); + waitFor(By.cssSelector("#field-analysis button[type=submit]")).click(); + // text_general tokenizes and lowercases + waitForPageContains("running"); + waitForPageContains("quickly"); + assertNoSevereConsoleErrors(); + } + + @Test + public void testFilesScreenShowsConfig() { + openPage(COLLECTION + "/files", By.id("files")); + waitForPageContains("solrconfig.xml"); + // open the file and check its content is rendered + openPage(COLLECTION + "/files?file=solrconfig.xml", By.id("files")); + waitForPageContains("luceneMatchVersion"); + assertNoSevereConsoleErrors(); + } + + @Test + public void testSegmentsScreenShowsSegments() throws Exception { + String coreName = coreNameOnNode0(COLLECTION); + openPage(coreName + "/segments", By.id("segments")); + waitUntil( + "at least one segment should render after committing docs", + () -> !driver.findElements(By.cssSelector("#segments #response li")).isEmpty()); + assertNoSevereConsoleErrors(); + } + + @Test + public void testPluginsScreenShowsStats() { + String coreName = coreNameOnNode0(COLLECTION); + openPage(coreName + "/plugins", By.id("plugins")); + waitForPageContains("searcher"); + assertNoSevereConsoleErrors(); + } + + @Test + public void testCollectionOverviewShowsShard() { + openPage(COLLECTION + "/collection-overview", By.id("dashboard")); + waitForPageContains("shard1"); + assertNoSevereConsoleErrors(); + } + + @Test + public void testCoreOverviewShowsStats() { + String coreName = coreNameOnNode0(COLLECTION); + openPage(coreName + "/core-overview", By.id("dashboard")); + waitForPageContains("Num Docs"); + waitForPageContains(Integer.toString(NUM_DOCS)); + // the ping widget answers 503 when the configset has no healthcheck file + assertNoSevereConsoleErrors("/admin/ping"); + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCollectionsScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCollectionsScreenTest.java new file mode 100644 index 000000000000..c974a0bccab7 --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCollectionsScreenTest.java @@ -0,0 +1,203 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import java.util.List; +import java.util.Map; +import org.apache.solr.client.solrj.request.CollectionAdminRequest; +import org.apache.solr.client.solrj.response.CollectionAdminResponse; +import org.junit.BeforeClass; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +/** + * Tests the Collections screen ({@code #/~collections}): display of collection details, and the + * write actions offered by the screen - create/delete collection, aliases, replicas and reload. + */ +public class AdminUiCollectionsScreenTest extends AdminUiTestBase { + + private static final String COLLECTION = "collscreen"; + + @BeforeClass + public static void setupCollection() throws Exception { + createFixtureCollection(COLLECTION, 1, 1); + } + + @Test + public void testCollectionDetailDisplay() { + openPage("~collections/" + COLLECTION, By.id("collections")); + waitForPageContains(COLLECTION); + waitForPageContains("shard1"); + assertNoSevereConsoleErrors(); + } + + @Test + public void testCreateAndDeleteCollectionViaUi() throws Exception { + String name = "uicreated"; + openPage("~collections", By.id("collections")); + + // create through the Add Collection dialog + waitFor(By.cssSelector("#navigation button#add")).click(); + WebElement nameInput = waitFor(By.id("add_name")); + nameInput.clear(); + nameInput.sendKeys(name); + chosenSelect("add_config", COLLECTION); + WebElement numShards = waitFor(By.id("add_numShards")); + numShards.clear(); + numShards.sendKeys("1"); + WebElement replicationFactor = waitFor(By.id("add_replicationFactor")); + replicationFactor.clear(); + replicationFactor.sendKeys("1"); + waitFor(By.xpath("//button[@ng-click='addCollection()']")).click(); + + // the new collection shows up in the list, and the API confirms it + waitForPageContains(name); + assertCollectionExists(name, true); + + // delete it through the delete dialog, which requires typing the name to confirm + openPage("~collections/" + name, By.id("collections")); + waitFor(By.id("delete-collection")).click(); + WebElement confirmInput = waitFor(By.id("collectionDeleteConfirm")); + confirmInput.clear(); + confirmInput.sendKeys(name); + waitFor(By.xpath("//button[@ng-click='deleteCollection()']")).click(); + + assertCollectionExists(name, false); + assertNoSevereConsoleErrors(); + } + + @Test + public void testCreateAndDeleteAliasViaUi() throws Exception { + String alias = "uialias"; + openPage("~collections", By.id("collections")); + + // the Create Alias button stays disabled until the collection list has loaded + waitForPageContains(COLLECTION); + waitFor(By.cssSelector("button#create-alias:not([disabled])")).click(); + WebElement aliasInput = waitFor(By.id("alias")); + aliasInput.clear(); + aliasInput.sendKeys(alias); + // the collections picker is a plain multi-select; click the option directly + waitFor(By.xpath("//select[@id='aliasCollections']/option[text()='" + COLLECTION + "']")) + .click(); + waitFor(By.xpath("//button[@ng-click='createAlias()']")).click(); + + waitUntil( + "alias " + alias + " should exist", + () -> listAliases().getOrDefault(alias, "").equals(COLLECTION)); + + // aliases are listed with an alias_ route prefix + openPage("~collections/alias_" + alias, By.id("collections")); + waitFor(By.id("delete-alias")).click(); + waitFor(By.xpath("//button[@ng-click='deleteAlias()']")).click(); + + waitUntil("alias " + alias + " should be gone", () -> !listAliases().containsKey(alias)); + assertNoSevereConsoleErrors(); + } + + @Test + public void testAddAndDeleteReplicaViaUi() throws Exception { + openPage("~collections/" + COLLECTION, By.id("collections")); + + // expand shard1 and open the add-replica form + waitFor(By.xpath("//div[@id='shard-data']//a[contains(., 'shard1')]")).click(); + waitFor(By.id("add-replica")).click(); + waitFor(By.xpath("//button[@ng-click='addReplica(shard)']")).click(); + + waitUntil("second replica should appear", () -> replicaCount() == 2); + + // delete the added replica: expand it and confirm removal within the same replica block + driver.navigate().refresh(); + waitFor(By.xpath("//div[@id='shard-data']//a[contains(., 'shard1')]")).click(); + waitFor(By.xpath("//a[@ng-click='toggleRemoveReplica(replica)']")); + List removeToggles = + driver.findElements(By.xpath("//a[@ng-click='toggleRemoveReplica(replica)']")); + assertEquals("Expected a remove toggle per replica", 2, removeToggles.size()); + WebElement toggle = removeToggles.get(1); + toggle.click(); + toggle + .findElement( + By.xpath( + "ancestor::ul[contains(@class,'replica')][1]" + + "//button[@ng-click='deleteReplica(replica)']")) + .click(); + + waitUntil("replica should be removed again", () -> replicaCount() == 1); + assertNoSevereConsoleErrors(); + } + + @Test + public void testReloadCollectionViaUi() throws Exception { + // reloading resets the core's start time; that proves the action end-to-end, + // unlike the UI success indicator which only flashes for a second + String coreName = coreNameOnNode0(COLLECTION); + Object startTimeBefore = coreStartTime(coreName); + + openPage("~collections/" + COLLECTION, By.id("collections")); + waitFor(By.id("reload")).click(); + waitUntil( + "core start time should change after reload", + () -> !startTimeBefore.equals(coreStartTime(coreName))); + assertNoSevereConsoleErrors(); + } + + private void assertCollectionExists(String name, boolean expectExists) throws Exception { + waitUntil( + "collection " + name + " should " + (expectExists ? "exist" : "not exist"), + () -> { + try { + return CollectionAdminRequest.listCollections(cluster.getSolrClient()).contains(name) + == expectExists; + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + private Map listAliases() { + try { + CollectionAdminResponse response = + new CollectionAdminRequest.ListAliases().process(cluster.getSolrClient()); + return response.getAliases(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private Object coreStartTime(String coreName) { + try { + return adminApi("/admin/cores", params("core", coreName)) + ._get(List.of("status", coreName, "startTime"), null); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private int replicaCount() { + try { + return cluster + .getSolrClient() + .getClusterState() + .getCollection(COLLECTION) + .getReplicas() + .size(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCoreAdminScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCoreAdminScreenTest.java new file mode 100644 index 000000000000..71ec933fbb5c --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCoreAdminScreenTest.java @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import java.util.Map; +import org.apache.solr.common.util.NamedList; +import org.junit.BeforeClass; +import org.junit.Test; +import org.openqa.selenium.By; + +/** Tests the Core Admin screen: core listing and the reload action. */ +public class AdminUiCoreAdminScreenTest extends AdminUiTestBase { + + private static final String COLLECTION = "corescoll"; + + @BeforeClass + public static void setupCollection() throws Exception { + createFixtureCollection(COLLECTION, 1, 1); + } + + @Test + public void testCoreAdminShowsCore() throws Exception { + NamedList response = adminApi("/admin/cores", params()); + Map status = (Map) response.get("status"); + assertFalse("Node should host at least one core", status.isEmpty()); + String coreName = status.keySet().iterator().next().toString(); + + openPage("~cores", By.id("cores")); + waitForPageContains(coreName); + assertNoSevereConsoleErrors(); + } + + @Test + public void testReloadCoreViaUi() { + String coreName = coreNameOnNode0(COLLECTION); + openPage("~cores/" + coreName, By.id("cores")); + waitFor(By.cssSelector("#cores #reload")).click(); + // the button is marked with the success class when the reload succeeded + waitFor(By.cssSelector("#cores #reload.success")); + assertNoSevereConsoleErrors(); + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCoreAdminStandaloneTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCoreAdminStandaloneTest.java new file mode 100644 index 000000000000..1ebcee9bed4e --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCoreAdminStandaloneTest.java @@ -0,0 +1,145 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import java.nio.file.Path; +import java.util.Map; +import org.apache.solr.client.solrj.request.SolrQuery; +import org.apache.solr.common.SolrInputDocument; +import org.apache.solr.common.util.NamedList; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.openqa.selenium.By; + +/** + * Tests the Core Admin screen's write actions - add, rename, swap and unload - which only apply to + * a standalone (user-managed) Solr node; in cloud mode these operations belong to the Collections + * API. Also asserts the standalone-mode differences of the UI menus. + */ +public class AdminUiCoreAdminStandaloneTest extends AdminUiStandaloneTestBase { + + private static Path home; + + @BeforeClass + public static void startStandaloneNode() throws Exception { + home = buildStandaloneHome("renamecore", "swapa", "swapb", "unloadcore"); + // instance dir for the add-core test; the core is created through the UI + createStandaloneCoreDir(home, "addedcore"); + standaloneJetty = startStandaloneJetty(home); + baseUrl = standaloneJetty.getBaseUrl().toString(); + } + + @AfterClass + public static void stopStandaloneNode() throws Exception { + if (standaloneJetty != null) { + standaloneJetty.stop(); + standaloneJetty = null; + } + } + + @Test + public void testStandaloneMenus() { + openPage("", By.id("index")); + // cloud-only menu entries are absent in standalone mode + assertTrue(driver.findElements(By.cssSelector("#menu .cloud")).isEmpty()); + assertTrue(driver.findElements(By.cssSelector("#menu .collections")).isEmpty()); + // the per-core menu (shown when a core page is open) offers the core-level + // screens directly - query and replication are standalone-only entries + openPage("swapa/core-overview", By.id("dashboard")); + waitFor(By.cssSelector("#core-menu .query")); + waitFor(By.cssSelector("#core-menu .replication")); + // the ping widget answers 503 when the configset has no healthcheck file + assertNoSevereConsoleErrors("/admin/ping"); + } + + @Test + public void testAddCoreViaUi() throws Exception { + openPage("~cores", By.id("cores")); + waitFor(By.cssSelector("#cores #add")).click(); + setText(By.id("add_name"), "addedcore"); + setText(By.id("add_instanceDir"), "addedcore"); + waitFor(By.xpath("//button[@ng-click='addCore()']")).click(); + + waitUntil("core addedcore should exist", () -> coreExists("addedcore")); + waitForPageContains("addedcore"); + assertNoSevereConsoleErrors(); + } + + @Test + public void testRenameCoreViaUi() throws Exception { + openPage("~cores/renamecore", By.id("cores")); + waitFor(By.cssSelector("#cores #rename")).click(); + setText(By.id("rename_other"), "renamedcore"); + waitFor(By.xpath("//button[@ng-click='renameCore()']")).click(); + + waitUntil( + "core should be renamed", () -> coreExists("renamedcore") && !coreExists("renamecore")); + assertNoSevereConsoleErrors(); + } + + @Test + public void testSwapCoresViaUi() throws Exception { + // make the cores distinguishable: swapa gets one document, swapb stays empty + try (var client = standaloneJetty.newClient()) { + var doc = new SolrInputDocument(); + doc.addField("id", "swap-doc"); + client.add("swapa", doc); + client.commit("swapa"); + } + assertEquals(1, numDocs("swapa")); + assertEquals(0, numDocs("swapb")); + + openPage("~cores/swapa", By.id("cores")); + waitFor(By.cssSelector("#cores #swap")).click(); + // pick the other core in the swap-with dropdown (plain select) + waitFor(By.id("swap_other")).sendKeys("swapb"); + waitFor(By.xpath("//button[@ng-click='swapCores()']")).click(); + + waitUntil("swap should exchange the cores' indexes", () -> numDocs("swapb") == 1); + assertEquals(0, numDocs("swapa")); + assertNoSevereConsoleErrors(); + } + + @Test + public void testUnloadCoreViaUi() throws Exception { + openPage("~cores/unloadcore", By.id("cores")); + waitFor(By.cssSelector("#cores #unload")).click(); + // unload asks for confirmation via a native browser dialog + driver.switchTo().alert().accept(); + + waitUntil("core unloadcore should be gone", () -> !coreExists("unloadcore")); + assertNoSevereConsoleErrors(); + } + + private boolean coreExists(String coreName) { + try { + NamedList response = adminApi("/admin/cores", params()); + return ((Map) response.get("status")).containsKey(coreName); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private long numDocs(String coreName) { + try (var client = standaloneJetty.newClient()) { + return client.query(coreName, new SolrQuery("*:*")).getResults().getNumFound(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiDashboardTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiDashboardTest.java new file mode 100644 index 000000000000..2ec93391acf2 --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiDashboardTest.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import java.util.Map; +import org.apache.solr.common.util.NamedList; +import org.junit.Test; +import org.openqa.selenium.By; + +/** + * Tests that the Admin UI dashboard (route {@code #/}) renders the correct Solr version and system + * stats, compared against the {@code /admin/info/system} API the screen is built from. + */ +public class AdminUiDashboardTest extends AdminUiTestBase { + + @Test + public void testDashboardShowsVersionsAndSystemStats() throws Exception { + NamedList system = adminApi("/admin/info/system", params()); + Map lucene = (Map) system.get("lucene"); + Map jvm = (Map) system.get("jvm"); + + openPage("", By.id("index")); + + // Versions block matches the API values + String solrSpecVersion = (String) lucene.get("solr-spec-version"); + assertEquals(solrSpecVersion, waitForText(By.cssSelector("#versions .solr_spec_version dd"))); + String luceneSpecVersion = (String) lucene.get("lucene-spec-version"); + assertEquals( + luceneSpecVersion, waitForText(By.cssSelector("#versions .lucene_spec_version dd"))); + + // JVM block shows the runtime name and version + String jvmText = waitForText(By.cssSelector("#jvm .jvm_version dd")); + assertEquals(jvm.get("name") + " " + jvm.get("version"), jvmText); + + // JVM memory bar is rendered with a non-empty max value + String jvmMemoryMax = waitForText(By.cssSelector("#jvm-memory-bar .bar-max.val")); + assertFalse("JVM memory bar should show a max value", jvmMemoryMax.isBlank()); + + // Security block warns that security is not enabled on this vanilla cluster + String securityText = waitFor(By.cssSelector("#security .warning-msg")).getText(); + assertTrue( + "Expected security warning, got: " + securityText, + securityText.contains("Security is not enabled")); + + assertNoSevereConsoleErrors(); + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiDocumentsScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiDocumentsScreenTest.java new file mode 100644 index 000000000000..f6db1224384d --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiDocumentsScreenTest.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import java.util.List; +import java.util.stream.Collectors; +import org.apache.solr.client.solrj.request.SolrQuery; +import org.junit.BeforeClass; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +/** Tests the Documents screen: the indexing form and submitting documents through it. */ +public class AdminUiDocumentsScreenTest extends AdminUiTestBase { + + private static final String COLLECTION = "docscoll"; + + @BeforeClass + public static void setupCollection() throws Exception { + createFixtureCollection(COLLECTION, 1, 1); + } + + @Test + public void testDocumentsScreenForm() { + openPage(COLLECTION + "/documents", By.id("documents")); + List types = + driver.findElements(By.cssSelector("#document-type option")).stream() + .map(WebElement::getText) + .collect(Collectors.toList()); + assertTrue("Doc type dropdown should offer JSON, got " + types, types.contains("JSON")); + assertTrue("Doc type dropdown should offer XML, got " + types, types.contains("XML")); + assertTrue("Doc type dropdown should offer CSV, got " + types, types.contains("CSV")); + waitFor(By.id("submit")); + assertNoSevereConsoleErrors(); + } + + @Test + public void testIndexDocumentViaUi() throws Exception { + openPage(COLLECTION + "/documents", By.id("documents")); + WebElement docInput = waitFor(By.id("document")); + docInput.clear(); + docInput.sendKeys("{\"id\":\"ui-doc-1\",\"title_txt\":\"indexed from the admin ui\"}"); + waitFor(By.id("submit")).click(); + waitForTextContains(By.cssSelector("#documents #result"), "success"); + + // the document becomes searchable (the form defaults to commitWithin=1000) + waitUntil( + "document indexed via UI should be searchable", + () -> { + try { + return cluster + .getSolrClient(COLLECTION) + .query(new SolrQuery("id:ui-doc-1")) + .getResults() + .getNumFound() + == 1; + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + assertNoSevereConsoleErrors(); + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiLoggingScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiLoggingScreenTest.java new file mode 100644 index 000000000000..2fee9698d1d7 --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiLoggingScreenTest.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import java.lang.invoke.MethodHandles; +import java.util.List; +import java.util.Map; +import org.apache.solr.common.util.NamedList; +import org.junit.Assume; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Tests the Logging screens: the recent-events viewer and the log level editor. */ +public class AdminUiLoggingScreenTest extends AdminUiTestBase { + + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + @Test + public void testLoggingLevelTree() { + openPage("~logging/level", By.id("loggingtree")); + waitForPageContains("org.apache.solr"); + waitFor(By.cssSelector("#loggingtree .jstree-anchor")); + assertNoSevereConsoleErrors(); + } + + @Test + public void testEventsViewerShowsWarnings() throws Exception { + // start the cluster before emitting the probe: the log watcher only exists once the + // nodes are up, and with lazy startup this test may be the first cluster user + ensureCloudCluster(); + + // the cluster nodes run in this JVM, so the log watcher observes our own log events + String probeMessage = "Admin UI logging viewer probe event"; + log.warn(probeMessage); + + // all test clusters in this JVM register a log-watcher appender under the same name + // in the shared log4j config, so this cluster's watcher may be blind when other UI + // test classes ran first; only assert the UI when the backing API sees the event + boolean watcherSawProbe = false; + long deadlineNanos = System.nanoTime() + WAIT_TIMEOUT.toNanos(); + while (System.nanoTime() < deadlineNanos && !watcherSawProbe) { + watcherSawProbe = + adminApi("/admin/info/logging", params("since", "0")).toString().contains(probeMessage); + Thread.sleep(250); + } + Assume.assumeTrue( + "This node's log watcher does not receive events (shared-JVM log4j state); skipping", + watcherSawProbe); + + openPage("~logging", By.id("viewer")); + waitUntil( + "probe event should appear in the viewer", + () -> { + driver.navigate().refresh(); + waitFor(By.id("viewer")); + return driver.getPageSource().contains(probeMessage); + }); + assertNoSevereConsoleErrors(); + } + + @Test + public void testChangeLogLevelViaUi() throws Exception { + String logger = "org.apache.solr.core"; + openPage("~logging/level", By.id("loggingtree")); + + WebElement anchor = + waitFor(By.cssSelector("#loggingtree a.jstree-anchor[title='" + logger + "']")); + anchor.click(); + waitFor(By.xpath("//li[a/@title='" + logger + "']//a[normalize-space()='WARN']")).click(); + assertLoggerLevel(logger, "WARN"); + + // revert to unset; the logger then reports the inherited level with set=false + waitFor(By.cssSelector("#loggingtree a.jstree-anchor[title='" + logger + "']")).click(); + waitFor(By.xpath("//li[a/@title='" + logger + "']//a[normalize-space()='UNSET']")).click(); + assertLoggerLevel(logger, null); + assertNoSevereConsoleErrors(); + } + + /** + * Asserts the level a logger was explicitly set to, or with {@code expectedLevel} null, that the + * logger has no explicit level (it then reports the inherited effective level with set=false). + */ + @SuppressWarnings("unchecked") + private void assertLoggerLevel(String logger, String expectedLevel) throws Exception { + waitUntil( + "logger " + logger + " has level " + (expectedLevel == null ? "(unset)" : expectedLevel), + () -> { + try { + NamedList response = adminApi("/admin/info/logging", params()); + for (Map entry : (List>) response.get("loggers")) { + if (logger.equals(entry.get("name"))) { + return expectedLevel == null + ? Boolean.FALSE.equals(entry.get("set")) + : expectedLevel.equals(entry.get("level")) + && Boolean.TRUE.equals(entry.get("set")); + } + } + return false; + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiNodeScreensTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiNodeScreensTest.java new file mode 100644 index 000000000000..681815f6b83c --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiNodeScreensTest.java @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import java.util.List; +import java.util.Map; +import org.apache.solr.common.util.NamedList; +import org.junit.BeforeClass; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +/** + * Verifies the data displayed on the node-level Admin UI screens (java properties, thread dump, + * cloud views, security, login) against the backing APIs. + */ +public class AdminUiNodeScreensTest extends AdminUiTestBase { + + private static final String COLLECTION = "nodescoll"; + + @BeforeClass + public static void setupCollection() throws Exception { + createFixtureCollection(COLLECTION, 1, 2); + } + + @Test + public void testJavaPropertiesMatchApi() throws Exception { + NamedList response = adminApi("/admin/info/properties", params()); + Map props = (Map) response.get("system.properties"); + String expectedJavaVersion = (String) props.get("java.version"); + + openPage("~java-properties", By.id("java-properties")); + waitFor(By.cssSelector("#java-properties li")); + // find the row for java.version and compare its value; the UI inserts zero-width + // spaces (​) into names and values for line wrapping, so strip them + String value = null; + for (WebElement row : driver.findElements(By.cssSelector("#java-properties li"))) { + String name = row.findElement(By.cssSelector("dt")).getText().replace("​", ""); + if (name.equals("java.version")) { + value = row.findElement(By.cssSelector("dd")).getText().replace("​", ""); + } + } + assertEquals(expectedJavaVersion, value); + } + + @Test + public void testThreadDumpShowsThreads() { + openPage("~threads", By.id("thread-dump")); + waitFor(By.cssSelector("#thread-dump tbody tr")); + List rows = driver.findElements(By.cssSelector("#thread-dump tbody tr")); + assertFalse("Thread dump should list threads", rows.isEmpty()); + // a Jetty worker thread is always present in a running Solr node + waitForPageContains("qtp"); + assertNoSevereConsoleErrors(); + } + + @Test + public void testCloudNodesListsAllNodes() { + openPage("~cloud?view=nodes", By.id("nodes-table")); + // both cluster nodes run on the same host, shown as two node rows + waitForPageContains("Hosts 1 - 1 of 1"); + List nodeNames = driver.findElements(By.cssSelector("#nodes-table .node-name")); + assertEquals("Expected one row per live node", 2, nodeNames.size()); + for (var jetty : cluster.getJettySolrRunners()) { + waitForPageContains(":" + jetty.getLocalPort()); + } + assertNoSevereConsoleErrors(); + } + + @Test + public void testCloudTreeShowsZkNodes() { + openPage("~cloud?view=tree", By.id("tree-content")); + waitForPageContains("live_nodes"); + waitForPageContains("collections"); + assertNoSevereConsoleErrors(); + } + + @Test + public void testCloudGraphShowsReplicas() throws Exception { + openPage("~cloud?view=graph", By.id("graph-content")); + // the d3 tree renders one circle per zk/collection/shard/replica node; the fixture + // collection has two replicas, so expect at least: root + collection + shard + 2 replicas + waitUntil( + "graph should render circles", + () -> driver.findElements(By.cssSelector("#graph-content svg circle")).size() >= 5); + waitForPageContains(COLLECTION); + waitForPageContains("shard1"); + assertNoSevereConsoleErrors(); + } + + @Test + public void testCloudZkStatusShowsEnsemble() { + openPage("~cloud?view=zkstatus", By.id("zk-status-content")); + // the embedded test ensemble is a single standalone zookeeper reported green + waitForTextContains(By.cssSelector(".zookeeper-status"), "green"); + waitForPageContains("Ensemble size: 1"); + assertNoSevereConsoleErrors(); + } + + @Test + public void testSecurityScreenWarnsNotEnabled() { + openPage("~security", By.id("securityPanel")); + waitForPageContains("Security is not enabled"); + assertNoSevereConsoleErrors(); + } + + @Test + public void testLoginScreenWithoutAuthentication() { + openPage("login", By.id("login")); + waitForPageContains("uthentication"); + assertNoSevereConsoleErrors(); + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiParamsetsScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiParamsetsScreenTest.java new file mode 100644 index 000000000000..b6360d353670 --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiParamsetsScreenTest.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import java.util.List; +import org.junit.BeforeClass; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +/** Tests the Paramsets screen: form display and creating/deleting a paramset through the UI. */ +public class AdminUiParamsetsScreenTest extends AdminUiTestBase { + + private static final String COLLECTION = "paramscoll"; + + @BeforeClass + public static void setupCollection() throws Exception { + createFixtureCollection(COLLECTION, 1, 1); + } + + @Test + public void testParamsetsScreenRenders() { + openPage(COLLECTION + "/paramsets", By.id("paramsets")); + waitFor(By.cssSelector("#paramsets #form")); + assertNoSevereConsoleErrors(); + } + + @Test + public void testCreateAndDeleteParamsetViaUi() throws Exception { + String paramset = "uiparams"; + openPage(COLLECTION + "/paramsets", By.id("paramsets")); + + WebElement content = waitFor(By.id("paramsetContent")); + content.clear(); + content.sendKeys("{\"set\":{\"" + paramset + "\":{\"rows\":\"7\",\"df\":\"title_txt\"}}}"); + waitFor(By.cssSelector("#paramsets #submit")).click(); + waitForTextContains(By.cssSelector("#paramsets #result"), "success"); + + waitUntil("paramset should exist with rows=7", () -> paramsetRows(paramset).equals("7")); + + // select the paramset and delete it + openPage(COLLECTION + "/paramsets?paramset=" + paramset, By.id("paramsets")); + waitFor(By.cssSelector("button#delete-paramset")).click(); + waitUntil("paramset should be gone", () -> paramsetRows(paramset).isEmpty()); + assertNoSevereConsoleErrors(); + } + + /** Returns the rows param of the paramset, or empty string when absent. */ + private String paramsetRows(String name) { + try { + Object rows = + adminApi("/" + COLLECTION + "/config/params/" + name, params()) + ._get(List.of("response", "params", name, "rows"), ""); + return rows == null ? "" : rows.toString(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiQueryScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiQueryScreenTest.java new file mode 100644 index 000000000000..e590419734a6 --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiQueryScreenTest.java @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.SolrRequest; +import org.apache.solr.client.solrj.request.GenericSolrRequest; +import org.apache.solr.client.solrj.request.RequestWriter; +import org.apache.solr.common.SolrInputDocument; +import org.apache.solr.common.params.CommonParams; +import org.junit.BeforeClass; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +/** Tests the Query screen: executing queries through the form and its parameter fields. */ +public class AdminUiQueryScreenTest extends AdminUiTestBase { + + private static final String COLLECTION = "querycoll"; + private static final int NUM_DOCS = 4; + + @BeforeClass + public static void setupCollection() throws Exception { + createFixtureCollection(COLLECTION, 1, 1); + SolrClient client = cluster.getSolrClient(COLLECTION); + for (int i = 1; i <= NUM_DOCS; i++) { + SolrInputDocument doc = new SolrInputDocument(); + doc.addField("id", Integer.toString(i)); + doc.addField("title_txt", "Book number " + i); + client.add(doc); + } + client.commit(); + } + + @Test + public void testQueryScreenExecutesQueries() { + openPage(COLLECTION + "/query", By.id("query")); + + // default *:* query finds all documents + waitFor(By.cssSelector("#query button[type=submit]")).click(); + waitForTextContains(By.cssSelector("#query #response"), "\"numFound\":" + NUM_DOCS); + + // a specific id query finds exactly one document + WebElement queryInput = waitFor(By.id("q")); + queryInput.clear(); + queryInput.sendKeys("id:1"); + waitFor(By.cssSelector("#query button[type=submit]")).click(); + waitForTextContains(By.cssSelector("#query #response"), "\"numFound\":1"); + assertNoSevereConsoleErrors(); + } + + @Test + public void testRowsAndFieldListParameters() { + openPage(COLLECTION + "/query", By.id("query")); + + WebElement rows = waitFor(By.id("rows")); + rows.clear(); + rows.sendKeys("2"); + WebElement fl = waitFor(By.id("fl")); + fl.clear(); + fl.sendKeys("id"); + waitFor(By.cssSelector("#query button[type=submit]")).click(); + + String response = + waitForTextContains(By.cssSelector("#query #response"), "\"numFound\":" + NUM_DOCS); + // only two docs are returned, and only their id field + assertEquals("Expected 2 returned docs: " + response, 2, countOccurrences(response, "\"id\":")); + assertFalse("fl=id should exclude other fields: " + response, response.contains("title_txt")); + assertNoSevereConsoleErrors(); + } + + @Test + public void testParamsetDropdown() throws Exception { + // create a paramset via the API, then apply it through the useParams dropdown + String paramset = "uiqueryparams"; + GenericSolrRequest setParams = + new GenericSolrRequest( + SolrRequest.METHOD.POST, "/" + COLLECTION + "/config/params", params()); + setParams.setContentWriter( + new RequestWriter.StringPayloadContentWriter( + "{\"set\":{\"" + paramset + "\":{\"rows\":\"2\"}}}", CommonParams.JSON_MIME)); + try (SolrClient client = cluster.getJettySolrRunner(0).newClient()) { + client.request(setParams); + } + + openPage(COLLECTION + "/query", By.id("query")); + chosenSelect("useParams", paramset); + waitFor(By.cssSelector("#query button[type=submit]")).click(); + + String response = + waitForTextContains(By.cssSelector("#query #response"), "\"numFound\":" + NUM_DOCS); + assertEquals( + "Paramset rows=2 should limit returned docs: " + response, + 2, + countOccurrences(response, "\"id\":")); + assertNoSevereConsoleErrors(); + } + + @Test + public void testEdismaxToggle() { + openPage(COLLECTION + "/query", By.id("query")); + + // the qf field only shows once a dismax parser is selected + assertFalse( + "qf should be hidden for the default parser", + driver.findElement(By.id("qf")).isDisplayed()); + waitFor(By.id("defType")).sendKeys("edismax"); + WebElement qf = waitFor(By.id("qf")); + qf.sendKeys("title_txt"); + WebElement queryInput = waitFor(By.id("q")); + queryInput.clear(); + queryInput.sendKeys("number"); + waitFor(By.cssSelector("#query button[type=submit]")).click(); + + // all fixture docs match "number" in title_txt via the edismax qf + String response = + waitForTextContains(By.cssSelector("#query #response"), "\"numFound\":" + NUM_DOCS); + assertTrue("Response should echo defType", response.contains("edismax")); + + // the edismax-only uf field is offered, dismax hides it again + assertTrue("uf should show for edismax", driver.findElement(By.id("uf")).isDisplayed()); + waitFor(By.id("defType")).sendKeys("dismax"); + assertFalse("uf should hide for dismax", driver.findElement(By.id("uf")).isDisplayed()); + assertNoSevereConsoleErrors(); + } + + @Test + public void testRawQueryParameters() { + openPage(COLLECTION + "/query", By.id("query")); + + waitFor(By.cssSelector("#custom_parameters input[name=rawParamQuery]")).sendKeys("fq=id:2"); + waitFor(By.cssSelector("#query button[type=submit]")).click(); + + String response = waitForTextContains(By.cssSelector("#query #response"), "\"numFound\":1"); + assertTrue("The raw fq param should be echoed: " + response, response.contains("id:2")); + assertNoSevereConsoleErrors(); + } + + private static int countOccurrences(String haystack, String needle) { + int count = 0; + int idx = 0; + while ((idx = haystack.indexOf(needle, idx)) >= 0) { + count++; + idx += needle.length(); + } + return count; + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiReplicationScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiReplicationScreenTest.java new file mode 100644 index 000000000000..568d7c8b814a --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiReplicationScreenTest.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.openqa.selenium.By; + +/** + * Tests the Replication screen in cloud mode. The screen targets standalone leader/follower + * replication; in cloud mode it should still render the core's index version information. + * Standalone-mode coverage (replicate-now, enable/disable polling) needs a non-cloud harness and is + * not covered here. + */ +public class AdminUiReplicationScreenTest extends AdminUiTestBase { + + private static final String COLLECTION = "replcoll"; + + @BeforeClass + public static void setupCollection() throws Exception { + createFixtureCollection(COLLECTION, 1, 1); + } + + @Test + public void testReplicationScreenRenders() { + String coreName = coreNameOnNode0(COLLECTION); + openPage(coreName + "/replication", By.id("replication")); + // the details block shows the index version table + waitForPageContains("Version"); + assertNoSevereConsoleErrors(); + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiReplicationStandaloneTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiReplicationStandaloneTest.java new file mode 100644 index 000000000000..f10ddbf51b58 --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiReplicationStandaloneTest.java @@ -0,0 +1,159 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Properties; +import org.apache.solr.client.solrj.request.SolrQuery; +import org.apache.solr.common.SolrInputDocument; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.embedded.JettyConfig; +import org.apache.solr.embedded.JettySolrRunner; +import org.apache.solr.util.ExternalPaths; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.openqa.selenium.By; + +/** + * Tests the Replication screen on a standalone leader/follower pair: the follower's screen shows + * the leader info, and the polling and replicate-now actions work. + */ +public class AdminUiReplicationStandaloneTest extends AdminUiStandaloneTestBase { + + private static final String CORE = "collection1"; + private static final Path REPLICATION_CONF = + ExternalPaths.SOURCE_HOME.resolve("core/src/test-files/solr/collection1/conf"); + + private static JettySolrRunner leaderJetty; + + @BeforeClass + public static void startLeaderAndFollower() throws Exception { + // sets the solr.tests.* index-config properties the test solrconfigs require + newRandomConfig(); + // the follower's leaderUrl is not covered by the URL allow-list + systemSetPropertyEnableUrlAllowList(false); + + Path leaderHome = buildReplicationHome("solrconfig-leader.xml", 0); + leaderJetty = new JettySolrRunner(leaderHome.toString(), JettyConfig.builder().build()); + leaderJetty.start(); + + Path followerHome = buildReplicationHome("solrconfig-follower.xml", leaderJetty.getLocalPort()); + standaloneJetty = startStandaloneJetty(followerHome); + baseUrl = standaloneJetty.getBaseUrl().toString(); + } + + @AfterClass + public static void stopLeaderAndFollower() throws Exception { + if (standaloneJetty != null) { + standaloneJetty.stop(); + standaloneJetty = null; + } + if (leaderJetty != null) { + leaderJetty.stop(); + leaderJetty = null; + } + } + + @Test + public void testReplicationScreenAndActions() throws Exception { + openPage(CORE + "/replication", By.id("replication")); + // the follower screen shows its own and the leader's index version info + waitForPageContains("Version"); + // scraping the rendered DOM for the leader's port is flaky: the "leader url:" row + // can lag the rest of the screen's data on the very first load. Assert against the + // API response instead, like the isPollingDisabled/followerNumDocs checks below do. + waitUntil( + "follower should report the leader's url", + () -> followerDetail("leaderUrl").contains(":" + leaderJetty.getLocalPort())); + + // disable polling so replication only happens on demand + waitFor(By.cssSelector("#replication button.disable-polling")).click(); + waitUntil( + "polling should be disabled", () -> "true".equals(followerDetail("isPollingDisabled"))); + + // index documents on the leader; the follower does not poll them + try (var client = leaderJetty.newClient()) { + for (int i = 1; i <= 2; i++) { + SolrInputDocument doc = new SolrInputDocument(); + doc.addField("id", "repl-doc-" + i); + doc.addField("name", "replicated"); + client.add(CORE, doc); + } + client.commit(CORE); + } + + // replicate on demand and watch the docs arrive on the follower + waitFor(By.cssSelector("#replication button.replicate-now")).click(); + waitUntil("follower should receive the docs after replicate-now", () -> followerNumDocs() == 2); + + // re-enable polling + waitFor(By.cssSelector("#replication button.enable-polling")).click(); + waitUntil( + "polling should be enabled again", + () -> "false".equals(followerDetail("isPollingDisabled"))); + assertNoSevereConsoleErrors(); + } + + /** Builds a home with one core configured from the given test solrconfig variant. */ + private static Path buildReplicationHome(String solrconfigName, int leaderPort) + throws IOException { + Path home = createTempDir("replication-home"); + Files.copy( + ExternalPaths.SOURCE_HOME.resolve("core/src/test-files/solr/solr.xml"), + home.resolve("solr.xml")); + Path confDir = home.resolve(CORE).resolve("conf"); + Files.createDirectories(confDir); + String solrconfig = Files.readString(REPLICATION_CONF.resolve(solrconfigName)); + solrconfig = + solrconfig + .replace("TEST_PORT", Integer.toString(leaderPort)) + .replace("COMPRESSION", "internal"); + Files.writeString(confDir.resolve("solrconfig.xml"), solrconfig); + Files.copy(REPLICATION_CONF.resolve("schema-replication1.xml"), confDir.resolve("schema.xml")); + Files.copy( + REPLICATION_CONF.resolve("solrconfig.snippet.randomindexconfig.xml"), + confDir.resolve("solrconfig.snippet.randomindexconfig.xml")); + Properties props = new Properties(); + props.setProperty("name", CORE); + writeCoreProperties(home.resolve(CORE), props, CORE); + return home; + } + + /** Reads a detail from the follower section of the replication details API. */ + private String followerDetail(String key) { + try { + NamedList response = + adminApi("/" + CORE + "/replication", params("command", "details")); + Object value = response._get(List.of("details", "follower", key), null); + return value == null ? "" : value.toString(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private long followerNumDocs() { + try (var client = standaloneJetty.newClient()) { + return client.query(CORE, new SolrQuery("*:*")).getResults().getNumFound(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSchemaDesignerTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSchemaDesignerTest.java new file mode 100644 index 000000000000..319c1d459526 --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSchemaDesignerTest.java @@ -0,0 +1,80 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import org.apache.lucene.tests.util.LuceneTestCase; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +/** + * Happy-path test of the Schema Designer screen: create a new schema, paste a sample document and + * let the designer analyze it. + * + *

AwaitsFix: the designer backend transiently fails its own prep/analyze calls ("version + * mismatch, retry", "Error loading solr config") when driven at automation speed, making this test + * flaky even with retries. + */ +@LuceneTestCase.AwaitsFix(bugUrl = "https://issues.apache.org/jira/browse/SOLR-18347") +public class AdminUiSchemaDesignerTest extends AdminUiTestBase { + + @Test + public void testDesignSchemaFromSampleDocument() throws Exception { + openPage("~schema-designer", By.id("designer")); + + // create a new schema via the dialog + waitFor(By.cssSelector("#designer #add")).click(); + WebElement schemaName = waitFor(By.id("add_schema")); + schemaName.clear(); + schemaName.sendKeys("uidesigned"); + waitFor(By.xpath("//button[@ng-click='addSchema()']")).click(); + + // paste a sample document and analyze it + WebElement sampleDocs = waitFor(By.cssSelector("#sample-docs textarea#document")); + sampleDocs.clear(); + sampleDocs.sendKeys("[{\"id\":\"1\",\"designer_title\":\"Hello Designer\"}]"); + waitFor(By.id("analyze")).click(); + + // the analyzed schema lists the field derived from the sample doc. The designer + // backend transiently fails its own calls ("version mismatch, retry", "Error + // loading solr config") and surfaces an error dialog - dismiss it and analyze + // again, with a generous budget since each round trips several requests + long deadlineNanos = System.nanoTime() + WAIT_TIMEOUT.multipliedBy(3).toNanos(); + boolean analyzed = false; + while (!analyzed && System.nanoTime() < deadlineNanos) { + analyzed = driver.getPageSource().contains("designer_title"); + if (!analyzed) { + for (String dismissButton : new String[] {"Reload Schema", "OK"}) { + driver.findElements(By.xpath("//button[contains(., '" + dismissButton + "')]")).stream() + .filter(WebElement::isDisplayed) + .findFirst() + .ifPresent(WebElement::click); + } + driver.findElements(By.id("analyze")).stream() + .filter(WebElement::isDisplayed) + .findFirst() + .ifPresent(WebElement::click); + Thread.sleep(500); + } + } + assertTrue("Analyzed schema should list the sample doc field", analyzed); + // the designer's own API calls (prep/analyze/luke against its temp core) error + // transiently while it persists and reloads the schema - it recovers via its retry + // dialog, so only unrelated console errors fail the test + assertNoSevereConsoleErrors("schema-designer/", "._designer_"); + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSchemaScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSchemaScreenTest.java new file mode 100644 index 000000000000..d89c8464e4be --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSchemaScreenTest.java @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import java.util.List; +import java.util.Map; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.common.SolrInputDocument; +import org.apache.solr.common.util.NamedList; +import org.junit.BeforeClass; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +/** + * Tests the Schema Browser screen: browsing fields and their flags, term info, and adding/deleting + * a field through the UI dialogs (the fixture uses a mutable managed schema). + */ +public class AdminUiSchemaScreenTest extends AdminUiTestBase { + + private static final String COLLECTION = "schemacoll"; + + @BeforeClass + public static void setupCollection() throws Exception { + createFixtureCollection(COLLECTION, 1, 1); + SolrClient client = cluster.getSolrClient(COLLECTION); + for (int i = 1; i <= 3; i++) { + SolrInputDocument doc = new SolrInputDocument(); + doc.addField("id", "doc" + i); + client.add(doc); + } + client.commit(); + } + + @Test + public void testSchemaBrowserShowsFields() { + openPage(COLLECTION + "/schema", By.id("schema")); + // managed schema is editable, so the action buttons are shown + waitFor(By.id("addField")); + // known fields from the _default configset are browsable + waitForPageContains("_version_"); + assertNoSevereConsoleErrors(); + } + + @Test + public void testFieldDetailShowsFlagsAndTermInfo() throws Exception { + // the id field of _default is indexed, stored and required per the schema API + NamedList response = adminApi("/" + COLLECTION + "/schema/fields/id", params()); + Map field = (Map) response.get("field"); + assertEquals(Boolean.TRUE, field.get("indexed")); + assertEquals(Boolean.TRUE, field.get("stored")); + + openPage(COLLECTION + "/schema?field=id", By.id("schema")); + // the detail header shows the selected field name + waitForTextContains(By.cssSelector("#schema span.name"), "id"); + // the flags matrix lists these properties for the field + waitForPageContains("Indexed"); + waitForPageContains("Stored"); + + // term info for the populated id field shows the indexed terms + waitFor(By.xpath("//button[@ng-click='toggleTerms()']")).click(); + waitForPageContains("doc1"); + assertNoSevereConsoleErrors(); + } + + @Test + public void testAddAndDeleteFieldViaUi() throws Exception { + String fieldName = "ui_added_field"; + openPage(COLLECTION + "/schema", By.id("schema")); + + waitFor(By.id("addField")).click(); + WebElement nameInput = waitFor(By.id("add_name")); + nameInput.clear(); + nameInput.sendKeys(fieldName); + chosenSelect("add_type", "string"); + waitFor(By.xpath("//button[@ng-click='addField()']")).click(); + + waitUntil("field " + fieldName + " should exist in schema", () -> fieldExists(fieldName)); + + // delete it again from the field detail view + openPage(COLLECTION + "/schema?field=" + fieldName, By.id("schema")); + waitForTextContains(By.cssSelector("#schema span.name"), fieldName); + waitFor(By.xpath("//dd[contains(@class,'delete-field')]/button")).click(); + waitFor(By.xpath("//div[contains(@class,'delete')]//button[@ng-click='delete()']")).click(); + + waitUntil("field " + fieldName + " should be gone", () -> !fieldExists(fieldName)); + assertNoSevereConsoleErrors(); + } + + @SuppressWarnings("unchecked") + private boolean fieldExists(String fieldName) { + try { + NamedList response = adminApi("/" + COLLECTION + "/schema/fields", params()); + List> fields = (List>) response.get("fields"); + return fields.stream().anyMatch(f -> fieldName.equals(f.get("name"))); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSecurityAuthTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSecurityAuthTest.java new file mode 100644 index 000000000000..da71832e4384 --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSecurityAuthTest.java @@ -0,0 +1,178 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import java.util.List; +import java.util.Map; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.SolrRequest; +import org.apache.solr.client.solrj.request.GenericSolrRequest; +import org.apache.solr.common.util.NamedList; +import org.junit.BeforeClass; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.WebElement; + +/** + * Tests the Admin UI with BasicAuth enabled: the login screen flow and the Security screen, + * including adding a user through the UI dialog. + */ +public class AdminUiSecurityAuthTest extends AdminUiTestBase { + + private static final String USER = "solr"; + private static final String PASS = "SolrRocks"; + + @BeforeClass + public static void setSecurityConfig() { + // consumed by AdminUiTestBase when the cluster starts lazily on first use + securityJson = + "{\n" + + " \"authentication\": {\n" + + " \"blockUnknown\": true,\n" + + " \"class\": \"solr.BasicAuthPlugin\",\n" + + " \"credentials\": {\"solr\": \"IV0EHq1OnNrj6gvRCwvFwTrZ1+z1oBbnQdiVC3otuq0=" + + " Ndd7LKvVBAaZIF0QAVi1ekCfAJXr1GGfLtRUXhgrF8c=\"}\n" + + " },\n" + + " \"authorization\": {\n" + + " \"class\": \"solr.RuleBasedAuthorizationPlugin\",\n" + + " \"permissions\": [{\"name\": \"security-edit\", \"role\": \"admin\"},\n" + + " {\"name\": \"all\", \"role\": \"admin\"}],\n" + + " \"user-role\": {\"solr\": \"admin\"}\n" + + " }\n" + + "}"; + } + + @Test + public void testLoginAndSecurityScreen() throws Exception { + // an unauthenticated visit is redirected to the login screen + openPage("", By.id("login")); + WebElement username = waitFor(By.id("username")); + username.clear(); + username.sendKeys(USER); + WebElement password = waitFor(By.id("password")); + password.clear(); + password.sendKeys(PASS); + waitFor(By.xpath("//div[@id='login']//button[@type='submit']")).click(); + + // after login the dashboard loads and shows the authenticated security info + waitFor(By.id("index")); + waitForPageContains("BasicAuthPlugin"); + + // the security screen shows the configured plugins and users + openPage("~security", By.id("securityPanel")); + waitForPageContains("BasicAuthPlugin"); + waitForPageContains("RuleBasedAuthorizationPlugin"); + waitForPageContains(USER); + + // add a user through the dialog. The dialog is driven via the controller scope: + // native clicks/keystrokes into this absolutely-positioned dialog proved unreliable + // in headless mode, and per-keystroke entry is already covered by the login form. + String newUser = "uitestuser"; + String newUserPass = "Uitest!Pass99"; + waitFor(By.id("add-user")); + ((JavascriptExecutor) driver) + .executeScript( + "var scope = angular.element(document.getElementById('add-user')).scope();" + + " scope.showAddUserDialog(); scope.$apply();"); + waitFor(By.id("add_user")); + ((JavascriptExecutor) driver) + .executeScript( + "var scope = angular.element(document.getElementById('add_user')).scope();" + + " scope.upsertUser = {username: arguments[0], password: arguments[1]," + + " password2: arguments[1]};" + + " scope.doUpsertUser(); scope.$apply();", + newUser, + newUserPass); + + waitUntil("user " + newUser + " should exist", () -> userExists(newUser)); + // the users list refreshes to include the new user + waitForPageContains(newUser); + + // add a role for the new user through the role dialog + String newRole = "uitestrole"; + waitFor(By.id("add-role")); + ((JavascriptExecutor) driver) + .executeScript( + "var scope = angular.element(document.getElementById('add-role')).scope();" + + " scope.showAddRoleDialog();" + + " scope.upsertRole = {name: arguments[0], selectedUsers: [arguments[1]]};" + + " scope.doUpsertRole(); scope.$apply();", + newRole, + newUser); + waitUntil( + "user " + newUser + " should have role " + newRole, + () -> authorizationApi().toString().contains(newRole)); + waitForPageContains(newRole); + + // grant a predefined permission to the role through the permission dialog + String permission = "collection-admin-read"; + waitFor(By.id("add-permission")); + ((JavascriptExecutor) driver) + .executeScript( + "var scope = angular.element(document.getElementById('add-permission')).scope();" + + " scope.showAddPermDialog();" + + " scope.selectedPredefinedPermission = arguments[0];" + + " scope.upsertPerm.selectedRoles = [arguments[1]];" + + " scope.doUpsertPermission(); scope.$apply();", + permission, + newRole); + waitUntil( + "permission " + permission + " should be granted to " + newRole, + () -> permissionRole(permission).contains(newRole)); + waitForPageContains(permission); + } + + /** Returns the authorization config as fetched with credentials. */ + private NamedList authorizationApi() { + try (SolrClient client = cluster.getJettySolrRunner(0).newClient()) { + GenericSolrRequest req = + new GenericSolrRequest(SolrRequest.METHOD.GET, "/admin/authorization", params()); + req.setBasicAuthCredentials(USER, PASS); + return client.request(req); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** Returns the roles granted the named permission, as a string, or empty when absent. */ + @SuppressWarnings("unchecked") + private String permissionRole(String permission) { + Map authorization = (Map) authorizationApi().get("authorization"); + for (Map perm : (List>) authorization.get("permissions")) { + if (permission.equals(perm.get("name")) && perm.get("role") != null) { + return perm.get("role").toString(); + } + } + return ""; + } + + /** Checks via the authentication API (with credentials) whether the user exists. */ + private boolean userExists(String user) { + try (SolrClient client = cluster.getJettySolrRunner(0).newClient()) { + GenericSolrRequest req = + new GenericSolrRequest(SolrRequest.METHOD.GET, "/admin/authentication", params()); + req.setBasicAuthCredentials(USER, PASS); + NamedList response = client.request(req); + Map authentication = (Map) response.get("authentication"); + Map credentials = (Map) authentication.get("credentials"); + return credentials.containsKey(user); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSmokeTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSmokeTest.java new file mode 100644 index 000000000000..5dfc5b2a4517 --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSmokeTest.java @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import java.util.Map; +import org.junit.BeforeClass; +import org.junit.Test; +import org.openqa.selenium.By; + +/** + * Smoke test navigating every screen of the Admin UI, asserting that each renders its main content + * element without severe browser console errors. + */ +public class AdminUiSmokeTest extends AdminUiTestBase { + + private static final String COLLECTION = "smoke"; + + @BeforeClass + public static void setupCollection() throws Exception { + createFixtureCollection(COLLECTION, 1, 2); + } + + @Test + public void testNodeLevelScreens() { + Map screens = + Map.of( + "", By.id("index"), + "~logging", By.id("logging"), + "~logging/level", By.id("logging"), + "~cores", By.id("cores"), + "~collections", By.id("collections"), + "~java-properties", By.id("java-properties"), + "~threads", By.id("threads"), + "~security", By.id("securityPanel"), + "login", By.id("login")); + screens.forEach(this::smoke); + } + + @Test + public void testCloudScreens() { + Map screens = + Map.of( + "~cloud?view=nodes", By.id("nodes-content"), + "~cloud?view=tree", By.id("tree-content"), + "~cloud?view=zkstatus", By.id("zk-status-content"), + "~cloud?view=graph", By.id("graph-content")); + screens.forEach(this::smoke); + } + + @Test + public void testSchemaDesignerScreen() { + smoke("~schema-designer", By.id("designer")); + } + + @Test + public void testCollectionScreens() { + Map screens = + Map.of( + COLLECTION + "/collection-overview", By.id("dashboard"), + COLLECTION + "/analysis", By.id("analysis"), + COLLECTION + "/documents", By.id("documents"), + COLLECTION + "/files", By.id("files"), + COLLECTION + "/query", By.id("query"), + COLLECTION + "/stream", By.id("stream"), + COLLECTION + "/paramsets", By.id("paramsets"), + COLLECTION + "/schema", By.id("schema")); + screens.forEach(this::smoke); + } + + @Test + public void testCoreScreens() { + // Plugins and Segments are core-level screens: their menu links use the core name + String coreName = + cluster.getJettySolrRunner(0).getCoreContainer().getAllCoreNames().iterator().next(); + Map screens = + Map.of( + coreName + "/plugins", By.id("plugins"), + coreName + "/segments", By.id("segments")); + screens.forEach(this::smoke); + // the ping widget on the overview answers 503 when no healthcheck file is configured, + // as is the case for the _default configset + smoke(coreName + "/core-overview", By.id("dashboard"), "/admin/ping"); + } + + private void smoke(String route, By anchor) { + smoke(route, anchor, new String[0]); + } + + private void smoke(String route, By anchor, String... allowedConsoleErrors) { + try { + openPage(route, anchor); + assertNoSevereConsoleErrors(allowedConsoleErrors); + } catch (AssertionError | RuntimeException e) { + throw new AssertionError("Screen '" + route + "' failed to render: " + e.getMessage(), e); + } + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java new file mode 100644 index 000000000000..5c82212738ff --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.common.SolrInputDocument; +import org.junit.BeforeClass; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +/** + * Tests the SQL screen: executing a SQL query through the form. Requires the sql module on the + * server classpath, provided by the webapp test dependencies. + */ +public class AdminUiSqlScreenTest extends AdminUiTestBase { + + private static final String COLLECTION = "sqlcoll"; + + @BeforeClass + public static void setupCollection() throws Exception { + createFixtureCollection(COLLECTION, 1, 1); + SolrClient client = cluster.getSolrClient(COLLECTION); + for (int i = 1; i <= 3; i++) { + SolrInputDocument doc = new SolrInputDocument(); + doc.addField("id", "sql-doc-" + i); + client.add(doc); + } + client.commit(); + } + + @Test + public void testSqlQueryViaUi() { + openPage(COLLECTION + "/sqlquery", By.id("sqlquery")); + WebElement stmt = waitFor(By.id("sqlexp")); + stmt.clear(); + stmt.sendKeys("SELECT id FROM " + COLLECTION + " LIMIT 10"); + waitFor(By.xpath("//div[@id='sqlquery']//button[@type='submit']")).click(); + + // the result grid lists all documents + for (int i = 1; i <= 3; i++) { + waitForPageContains("sql-doc-" + i); + } + assertNoSevereConsoleErrors(); + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiStandaloneTestBase.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiStandaloneTestBase.java new file mode 100644 index 000000000000..0d18b3939a34 --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiStandaloneTestBase.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; +import org.apache.solr.embedded.JettyConfig; +import org.apache.solr.embedded.JettySolrRunner; +import org.apache.solr.util.ExternalPaths; +import org.junit.BeforeClass; + +/** + * Base class for Admin UI tests of a standalone (user-managed, no ZooKeeper) Solr node, whose UI + * differs from cloud mode: no Cloud/Collections/Schema Designer menus, and the per-core menu offers + * analysis, documents, query, replication etc. directly. + * + *

The base {@code @BeforeClass} only starts the browser (via {@link #standaloneMode}); this + * class builds a solr home and starts a standalone {@link JettySolrRunner} serving the UI. + */ +public abstract class AdminUiStandaloneTestBase extends AdminUiTestBase { + + /** + * Arms standalone mode before the cloud cluster can start lazily. Runs after the base class's + * browser-starting {@code @BeforeClass} and before any subclass {@code @BeforeClass} or test. + */ + @BeforeClass + public static void enableStandaloneMode() { + standaloneMode = true; + } + + /** Builds a solr home with the shared test solr.xml and the given pre-created cores. */ + protected static Path buildStandaloneHome(String... coreNames) throws IOException { + Path home = createTempDir("standalone-home"); + Files.copy( + ExternalPaths.SOURCE_HOME.resolve("core/src/test-files/solr/solr.xml"), + home.resolve("solr.xml")); + for (String coreName : coreNames) { + createStandaloneCoreDir(home, coreName); + Properties props = new Properties(); + props.setProperty("name", coreName); + writeCoreProperties(home.resolve(coreName), props, coreName); + } + return home; + } + + /** Creates a core instance dir with the default configset, without registering the core. */ + protected static void createStandaloneCoreDir(Path home, String coreName) throws IOException { + Path confDir = home.resolve(coreName).resolve("conf"); + Files.createDirectories(confDir); + copyDirectory(ExternalPaths.DEFAULT_CONFIGSET, confDir); + } + + /** Starts a standalone Jetty on the given home, serving the Admin UI. */ + protected static JettySolrRunner startStandaloneJetty(Path home) throws Exception { + JettyConfig.Builder config = JettyConfig.builder(); + configureJettyForUi(config); + JettySolrRunner jetty = new JettySolrRunner(home.toString(), new Properties(), config.build()); + jetty.start(); + return jetty; + } + + protected static void copyDirectory(Path source, Path target) throws IOException { + try (var paths = Files.walk(source)) { + for (Path path : (Iterable) paths::iterator) { + Path dest = target.resolve(source.relativize(path).toString()); + if (Files.isDirectory(path)) { + Files.createDirectories(dest); + } else { + Files.createDirectories(dest.getParent()); + Files.copy(path, dest); + } + } + } + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiStreamScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiStreamScreenTest.java new file mode 100644 index 000000000000..8e4125596ef2 --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiStreamScreenTest.java @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.common.SolrInputDocument; +import org.junit.BeforeClass; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +/** Tests the Stream screen: executing a streaming expression through the form. */ +public class AdminUiStreamScreenTest extends AdminUiTestBase { + + private static final String COLLECTION = "streamcoll"; + + @BeforeClass + public static void setupCollection() throws Exception { + createFixtureCollection(COLLECTION, 1, 1); + SolrClient client = cluster.getSolrClient(COLLECTION); + for (int i = 1; i <= 3; i++) { + SolrInputDocument doc = new SolrInputDocument(); + doc.addField("id", "stream-doc-" + i); + client.add(doc); + } + client.commit(); + } + + @Test + public void testStreamingExpressionViaUi() { + openPage(COLLECTION + "/stream", By.id("stream")); + WebElement expr = waitFor(By.id("expr")); + expr.clear(); + expr.sendKeys("search(" + COLLECTION + ",q=\"*:*\",fl=\"id\",sort=\"id asc\")"); + waitFor(By.cssSelector("#stream button[type=submit]")).click(); + String response = waitForTextContains(By.cssSelector("#stream #result"), "stream-doc-1"); + assertTrue("All docs should stream: " + response, response.contains("stream-doc-3")); + assertNoSevereConsoleErrors(); + } +} diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiTestBase.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiTestBase.java new file mode 100644 index 000000000000..8b36b7967737 --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiTestBase.java @@ -0,0 +1,534 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.webapp; + +import com.carrotsearch.randomizedtesting.ThreadFilter; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakLingering; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.File; +import java.io.IOException; +import java.lang.invoke.MethodHandles; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.function.BooleanSupplier; +import java.util.function.Function; +import java.util.logging.Level; +import org.apache.lucene.tests.util.QuickPatchThreadsFilter; +import org.apache.lucene.util.SuppressForbidden; +import org.apache.solr.SolrIgnoredThreadsFilter; +import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.SolrRequest; +import org.apache.solr.client.solrj.SolrServerException; +import org.apache.solr.client.solrj.request.CollectionAdminRequest; +import org.apache.solr.client.solrj.request.GenericSolrRequest; +import org.apache.solr.cloud.SolrCloudTestCase; +import org.apache.solr.common.params.SolrParams; +import org.apache.solr.common.util.EnvUtils; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.embedded.JettyConfig; +import org.apache.solr.embedded.JettySolrRunner; +import org.apache.solr.util.ExternalPaths; +import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.junit.AfterClass; +import org.junit.Assume; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.rules.TestRule; +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; +import org.openqa.selenium.By; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.OutputType; +import org.openqa.selenium.StaleElementReferenceException; +import org.openqa.selenium.TakesScreenshot; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebDriverException; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.logging.LogEntry; +import org.openqa.selenium.logging.LogType; +import org.openqa.selenium.logging.LoggingPreferences; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Base class for browser-based tests of the AngularJS Admin UI. + * + *

Starts a {@link SolrCloudTestCase} mini-cluster whose Jetty nodes also serve the Admin UI + * static files (see {@code JettyConfig.Builder#enableAdminUi(boolean)}), then drives the UI with a + * headless Chrome via Selenium WebDriver. + * + *

The tests require a locally installed Chrome/Chromium browser. Discovery order: the {@code + * tests.ui.chrome.binary} system property, the {@code CHROME_BIN} environment variable, then a list + * of well-known install locations. When no browser is found, all tests in the class are skipped via + * {@link Assume}. The matching chromedriver is provisioned by Selenium Manager, which may download + * it on first use (cached under {@code ~/.cache/selenium}); if that fails (e.g. offline), tests are + * likewise skipped. + */ +@SolrTestCaseJ4.SuppressSSL(bugUrl = "Admin UI browser tests drive plain http") +@ThreadLeakFilters( + defaultFilters = true, + filters = { + SolrIgnoredThreadsFilter.class, + QuickPatchThreadsFilter.class, + AdminUiTestBase.WebDriverThreadsFilter.class + }) +@ThreadLeakLingering(linger = 5000) +public abstract class AdminUiTestBase extends SolrCloudTestCase { + + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + protected static final Duration WAIT_TIMEOUT = Duration.ofSeconds(15); + + protected static WebDriver driver; + + /** Base url of the first node, e.g. {@code http://127.0.0.1:PORT/solr} */ + protected static String baseUrl; + + /** + * Optional security.json for the cluster. Subclasses assign this in their {@code @BeforeClass} + * (which runs after this class's browser-starting one, but before the cluster starts lazily on + * first use). Never assign it in a {@code static} block: test runners may load all test classes + * up front, so static initializers of one class can run long before its suite executes. + */ + protected static String securityJson; + + /** + * When true (set by {@code AdminUiStandaloneTestBase}), no cloud cluster is started; the test + * class starts its own standalone {@link JettySolrRunner}(s), assigns {@link #standaloneJetty} + * and {@link #baseUrl}, and stops them again. + */ + protected static boolean standaloneMode = false; + + /** The standalone node backing {@link #adminApi} when {@link #standaloneMode} is set. */ + protected static JettySolrRunner standaloneJetty; + + /** + * Serves a minimal stand-in for the generated js-client bundle ({@code libs/solr/index.js}), + * which only exists inside the built webapp, not in the source tree tests serve from. The + * AngularJS {@code CollectionsV2} service fails to instantiate without the {@code solrApi} + * global, taking the whole Collections screen down with it. Only the small API surface the + * AngularJS UI actually uses is stubbed. + */ + public static class StubJsClientServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.setContentType("text/javascript"); + resp.getOutputStream() + .write( + ("var solrApi = {\n" + + " ApiClient: { instance: { basePath: '/api', defaultHeaders: {} } },\n" + + " CollectionsApi: function() {\n" + + " this.reloadCollection = function(name, callback) {\n" + + " var xhr = new XMLHttpRequest();\n" + + " xhr.open('POST', '/api/collections/' + name + '/reload');\n" + + " xhr.setRequestHeader('Content-Type', 'application/json');\n" + + " xhr.onload = function() { callback(null, null, {status: xhr.status}); };\n" + + " xhr.onerror = function() { callback(new Error('reload failed'), null, {status: xhr.status}); };\n" + + " xhr.send('{}');\n" + + " };\n" + + " }\n" + + "};\n") + .getBytes(StandardCharsets.UTF_8)); + } + } + + /** Ignores threads spawned by Selenium and the JDK http client it uses. */ + public static class WebDriverThreadsFilter implements ThreadFilter { + @Override + public boolean reject(Thread t) { + String name = t.getName(); + // JDK java.net.http client worker/selector threads (used by Selenium) are daemon + // threads in a shared pool that outlive WebDriver.quit() + return name.startsWith("HttpClient-") + // reaps the external chromedriver/chrome processes + || name.startsWith("process reaper") + // selenium driver-service startup checker pool, terminates on its own + || name.startsWith("UrlChecker-") + // selenium's chromedriver stdout/stderr pump, stops when the process exits + || name.startsWith("External Process Output Forwarder") + // JDK-internal scheduler backing CompletableFuture timeouts, lives forever + || name.equals("CompletableFutureDelayScheduler"); + } + } + + @BeforeClass + @SuppressForbidden(reason = "Selenium's logging preferences API uses java.util.logging levels") + public static void startClusterAndBrowser() throws Exception { + Path chrome = findChromeBinary(); + Assume.assumeTrue( + "No Chrome/Chromium binary found (set -Dtests.ui.chrome.binary=...), skipping UI tests", + chrome != null); + + // metrics are off by default in test clusters, but UI screens (e.g. Plugins) need them; + // restored after the class by SolrTestCase's SystemPropertiesRestoreRule + System.setProperty("metricsEnabled", "true"); + // the cluster starts lazily via ensureCloudCluster(), after subclass @BeforeClass + // methods have had the chance to configure securityJson or standalone mode + + ChromeOptions options = new ChromeOptions(); + options.setBinary(chrome.toString()); + options.addArguments( + "--headless=new", + "--window-size=1440,1024", + "--disable-gpu", + "--no-sandbox", + "--disable-dev-shm-usage"); + LoggingPreferences logPrefs = new LoggingPreferences(); + logPrefs.enable(LogType.BROWSER, Level.ALL); + options.setCapability("goog:loggingPrefs", logPrefs); + try { + driver = new ChromeDriver(options); + } catch (WebDriverException e) { + Assume.assumeNoException( + "Could not start ChromeDriver (chromedriver missing and not downloadable?)", e); + } + driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(30)); + } + + /** Starts the 2-node cloud cluster serving the UI, unless already started. */ + protected static void ensureCloudCluster() { + if (standaloneMode || cluster != null) { + return; + } + try { + var clusterBuilder = + configureCluster(2).withJettyConfig(AdminUiTestBase::configureJettyForUi); + if (securityJson != null) { + clusterBuilder.withSecurityJson(securityJson); + } + clusterBuilder.configure(); + baseUrl = cluster.getJettySolrRunner(0).getBaseUrl().toString(); + } catch (Exception e) { + throw new RuntimeException("Could not start UI test cluster", e); + } + } + + /** Configures a Jetty node to serve the Admin UI plus the js-client stub. */ + protected static void configureJettyForUi(JettyConfig.Builder jetty) { + jetty + .enableAdminUi(true) + // exact-path mapping takes precedence over the static /libs/* servlet + .withServlet(new ServletHolder(new StubJsClientServlet()), "/libs/solr/index.js"); + } + + @AfterClass + public static void stopBrowser() { + // reset the static per-class configuration: several test classes run in the same + // JVM, and flags set by one class's static initializer must not leak into the next + standaloneMode = false; + standaloneJetty = null; + securityJson = null; + if (driver != null) { + try { + driver.quit(); + } finally { + driver = null; + baseUrl = null; + } + } + } + + /** Captures a screenshot and the page source when a test fails, for post-mortem debugging. */ + @Rule + public final TestRule screenshotOnFailure = + new TestWatcher() { + @Override + protected void failed(Throwable e, Description description) { + if (driver == null) return; + try { + Path dir = createTempDir("ui-failure-" + description.getMethodName()); + byte[] png = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES); + Files.write(dir.resolve("screenshot.png"), png); + Files.writeString(dir.resolve("page.html"), driver.getPageSource()); + StringBuilder console = new StringBuilder(); + for (LogEntry entry : driver.manage().logs().get(LogType.BROWSER).getAll()) { + console.append(entry.getLevel()).append(' ').append(entry.getMessage()).append('\n'); + } + Files.writeString(dir.resolve("console.log"), console.toString()); + log.error("UI test failure artifacts saved to {}", dir); + } catch (Exception suppressed) { + log.warn("Could not save UI failure artifacts", suppressed); + } + } + }; + + /** + * Navigates to an Admin UI page and waits for a screen-specific anchor element to be visible. + * + * @param route the Angular hash route without leading {@code #/}, e.g. {@code ""} (dashboard), + * {@code "~cloud"} or {@code "collection1/query"} + * @param anchor a locator for an element that indicates the screen has rendered + * @return the anchor element + */ + protected static WebElement openPage(String route, By anchor) { + ensureCloudCluster(); + driver.get(baseUrl + "/index.html#/" + route); + return waitFor(anchor); + } + + /** Waits for the given element to be visible, up to {@link #WAIT_TIMEOUT}. */ + protected static WebElement waitFor(By locator) { + return poll(locator, el -> el.isDisplayed() ? el : null, "visible element"); + } + + /** Waits until the given element has non-blank text, and returns the text. */ + protected static String waitForText(By locator) { + return poll( + locator, + el -> { + String text = el.getText(); + return el.isDisplayed() && !text.isBlank() ? text : null; + }, + "non-empty text"); + } + + /** + * Polls the given element until {@code condition} returns non-null (a fresh lookup each round, so + * elements replaced by Angular re-renders are tolerated), failing after {@link #WAIT_TIMEOUT}. + */ + private static T poll(By locator, Function condition, String description) { + long deadlineNanos = System.nanoTime() + WAIT_TIMEOUT.toNanos(); + WebDriverException lastException = null; + while (System.nanoTime() < deadlineNanos) { + try { + T result = condition.apply(driver.findElement(locator)); + if (result != null) { + return result; + } + lastException = null; + } catch (NoSuchElementException | StaleElementReferenceException e) { + lastException = e; + } + try { + Thread.sleep(200); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + throw new AssertionError( + "Timed out waiting for " + description + " at " + locator, lastException); + } + + /** + * Issues a GET request to the given admin path (e.g. {@code /admin/info/system}) on the same node + * the browser talks to, and returns the parsed response. Used to fetch the expected values that + * the UI should display. + */ + protected static NamedList adminApi(String path, SolrParams params) + throws IOException, SolrServerException { + ensureCloudCluster(); + JettySolrRunner jetty = standaloneMode ? standaloneJetty : cluster.getJettySolrRunner(0); + try (SolrClient client = jetty.newClient()) { + return client.request(new GenericSolrRequest(SolrRequest.METHOD.GET, path, params)); + } + } + + /** + * Uploads the default configset under the collection's name and creates the collection. A + * single-replica collection is pinned to the node the browser talks to, so core-level screens + * find its core locally. + */ + protected static void createFixtureCollection(String name, int numShards, int numReplicas) + throws Exception { + ensureCloudCluster(); + cluster.uploadConfigSet(ExternalPaths.DEFAULT_CONFIGSET, name); + CollectionAdminRequest.Create create = + CollectionAdminRequest.createCollection(name, name, numShards, numReplicas); + if (numShards * numReplicas == 1) { + create.setCreateNodeSet(cluster.getJettySolrRunner(0).getNodeName()); + } + create.process(cluster.getSolrClient()); + cluster.waitForActiveCollection(name, numShards, numShards * numReplicas); + } + + /** Polls the condition until it holds, failing after {@link #WAIT_TIMEOUT}. */ + protected static void waitUntil(String description, BooleanSupplier condition) + throws InterruptedException { + long deadlineNanos = System.nanoTime() + WAIT_TIMEOUT.toNanos(); + while (System.nanoTime() < deadlineNanos) { + if (condition.getAsBoolean()) { + return; + } + Thread.sleep(250); + } + fail("Timed out waiting until " + description); + } + + /** Returns the name of a core of the given collection hosted on node 0. */ + protected static String coreNameOnNode0(String collection) { + for (String name : cluster.getJettySolrRunner(0).getCoreContainer().getAllCoreNames()) { + if (name.startsWith(collection + "_")) { + return name; + } + } + throw new AssertionError("No core found on node 0 for collection " + collection); + } + + /** + * Clears the input at the locator and types the given text, retrying when Angular re-renders the + * element mid-interaction (StaleElementReferenceException). + */ + protected static void setText(By locator, String text) { + poll( + locator, + el -> { + if (!el.isDisplayed()) { + return null; + } + el.clear(); + el.sendKeys(text); + return Boolean.TRUE; + }, + "typing '" + text + "'"); + } + + /** Waits until the element's rendered text contains the given substring, and returns it. */ + protected static String waitForTextContains(By locator, String substring) { + return poll( + locator, + el -> { + String text = el.getText(); + return text.contains(substring) ? text : null; + }, + "text containing '" + substring + "'"); + } + + /** Waits until the page source contains the given text. */ + protected static void waitForPageContains(String text) { + long deadlineNanos = System.nanoTime() + WAIT_TIMEOUT.toNanos(); + while (System.nanoTime() < deadlineNanos) { + if (driver.getPageSource().contains(text)) { + return; + } + try { + Thread.sleep(200); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + throw new AssertionError("Timed out waiting for page to contain: " + text); + } + + /** + * Selects an option in a "chosen"-decorated select element. The original select is hidden by the + * widget, so this drives the generated container instead. + */ + protected static void chosenSelect(String selectId, String optionText) { + WebElement container = waitFor(By.id(selectId + "_chosen")); + container.click(); + long deadlineNanos = System.nanoTime() + WAIT_TIMEOUT.toNanos(); + while (System.nanoTime() < deadlineNanos) { + for (WebElement option : + container.findElements(By.cssSelector(".chosen-results li.active-result"))) { + if (optionText.equals(option.getText())) { + option.click(); + return; + } + } + try { + Thread.sleep(200); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + throw new AssertionError("Option '" + optionText + "' not found in select " + selectId); + } + + /** + * Fails the test if the browser console contains SEVERE errors, ignoring known-benign ones and + * any messages containing one of {@code allowedSubstrings}. + */ + @SuppressForbidden(reason = "Selenium's log API reports java.util.logging levels") + protected static void assertNoSevereConsoleErrors(String... allowedSubstrings) { + List entries = driver.manage().logs().get(LogType.BROWSER).getAll(); + List severe = + entries.stream() + .filter(entry -> entry.getLevel().intValue() >= Level.SEVERE.intValue()) + .filter( + entry -> + Arrays.stream(allowedSubstrings) + .noneMatch(allowed -> entry.getMessage().contains(allowed))) + .filter(entry -> !entry.getMessage().contains("favicon.ico")) + // the ui-grid icon font referenced from ui-grid.min.css is not shipped with + // the webapp at all, so it 404s in production too + .filter(entry -> !entry.getMessage().contains("fonts/ui-grid")) + // benign race in the shared menu code: showCore() fires with a null core + // while the per-collection menu resolves after navigation + .filter( + entry -> + !(entry.getMessage().contains("reading 'name'") + && entry.getMessage().contains("showCore"))) + .toList(); + assertTrue("Severe browser console errors: " + severe, severe.isEmpty()); + } + + /** Locates a Chrome/Chromium binary, or returns null if none can be found. */ + @SuppressForbidden(reason = "Reading CHROME_BIN/PATH from the environment to locate a browser") + protected static Path findChromeBinary() { + String sysProp = EnvUtils.getProperty("tests.ui.chrome.binary"); + if (sysProp != null) { + Path path = Path.of(sysProp); + return Files.isExecutable(path) ? path : null; + } + String envBin = System.getenv("CHROME_BIN"); + if (envBin != null && Files.isExecutable(Path.of(envBin))) { + return Path.of(envBin); + } + List wellKnown = + List.of( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"); + for (String candidate : wellKnown) { + Path path = Path.of(candidate); + if (Files.isExecutable(path)) { + return path; + } + } + String pathEnv = System.getenv("PATH"); + if (pathEnv != null) { + for (String dir : pathEnv.split(File.pathSeparator)) { + for (String name : List.of("google-chrome", "chromium", "chromium-browser")) { + Path path = Path.of(dir, name); + if (Files.isExecutable(path)) { + return path; + } + } + } + } + return null; + } +}