diff --git a/FitNesseRoot/FitNesse/ReleaseNotes/content.txt b/FitNesseRoot/FitNesse/ReleaseNotes/content.txt
index 41bb45ac6..3a12a09b7 100644
--- a/FitNesseRoot/FitNesse/ReleaseNotes/content.txt
+++ b/FitNesseRoot/FitNesse/ReleaseNotes/content.txt
@@ -1,4 +1,8 @@
!2 Pending Changes
+ * Search skips symbolic links by default (check ''Include symbolic links'' to search them too); search results show which pages are reached via a symbolic link and how long the search took ([[1636][https://github.com/unclebob/fitnesse/pull/1636]]).
+ * Faster title search: when symbolic links are skipped, page files are no longer read just to find child pages ([[1637][https://github.com/unclebob/fitnesse/pull/1637]]).
+ * Searches can read pages with up to 4 threads in parallel (''Threads'' on the search form, searchThreads input) ([[1637][https://github.com/unclebob/fitnesse/pull/1637]]).
+ * Text search supports * as wildcard when ''Use wildcards'' is checked (useWildcards input) ([[1638][https://github.com/unclebob/fitnesse/pull/1638]]).
!2 20261101
* Refactor page shows tree structure ([[1617][https://github.com/unclebob/fitnesse/pull/1617]]).
diff --git a/FitNesseRoot/FitNesse/UserGuide/AdministeringFitNesse/RestfulServices/content.txt b/FitNesseRoot/FitNesse/UserGuide/AdministeringFitNesse/RestfulServices/content.txt
index 93c9a3c71..13f54f4f7 100644
--- a/FitNesseRoot/FitNesse/UserGuide/AdministeringFitNesse/RestfulServices/content.txt
+++ b/FitNesseRoot/FitNesse/UserGuide/AdministeringFitNesse/RestfulServices/content.txt
@@ -133,7 +133,10 @@
| | searchString | The string to search for. For text search, this is a string, not a regular expression. For method search, it is a line from wiki which evaluates to a method or a scenario name. |
| | searchType | May be "title" or "content". If absent or "title" then searches for pages whose names contain the ''searchString'', otherwise searches for pages whose content contains the ''searchString''. |
| | isMethodSearch | If present, a method search is performed instead of a text search. Only works together with searchType=content. |
+| | useWildcards | Text search only: if present, an asterisk (*) in ''searchString'' matches any characters within a line, and * alone finds all pages. Without it, the ''searchString'' is matched literally. |
| | searchScope | Limits the search to the pages below the given ancestor page name, or the word "root" to search the whole wiki. Otherwise the selected page and its children are searched. |
+| | includeSymLinks | Also search the pages behind symbolic links. Without it, symbolic links and the pages below them are skipped. |
+| | searchThreads | Number of threads (1-4) reading pages during the search. 1, the default, searches sequentially; more threads read pages ahead in parallel, which makes searches on large wikis faster but puts more load on the server. |
| searchForm | | Returns the search form that allows users to specify a search. |
| searchProperties | | Returns a list of pages that match the search criteria |
| | pageType | a comma separated list of page types to include in the search {Static, Test, Suite}. (The former name !-PageType-! is still accepted.) |
@@ -143,6 +146,8 @@
| | Special | a list of !-{SetUp, TearDown}-! or the word "Any". The listed kinds of setup/teardown pages are included, the ones not listed are excluded from the search. |
| | Prune | Only find pages with the "skipped" attribute selected. (The former name ''obsolete'' is still accepted.) |
| | searchScope | Limits the search to the pages below the given ancestor page name, or the word "root" to search the whole wiki. |
+| | includeSymLinks | Also search the pages behind symbolic links. Without it, symbolic links and the pages below them are skipped. |
+| | searchThreads | Number of threads (1-4) reading pages during the search. 1, the default, searches sequentially; more threads read pages ahead in parallel, which makes searches on large wikis faster but puts more load on the server. |
| shutdown | | Shuts !-FitNesse-! down. |
| stoptest | | Stops running tests. |
| | id | If present, is the id of the test run to stop, as returned in the ''X-FitNesse-Test-Id'' header of the ''test'' or ''suite'' response. Otherwise stops ''all'' running tests. |
diff --git a/src/fitnesse/resources/css/fitnesse_pages.css b/src/fitnesse/resources/css/fitnesse_pages.css
index 194f28783..c0242f324 100644
--- a/src/fitnesse/resources/css/fitnesse_pages.css
+++ b/src/fitnesse/resources/css/fitnesse_pages.css
@@ -298,6 +298,12 @@ span.error {
font-style: italic;
}
+/** Search results **/
+.symlink {
+ font-style: italic;
+ font-weight: bold;
+}
+
.CodeMirror {
border: 1px solid #d7d7d7;
}
diff --git a/src/fitnesse/resources/templates/searchForm.vm b/src/fitnesse/resources/templates/searchForm.vm
index d33c35c40..d660b28e4 100644
--- a/src/fitnesse/resources/templates/searchForm.vm
+++ b/src/fitnesse/resources/templates/searchForm.vm
@@ -27,6 +27,16 @@
#end
$page.getName()
+
+ Options:
+ Include symbolic links
+ Use wildcards (*)
+
+ #foreach( $threads in $resultResponder.getSearchThreadChoices() )
+ $threads
+ #end
+ Threads
+
@@ -54,6 +64,15 @@
#end
$page.getName()
+
+ Options:
+ Include symbolic links
+
+ #foreach( $threads in $resultResponder.getSearchThreadChoices() )
+ $threads
+ #end
+ Threads
+
Back
@@ -75,6 +94,15 @@
#end
$page.getName()
+
+ Options:
+ Include symbolic links
+
+ #foreach( $threads in $resultResponder.getSearchThreadChoices() )
+ $threads
+ #end
+ Threads
+
diff --git a/src/fitnesse/resources/templates/searchResults.vm b/src/fitnesse/resources/templates/searchResults.vm
index e49a83645..5ac6de8ab 100644
--- a/src/fitnesse/resources/templates/searchResults.vm
+++ b/src/fitnesse/resources/templates/searchResults.vm
@@ -12,7 +12,10 @@
Tags
- LastModified
+ Via SymLink
+
+
+ LastModified
@@ -31,12 +34,17 @@
#end
+ #if( $result.isSymbolicPage() )
+ #foreach( $pathPage in $resultResponder.getPathPages($result) )#if( $resultResponder.isSymbolicLink($pathPage) )$pathPage.getName() #else$pathPage.getName()#end#if( $foreach.hasNext ).#end#end
+ #else
$pagePath
+ #end
#set ( $tags = $result.getData().getAttribute("Suites") )
#if ($tags && !$tags.equals("null"))#escape($tags)#end
+ #if($result.isSymbolicPage())yes#{else}no#end
$result.getData().getProperties().getLastModificationTime()
@@ -60,9 +68,10 @@
[[Search below !-$page.getName()-! for <description>][$page.getPageCrawler().getFullPath()?$queryString]]
#if ($hits > 1)
-
+
#elseif ($hits == 1)
-
+
#else
-
+
#end
diff --git a/src/fitnesse/responders/refactoring/SearchReplaceResponder.java b/src/fitnesse/responders/refactoring/SearchReplaceResponder.java
index f6dc8c3a5..ae1f24cf4 100644
--- a/src/fitnesse/responders/refactoring/SearchReplaceResponder.java
+++ b/src/fitnesse/responders/refactoring/SearchReplaceResponder.java
@@ -45,6 +45,18 @@ protected PageFinder getPageFinder(TraversalListener webOutputObserver
}
}
+ @Override
+ protected boolean includeSymbolicLinks() {
+ // replace has always changed the pages behind symbolic links too
+ return true;
+ }
+
+ @Override
+ public int getSearchThreads() {
+ // replace changes pages while traversing, so pages must not be loaded ahead
+ return 1;
+ }
+
private boolean isMethodReplace() {
return request.hasInput("isMethodReplace");
}
diff --git a/src/fitnesse/responders/search/ResultResponder.java b/src/fitnesse/responders/search/ResultResponder.java
index d19aa68c9..baac0f462 100644
--- a/src/fitnesse/responders/search/ResultResponder.java
+++ b/src/fitnesse/responders/search/ResultResponder.java
@@ -10,12 +10,20 @@
import fitnesse.html.template.HtmlPage;
import fitnesse.html.template.PageTitle;
import fitnesse.responders.ChunkingResponder;
+import fitnesse.wiki.NoPruningStrategy;
import fitnesse.wiki.PageCrawler;
+import fitnesse.wiki.PagePruningStrategy;
import fitnesse.wiki.PageType;
+import fitnesse.wiki.SymbolicLinkPruningStrategy;
+import fitnesse.wiki.SymbolicPage;
import fitnesse.wiki.WikiPage;
import fitnesse.wiki.search.PageFinder;
+import fitnesse.wiki.search.WikiPageFinder;
import java.io.IOException;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
import static fitnesse.wiki.PageData.PropertyEDIT;
import static fitnesse.wiki.PageData.PropertyFILES;
@@ -37,6 +45,11 @@ public abstract class ResultResponder extends ChunkingResponder implements
static final String[] SEARCH_NAVIGATION_ATTRIBUTES = { PropertyRECENT_CHANGES, PropertyFILES, PropertySEARCH };
static final String SEARCH_ATTRIBUTE_SKIP = PropertyPRUNE;
static final String[] SPECIAL_ATTRIBUTES = { "SetUp", "TearDown" };
+ static final String INCLUDE_SYMBOLIC_LINKS = "includeSymLinks";
+ static final String SEARCH_THREADS = "searchThreads";
+
+ private long searchStartTime;
+ private long searchEndTime;
@Override
protected PageCrawler getPageCrawler() {
@@ -90,12 +103,85 @@ protected void doSending() throws IOException {
@Override
public final void traverse(TraversalListener observer) {
+ searchStartTime = System.nanoTime();
+ try {
+ search(observer);
+ } finally {
+ searchEndTime = System.nanoTime();
+ }
+ }
+
+ /**
+ * @return the time the search took, in seconds with two decimals.
+ */
+ public String getSearchTime() {
+ return String.format(Locale.ROOT, "%.2f", (searchEndTime - searchStartTime) / 1_000_000_000.0);
+ }
+
+ private void search(TraversalListener observer) {
PageFinder pageFinder = getPageFinder(observer);
- if (pageFinder != null) {
+ if (pageFinder instanceof WikiPageFinder) {
+ PagePruningStrategy strategy = includeSymbolicLinks()
+ ? new NoPruningStrategy() : new SymbolicLinkPruningStrategy();
+ ((WikiPageFinder) pageFinder).search(getSearchScope(), strategy, getSearchThreads());
+ } else if (pageFinder != null) {
pageFinder.search(getSearchScope());
}
}
+ /**
+ * @return true to also search the pages behind symbolic links, as requested; false by default.
+ */
+ protected boolean includeSymbolicLinks() {
+ return request.hasInput(INCLUDE_SYMBOLIC_LINKS);
+ }
+
+ /**
+ * @return the number of threads to load pages with, as requested; 1 by default.
+ */
+ public int getSearchThreads() {
+ try {
+ int threads = Integer.parseInt(request.getInput(SEARCH_THREADS));
+ return Math.max(1, Math.min(threads, WikiPageFinder.MAX_THREADS));
+ } catch (NumberFormatException e) {
+ return 1;
+ }
+ }
+
+ /**
+ * @return the number of threads to preselect on the search form: as requested, or the most threads by default.
+ */
+ public int getSelectedSearchThreads() {
+ return request.hasInput(SEARCH_THREADS) ? getSearchThreads() : WikiPageFinder.MAX_THREADS;
+ }
+
+ /**
+ * @return the numbers of threads to choose from.
+ */
+ public int[] getSearchThreadChoices() {
+ int[] choices = new int[WikiPageFinder.MAX_THREADS];
+ for (int i = 0; i < choices.length; i++) {
+ choices[i] = i + 1;
+ }
+ return choices;
+ }
+
+ /**
+ * @param page a search result
+ * @return the pages making up the full path of the page, from the top level page down to the page itself
+ */
+ public List getPathPages(WikiPage page) {
+ LinkedList pathPages = new LinkedList<>();
+ for (WikiPage current = page; !current.isRoot(); current = current.getParent()) {
+ pathPages.addFirst(current);
+ }
+ return pathPages;
+ }
+
+ public boolean isSymbolicLink(WikiPage page) {
+ return SymbolicPage.isSymbolicLink(page);
+ }
+
protected abstract String getTemplate();
protected abstract String getTitle() ;
diff --git a/src/fitnesse/responders/search/SearchResponder.java b/src/fitnesse/responders/search/SearchResponder.java
index 802dd49e5..391ccbfd9 100644
--- a/src/fitnesse/responders/search/SearchResponder.java
+++ b/src/fitnesse/responders/search/SearchResponder.java
@@ -14,6 +14,7 @@
import static java.util.regex.Pattern.CASE_INSENSITIVE;
import static java.util.regex.Pattern.LITERAL;
+import static java.util.regex.Pattern.UNICODE_CASE;
public class SearchResponder extends ResultResponder {
@@ -53,13 +54,17 @@ protected PageFinder getPageFinder(TraversalListener observer) {
String searchString = getSearchString();
if (!"".equals(searchString)) {
String searchType = getSearchType();
- if ("Title".equals(searchType))
- return new TitleWikiPageFinder(searchString, observer);
- else {
+ if ("Title".equals(searchType)) {
+ return useWildcards()
+ ? new TitleWikiPageFinder(wildcardPattern(searchString), observer)
+ : new TitleWikiPageFinder(searchString, observer);
+ } else {
if (isMethodSearch()) {
return new MethodWikiPageFinder(searchString, observer);
} else {
- Pattern regularExpression = Pattern.compile(searchString, CASE_INSENSITIVE + LITERAL);
+ Pattern regularExpression = useWildcards()
+ ? wildcardPattern(searchString)
+ : Pattern.compile(searchString, CASE_INSENSITIVE + LITERAL);
return new RegularExpressionWikiPageFinder(regularExpression, observer);
}
}
@@ -71,6 +76,28 @@ private boolean isMethodSearch() {
return request.hasInput("isMethodSearch");
}
+ private boolean useWildcards() {
+ return request.hasInput("useWildcards");
+ }
+
+ /**
+ * @param searchString text to search for, where * matches any characters (within a line)
+ * @return case insensitive pattern matching the search string
+ */
+ static Pattern wildcardPattern(String searchString) {
+ StringBuilder regularExpression = new StringBuilder();
+ String[] parts = searchString.split("\\*", -1);
+ for (int i = 0; i < parts.length; i++) {
+ if (i > 0) {
+ regularExpression.append(".*");
+ }
+ if (!parts[i].isEmpty()) {
+ regularExpression.append(Pattern.quote(parts[i]));
+ }
+ }
+ return Pattern.compile(regularExpression.toString(), CASE_INSENSITIVE | UNICODE_CASE);
+ }
+
@Override
protected boolean shouldRespondWith404() {
return false;
diff --git a/src/fitnesse/wiki/PageCrawler.java b/src/fitnesse/wiki/PageCrawler.java
index 8c20f4cd9..de4c0bdf0 100644
--- a/src/fitnesse/wiki/PageCrawler.java
+++ b/src/fitnesse/wiki/PageCrawler.java
@@ -4,6 +4,8 @@
import fitnesse.components.TraversalListener;
+import java.util.List;
+
public class PageCrawler {
private final WikiPage context;
@@ -121,7 +123,8 @@ private void traverse(WikiPage page, TraversalListener super WikiPage> listene
if(pruningStrategy.skipPageAndChildren(page)){ return; }
listener.process(page);
- for (WikiPage wikiPage : page.getChildren()) {
+ List children = pruningStrategy.skipSymbolicLinks() ? page.getChildrenWithoutSymbolicLinks() : page.getChildren();
+ for (WikiPage wikiPage : children) {
traverse(wikiPage, listener, pruningStrategy);
}
}
diff --git a/src/fitnesse/wiki/PagePruningStrategy.java b/src/fitnesse/wiki/PagePruningStrategy.java
index 826135750..05828302e 100644
--- a/src/fitnesse/wiki/PagePruningStrategy.java
+++ b/src/fitnesse/wiki/PagePruningStrategy.java
@@ -2,4 +2,11 @@
public interface PagePruningStrategy {
boolean skipPageAndChildren(WikiPage page);
+
+ /**
+ * @return true to leave out symbolic links (and the pages below them) without resolving them.
+ */
+ default boolean skipSymbolicLinks() {
+ return false;
+ }
}
diff --git a/src/fitnesse/wiki/SymbolicLinkPruningStrategy.java b/src/fitnesse/wiki/SymbolicLinkPruningStrategy.java
new file mode 100644
index 000000000..57a55956d
--- /dev/null
+++ b/src/fitnesse/wiki/SymbolicLinkPruningStrategy.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2026 by netcare GmbH. All rights reserved.
+// Released under the terms of the CPL Common Public License version 1.0.
+package fitnesse.wiki;
+
+/**
+ * Leaves out symbolic links (and everything below them) found while traversing from a start page.
+ * The start page itself is never left out, so traversals that start at a symbolic link still
+ * cover the linked pages.
+ */
+public class SymbolicLinkPruningStrategy implements PagePruningStrategy {
+
+ @Override
+ public boolean skipPageAndChildren(WikiPage page) {
+ return false;
+ }
+
+ @Override
+ public boolean skipSymbolicLinks() {
+ return true;
+ }
+}
diff --git a/src/fitnesse/wiki/SymbolicPage.java b/src/fitnesse/wiki/SymbolicPage.java
index b56e2805c..0493fb04d 100644
--- a/src/fitnesse/wiki/SymbolicPage.java
+++ b/src/fitnesse/wiki/SymbolicPage.java
@@ -68,6 +68,15 @@ public List getChildren() {
return symChildren;
}
+ @Override
+ public List getChildrenWithoutSymbolicLinks() {
+ List symChildren = new LinkedList<>();
+ for (WikiPage child : realPage.getChildrenWithoutSymbolicLinks()) {
+ symChildren.add(createChildPage(child));
+ }
+ return symChildren;
+ }
+
private WikiPage createChildPage(WikiPage child) {
WikiPage cyclicReference = findCyclicReference(child);
if (cyclicReference != null) {
@@ -146,6 +155,16 @@ public int hashCode() {
return realPage.hashCode();
}
+ /**
+ * @param wikiPage page to check
+ * @return true if the page is a symbolic link, false for real pages and for pages below a symbolic link
+ */
+ public static boolean isSymbolicLink(WikiPage wikiPage) {
+ // pages below a link wrap their real page, links below a link wrap that link
+ return wikiPage.isSymbolicPage()
+ && (!wikiPage.getParent().isSymbolicPage() || isSymbolicLink(wikiPage.getRealPage()));
+ }
+
public static boolean containsWikitext(WikiPage wikiPage) {
if (wikiPage instanceof SymbolicPage) {
return containsWikitext(((SymbolicPage) wikiPage).realPage);
diff --git a/src/fitnesse/wiki/WikiPage.java b/src/fitnesse/wiki/WikiPage.java
index 77aa3b7d0..d0acd8115 100644
--- a/src/fitnesse/wiki/WikiPage.java
+++ b/src/fitnesse/wiki/WikiPage.java
@@ -3,6 +3,7 @@
package fitnesse.wiki;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
@@ -54,6 +55,22 @@ public interface WikiPage extends Comparable {
*/
List getChildren();
+ /**
+ * Get child pages for this wiki page, leaving out symbolic links. Implementations that can list their children
+ * without resolving symbolic links override this, to avoid loading page data.
+ *
+ * @return children that are no symbolic links, an empty list if there are none.
+ */
+ default List getChildrenWithoutSymbolicLinks() {
+ List children = new ArrayList<>();
+ for (WikiPage child : getChildren()) {
+ if (!SymbolicPage.isSymbolicLink(child)) {
+ children.add(child);
+ }
+ }
+ return children;
+ }
+
String getName();
PageData getData();
diff --git a/src/fitnesse/wiki/fs/FileSystemPage.java b/src/fitnesse/wiki/fs/FileSystemPage.java
index 7ad7ec3f8..cb9bd0930 100644
--- a/src/fitnesse/wiki/fs/FileSystemPage.java
+++ b/src/fitnesse/wiki/fs/FileSystemPage.java
@@ -102,6 +102,11 @@ public List getChildren() {
return subWikiPageFactory.getChildren(this);
}
+ @Override
+ public List getChildrenWithoutSymbolicLinks() {
+ return subWikiPageFactory.getChildrenWithoutSymbolicLinks(this);
+ }
+
@Override
public WikiPage getChildPage(String childName) {
return subWikiPageFactory.getChildPage(this, childName);
diff --git a/src/fitnesse/wiki/fs/FileSystemSubWikiPageFactory.java b/src/fitnesse/wiki/fs/FileSystemSubWikiPageFactory.java
index 9b1ca9115..d70aa538a 100644
--- a/src/fitnesse/wiki/fs/FileSystemSubWikiPageFactory.java
+++ b/src/fitnesse/wiki/fs/FileSystemSubWikiPageFactory.java
@@ -29,13 +29,18 @@ public List getChildren(FileBasedWikiPage page) {
return children;
}
+ @Override
+ public List getChildrenWithoutSymbolicLinks(FileBasedWikiPage page) {
+ return new ArrayList<>(getNormalChildren(page));
+ }
+
private Set getNormalChildren(FileBasedWikiPage page) {
final File thisDir = page.getFileSystemPath();
final Set children = new TreeSet<>();
if (fileSystem.exists(thisDir)) {
final String[] subFiles = fileSystem.list(thisDir);
for (String subFile : subFiles) {
- WikiPage maybeChildPage = getChildPage(page, subFile);
+ WikiPage maybeChildPage = makeChildPage(new File(thisDir, subFile), subFile, page);
if (maybeChildPage != null) {
children.add(maybeChildPage);
}
diff --git a/src/fitnesse/wiki/fs/SubWikiPageFactory.java b/src/fitnesse/wiki/fs/SubWikiPageFactory.java
index 99a4f2378..96faf3f1b 100644
--- a/src/fitnesse/wiki/fs/SubWikiPageFactory.java
+++ b/src/fitnesse/wiki/fs/SubWikiPageFactory.java
@@ -8,5 +8,7 @@ public interface SubWikiPageFactory {
List getChildren(FileBasedWikiPage wikiPage);
+ List getChildrenWithoutSymbolicLinks(FileBasedWikiPage wikiPage);
+
WikiPage getChildPage(FileBasedWikiPage wikiPage, String childName);
}
diff --git a/src/fitnesse/wiki/fs/WikiFilePage.java b/src/fitnesse/wiki/fs/WikiFilePage.java
index c2e692961..80ab20792 100644
--- a/src/fitnesse/wiki/fs/WikiFilePage.java
+++ b/src/fitnesse/wiki/fs/WikiFilePage.java
@@ -84,6 +84,11 @@ public List getChildren() {
return subWikiPageFactory.getChildren(this);
}
+ @Override
+ public List getChildrenWithoutSymbolicLinks() {
+ return subWikiPageFactory.getChildrenWithoutSymbolicLinks(this);
+ }
+
@Override
public PageData getData() {
if (pageData == null) {
diff --git a/src/fitnesse/wiki/search/ParallelTraversal.java b/src/fitnesse/wiki/search/ParallelTraversal.java
new file mode 100644
index 000000000..b13c8700b
--- /dev/null
+++ b/src/fitnesse/wiki/search/ParallelTraversal.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2026 by netcare GmbH. All rights reserved.
+// Released under the terms of the CPL Common Public License version 1.0.
+package fitnesse.wiki.search;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+import fitnesse.components.TraversalListener;
+import fitnesse.wiki.PagePruningStrategy;
+import fitnesse.wiki.WikiPage;
+
+/**
+ * Traverses a page tree like {@link fitnesse.wiki.PageCrawler#traverse}, but reads pages ahead on worker threads:
+ * the child pages (and optionally the page data) of all children of a page are loaded in parallel.
+ * Pages are still checked against the pruning strategy and passed to the listener one at a time,
+ * on the calling thread and in the same order, so neither has to be thread safe.
+ */
+class ParallelTraversal {
+ private final TraversalListener super WikiPage> listener;
+ private final PagePruningStrategy pruningStrategy;
+ private final boolean loadPageData;
+ private final boolean skipSymbolicLinks;
+ private final int threads;
+ private ExecutorService executor;
+
+ ParallelTraversal(TraversalListener super WikiPage> listener, PagePruningStrategy pruningStrategy, boolean loadPageData,
+ int threads) {
+ this.listener = listener;
+ this.pruningStrategy = pruningStrategy;
+ this.loadPageData = loadPageData;
+ this.skipSymbolicLinks = pruningStrategy.skipSymbolicLinks();
+ this.threads = threads;
+ }
+
+ void traverse(WikiPage startPage) {
+ executor = Executors.newFixedThreadPool(threads, ParallelTraversal::newThread);
+ try {
+ traverse(startPage, load(startPage));
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ private void traverse(WikiPage page, Future> loadedChildren) {
+ // wait for the page to be loaded before the pruning strategy or the listener may look at it
+ List children = await(loadedChildren);
+ if (pruningStrategy.skipPageAndChildren(page)) {
+ return;
+ }
+ listener.process(page);
+
+ List>> loadedGrandChildren = new ArrayList<>(children.size());
+ for (WikiPage child : children) {
+ loadedGrandChildren.add(load(child));
+ }
+ for (int i = 0; i < children.size(); i++) {
+ traverse(children.get(i), loadedGrandChildren.get(i));
+ }
+ }
+
+ private Future> load(WikiPage page) {
+ return executor.submit(() -> {
+ if (loadPageData) {
+ page.getData();
+ }
+ return skipSymbolicLinks ? page.getChildrenWithoutSymbolicLinks() : page.getChildren();
+ });
+ }
+
+ private static T await(Future future) {
+ try {
+ return future.get();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Interrupted while loading pages", e);
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause();
+ if (cause instanceof RuntimeException) {
+ throw (RuntimeException) cause;
+ }
+ if (cause instanceof Error) {
+ throw (Error) cause;
+ }
+ throw new IllegalStateException(cause);
+ }
+ }
+
+ private static Thread newThread(Runnable runnable) {
+ Thread thread = new Thread(runnable, "page loader");
+ thread.setDaemon(true);
+ return thread;
+ }
+}
diff --git a/src/fitnesse/wiki/search/TitleWikiPageFinder.java b/src/fitnesse/wiki/search/TitleWikiPageFinder.java
index a9dc037cd..d9a09d31e 100644
--- a/src/fitnesse/wiki/search/TitleWikiPageFinder.java
+++ b/src/fitnesse/wiki/search/TitleWikiPageFinder.java
@@ -1,21 +1,31 @@
package fitnesse.wiki.search;
+import java.util.regex.Pattern;
+
import fitnesse.components.TraversalListener;
import fitnesse.wiki.WikiPage;
public class TitleWikiPageFinder extends WikiPageFinder {
- private String searchString;
+ private final Pattern pattern;
public TitleWikiPageFinder(String searchString, TraversalListener super WikiPage> observer) {
+ this(Pattern.compile(searchString, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE | Pattern.LITERAL), observer);
+ }
+
+ public TitleWikiPageFinder(Pattern pattern, TraversalListener super WikiPage> observer) {
super(observer);
- this.searchString = searchString.toLowerCase();
+ this.pattern = pattern;
+ }
+
+ @Override
+ protected boolean readsPageData() {
+ return false;
}
@Override
protected boolean pageMatches(WikiPage page) {
- String content = page.getName().toLowerCase();
- return content.contains(searchString);
+ return pattern.matcher(page.getName()).find();
}
}
diff --git a/src/fitnesse/wiki/search/WikiPageFinder.java b/src/fitnesse/wiki/search/WikiPageFinder.java
index bd489f8f2..9c0914e50 100644
--- a/src/fitnesse/wiki/search/WikiPageFinder.java
+++ b/src/fitnesse/wiki/search/WikiPageFinder.java
@@ -6,6 +6,7 @@
import fitnesse.wiki.WikiPage;
public abstract class WikiPageFinder implements PageFinder {
+ public static final int MAX_THREADS = 4;
private WikiPageTraverser traverser;
@@ -15,13 +16,34 @@ protected WikiPageFinder(TraversalListener super WikiPage> observer) {
protected abstract boolean pageMatches(WikiPage page);
+ /**
+ * @return false if {@link #pageMatches(WikiPage)} does not look at the page data, so it need not be loaded ahead.
+ */
+ protected boolean readsPageData() {
+ return true;
+ }
+
@Override
public void search(WikiPage page) {
- page.getPageCrawler().traverse(traverser, new NoPruningStrategy());
+ search(page, new NoPruningStrategy());
}
public void search(WikiPage page, PagePruningStrategy strategy) {
- page.getPageCrawler().traverse(traverser, strategy);
+ search(page, strategy, 1);
+ }
+
+ /**
+ * @param page page to start searching at
+ * @param strategy pages to leave out
+ * @param threads number of threads to load pages with: 1 searches on the calling thread only,
+ * more load pages ahead on that many worker threads (at most {@link #MAX_THREADS})
+ */
+ public void search(WikiPage page, PagePruningStrategy strategy, int threads) {
+ if (threads <= 1) {
+ page.getPageCrawler().traverse(traverser, strategy);
+ } else {
+ new ParallelTraversal(traverser, strategy, readsPageData(), Math.min(threads, MAX_THREADS)).traverse(page);
+ }
}
}
diff --git a/test/fitnesse/responders/refactoring/SearchReplaceResponderTest.java b/test/fitnesse/responders/refactoring/SearchReplaceResponderTest.java
index 83b0a46d3..cd4b4f20d 100644
--- a/test/fitnesse/responders/refactoring/SearchReplaceResponderTest.java
+++ b/test/fitnesse/responders/refactoring/SearchReplaceResponderTest.java
@@ -77,6 +77,27 @@ public void onlySelectedPageAndChildrenAreSearched() throws Exception {
assertThat(content, containsString("SomePage.ChildPage"));
}
+ @Test
+ public void replacesInPagesBehindSymbolicLinks() throws Exception {
+ WikiPageUtil.addPage(root, PathParser.parse("LinkedPage"), "linked page has something too");
+ PageData data = somePage.getData();
+ data.getProperties().set(SymbolicPage.PROPERTY_NAME).set("SymLink", ".LinkedPage");
+ somePage.commit(data);
+
+ String content = getResponseContentUsingSearchReplaceString("something", "replacedthing");
+
+ assertThat(content, containsString("SomePage.SymLink"));
+ assertEquals("linked page has replacedthing too", root.getChildPage("LinkedPage").getData().getContent());
+ }
+
+ @Test
+ public void replacesWithOneThreadOnly() {
+ request.addInput("searchThreads", "4");
+ SearchReplaceResponder searchReplaceResponder = (SearchReplaceResponder) responder;
+ searchReplaceResponder.setRequest(request);
+ assertEquals(1, searchReplaceResponder.getSearchThreads());
+ }
+
private String getResponseContentUsingSearchReplaceString(String searchString, String replacementString) throws Exception {
request.addInput("searchString", searchString);
request.addInput("replacementString", replacementString);
diff --git a/test/fitnesse/responders/search/SearchFormResponderTest.java b/test/fitnesse/responders/search/SearchFormResponderTest.java
index ce516d25a..382292307 100644
--- a/test/fitnesse/responders/search/SearchFormResponderTest.java
+++ b/test/fitnesse/responders/search/SearchFormResponderTest.java
@@ -2,6 +2,8 @@
// Released under the terms of the CPL Common Public License version 1.0.
package fitnesse.responders.search;
+import static org.junit.Assert.assertEquals;
+import static util.RegexTestCase.assertDoesntHaveRegexp;
import static util.RegexTestCase.assertHasRegexp;
import static util.RegexTestCase.assertSubString;
import static fitnesse.responders.search.SearchResponder.*;
@@ -53,6 +55,18 @@ public void testForTwoSearchTypes() throws Exception {
assertSubString("value=\"Search Content\"", content);
}
+ @Test
+ public void textSearchOffersWildcards() throws Exception {
+ assertSubString("name=\"useWildcards\"", content);
+ assertEquals(content.indexOf("name=\"useWildcards\""), content.lastIndexOf("name=\"useWildcards\""));
+ }
+
+ @Test
+ public void mostSearchThreadsArePreselected() throws Exception {
+ assertSubString("4 ", content);
+ assertDoesntHaveRegexp("", content);
diff --git a/test/fitnesse/responders/search/SearchResponderTest.java b/test/fitnesse/responders/search/SearchResponderTest.java
index 6b6b76640..631ce7f91 100644
--- a/test/fitnesse/responders/search/SearchResponderTest.java
+++ b/test/fitnesse/responders/search/SearchResponderTest.java
@@ -12,6 +12,13 @@
import org.junit.Before;
import org.junit.Test;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
import static util.RegexTestCase.assertDoesntHaveRegexp;
import static util.RegexTestCase.assertHasRegexp;
import static util.RegexTestCase.assertSubString;
@@ -127,6 +134,8 @@ public void testHtml() throws Exception {
assertHasRegexp("something", content);
assertHasRegexp("SomePage", content);
+ assertHasRegexp("Found 1 result for your search in \\d+\\.\\d\\d seconds\\.", content);
+ assertDoesntHaveRegexp("searchResultsTable\"\\)\\.style\\.display", content);
}
@Test
@@ -151,13 +160,14 @@ public void testPageSortLink() throws Exception {
@Test
public void testLastModifiedSortLink() throws Exception {
String content = getResponseContentUsingSearchString("something");
- assertSubString("LastModified ", content);
+ assertSubString("LastModified ", content);
}
@Test
public void testNoSearchStringBringsUpNoResults() throws Exception {
String content = getResponseContentUsingSearchString("");
- assertSubString("No pages matched your search criteria.", content);
+ assertHasRegexp("No pages matched your search criteria\\. Search took \\d+\\.\\d\\d seconds\\.", content);
+ assertSubString("document.getElementById(\"searchResultsTable\").style.display = 'none'", content);
}
@Test
@@ -166,6 +176,148 @@ public void testEscapesSearchString() throws Exception {
assertSubString("Content Search Results for '!+-<&>' ", content);
}
+ @Test
+ public void symbolicLinksAreSkippedByDefault() throws Exception {
+ addSymbolicLinkToLinkedPage();
+ request.addInput("searchScope", "root");
+ String content = getResponseContentUsingSearchString("linked content");
+
+ assertHasRegexp("LinkedPage", content);
+ assertDoesntHaveRegexp("SomePage\\.SymLink", content);
+ }
+
+ @Test
+ public void symbolicLinksAreSearchedOnRequest() throws Exception {
+ addSymbolicLinkToLinkedPage();
+ request.addInput("searchScope", "root");
+ request.addInput("includeSymLinks", "on");
+ String content = getResponseContentUsingSearchString("linked content");
+
+ assertHasRegexp("LinkedPage\">LinkedPage.*? \\s*\\s*no ", content);
+ assertHasRegexp("SomePage\\.SymLink\">SomePage\\.SymLink .*? \\s*\\s*yes ", content);
+ assertSubString("SomePage.SymLink .LinkedChild ", content);
+ }
+
+ @Test
+ public void allSymbolicLinksInPathAreMarked() throws Exception {
+ addSymbolicLinkToLinkedPage();
+ request.addInput("searchScope", "root");
+ request.addInput("includeSymLinks", "on");
+ String content = getResponseContentUsingSearchString("linked content");
+
+ assertSubString("LinkedPage.InnerLink ", content);
+ assertSubString("SomePage.SymLink .InnerLink ", content);
+ }
+
+ @Test
+ public void searchStartingAtSymbolicLinkSearchesLinkedPages() throws Exception {
+ addSymbolicLinkToLinkedPage();
+ request.setResource("SomePage.SymLink");
+ String content = getResponseContentUsingSearchString("linked content");
+
+ assertSubString("", content);
+ assertDoesntHaveRegexp("SomePage\\.SymLink\\.InnerLink", content);
+ }
+
+ @Test
+ public void wildcardPatternMatchesAnyCharactersForAsterisk() {
+ assertEquals("\\QSome\\E.*\\QTest\\E", SearchResponder.wildcardPattern("Some*Test").pattern());
+ assertEquals(".*", SearchResponder.wildcardPattern("*").pattern());
+ assertEquals("\\Qa.b\\E.*", SearchResponder.wildcardPattern("a.b*").pattern());
+ }
+
+ @Test
+ public void titleSearchWithWildcards() throws Exception {
+ request.addInput("searchType", "Search Titles");
+ request.addInput("useWildcards", "on");
+ String content = getResponseContentUsingSearchString("some*test");
+
+ assertSubString(" ", content);
+ assertDoesntHaveRegexp(" ", content);
+ }
+
+ @Test
+ public void asteriskIsLiteralWithoutWildcards() throws Exception {
+ request.addInput("searchType", "Search Titles");
+ String content = getResponseContentUsingSearchString("some*test");
+
+ assertSubString("No pages matched your search criteria.", content);
+ }
+
+ @Test
+ public void asteriskAloneFindsAllPages() throws Exception {
+ request.addInput("searchType", "Search Titles");
+ request.addInput("searchScope", "root");
+ request.addInput("useWildcards", "on");
+ String content = getResponseContentUsingSearchString("*");
+
+ assertSubString(" ", content);
+ assertSubString(" ", content);
+ assertSubString(" ", content);
+ }
+
+ @Test
+ public void contentSearchWithWildcards() throws Exception {
+ request.addInput("searchType", "Search Content");
+ request.addInput("useWildcards", "on");
+ request.addInput("searchScope", "root");
+ String content = getResponseContentUsingSearchString("TEST*content");
+
+ assertSubString(" ", content);
+ assertDoesntHaveRegexp(" ", content);
+ }
+
+ @Test
+ public void searchThreadsDefaultToOneAndAreLimited() {
+ responder.setRequest(request);
+ assertEquals(1, responder.getSearchThreads());
+ request.addInput("searchThreads", "3");
+ assertEquals(3, responder.getSearchThreads());
+ request.addInput("searchThreads", "99");
+ assertEquals(4, responder.getSearchThreads());
+ request.addInput("searchThreads", "0");
+ assertEquals(1, responder.getSearchThreads());
+ request.addInput("searchThreads", "many");
+ assertEquals(1, responder.getSearchThreads());
+ }
+
+ @Test
+ public void searchWithMoreThreadsFindsSamePages() throws Exception {
+ addSymbolicLinkToLinkedPage();
+ request.addInput("searchScope", "root");
+ request.addInput("includeSymLinks", "on");
+ String sequential = getResponseContentUsingSearchString("linked content");
+ request.addInput("searchThreads", "4");
+ String parallel = getResponseContentUsingSearchString("linked content");
+
+ assertEquals(resultRows(sequential), resultRows(parallel));
+ assertTrue(resultRows(parallel).contains("SomePage.SymLink.InnerLink"));
+ }
+
+ private List resultRows(String content) {
+ List rows = new ArrayList<>();
+ Matcher matcher = Pattern.compile("").matcher(content);
+ while (matcher.find()) {
+ rows.add(matcher.group(1));
+ }
+ return rows;
+ }
+
+ private void addSymbolicLinkToLinkedPage() {
+ WikiPage root = context.getRootPage();
+ WikiPage linkedPage = WikiPageUtil.addPage(root, PathParser.parse("LinkedPage"), "linked content");
+ WikiPageUtil.addPage(linkedPage, PathParser.parse("LinkedChild"), "linked content");
+ WikiPageUtil.addPage(root, PathParser.parse("OtherPage"), "linked content");
+ addSymbolicLink(linkedPage, "InnerLink", ".OtherPage");
+ addSymbolicLink(root.getChildPage("SomePage"), "SymLink", ".LinkedPage");
+ }
+
+ private void addSymbolicLink(WikiPage page, String linkName, String linkPath) {
+ PageData data = page.getData();
+ data.getProperties().set(SymbolicPage.PROPERTY_NAME).set(linkName, linkPath);
+ page.commit(data);
+ }
+
private String getResponseContentUsingSearchString(String searchString) throws Exception {
request.addInput("searchString", searchString);
request.addInput(Request.NOCHUNK, "");
diff --git a/test/fitnesse/wiki/SymbolicLinkPruningStrategyTest.java b/test/fitnesse/wiki/SymbolicLinkPruningStrategyTest.java
new file mode 100644
index 000000000..01f557754
--- /dev/null
+++ b/test/fitnesse/wiki/SymbolicLinkPruningStrategyTest.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2026 by netcare GmbH. All rights reserved.
+// Released under the terms of the CPL Common Public License version 1.0.
+package fitnesse.wiki;
+
+import fitnesse.wiki.fs.InMemoryPage;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static java.util.Arrays.asList;
+import static org.junit.Assert.assertEquals;
+
+public class SymbolicLinkPruningStrategyTest {
+ private WikiPage root;
+
+ @Before
+ public void setUp() {
+ root = InMemoryPage.makeRoot("RooT");
+ WikiPage linkingPage = WikiPageUtil.addPage(root, PathParser.parse("LinkingPage"), "");
+ WikiPage linkedPage = WikiPageUtil.addPage(root, PathParser.parse("LinkedPage"), "");
+ WikiPageUtil.addPage(linkedPage, PathParser.parse("ChildPage"), "");
+ WikiPageUtil.addPage(root, PathParser.parse("OtherPage"), "");
+ addSymbolicLink(linkingPage, "SymLink", ".LinkedPage");
+ addSymbolicLink(linkedPage, "InnerLink", ".OtherPage");
+ }
+
+ @Test
+ public void leavesOutSymbolicLinks() {
+ assertEquals(asList("", "LinkedPage", "LinkedPage.ChildPage", "LinkingPage", "OtherPage"),
+ traverse(root, new SymbolicLinkPruningStrategy()));
+ }
+
+ @Test
+ public void followsSymbolicLinksWithoutPruning() {
+ assertEquals(asList("", "LinkedPage", "LinkedPage.ChildPage", "LinkedPage.InnerLink", "LinkingPage",
+ "LinkingPage.SymLink", "LinkingPage.SymLink.ChildPage", "LinkingPage.SymLink.InnerLink", "OtherPage"),
+ traverse(root, new NoPruningStrategy()));
+ }
+
+ @Test
+ public void traversesSymbolicLinkItStartsAtButNoSymbolicLinksBelow() {
+ WikiPage symLink = root.getChildPage("LinkingPage").getChildPage("SymLink");
+ assertEquals(asList("LinkingPage.SymLink", "LinkingPage.SymLink.ChildPage"),
+ traverse(symLink, new SymbolicLinkPruningStrategy()));
+ }
+
+ private List traverse(WikiPage startPage, PagePruningStrategy strategy) {
+ List paths = new ArrayList<>();
+ startPage.getPageCrawler().traverse(page -> paths.add(page.getFullPath().toString()), strategy);
+ paths.sort(null);
+ return paths;
+ }
+
+ private void addSymbolicLink(WikiPage page, String linkName, String linkPath) {
+ PageData data = page.getData();
+ data.getProperties().set(SymbolicPage.PROPERTY_NAME).set(linkName, linkPath);
+ page.commit(data);
+ }
+}
diff --git a/test/fitnesse/wiki/SymbolicPageTest.java b/test/fitnesse/wiki/SymbolicPageTest.java
index a464eccd3..378e9a4b8 100644
--- a/test/fitnesse/wiki/SymbolicPageTest.java
+++ b/test/fitnesse/wiki/SymbolicPageTest.java
@@ -276,4 +276,28 @@ private void commitNewContent(WikiPage wikiPage) {
data.setContent("new content");
wikiPage.commit(data);
}
+
+ @Test
+ public void realPageIsNoSymbolicLink() {
+ assertFalse(SymbolicPage.isSymbolicLink(pageOne));
+ }
+
+ @Test
+ public void symbolicPageBelowRealPageIsSymbolicLink() {
+ assertTrue(SymbolicPage.isSymbolicLink(symPage));
+ }
+
+ @Test
+ public void pageBelowSymbolicLinkIsNoSymbolicLink() {
+ WikiPageUtil.addPage(pageTwo, PathParser.parse("ChildPage"), "");
+ WikiPage child = symPage.getChildPage("ChildPage");
+ assertTrue(child.isSymbolicPage());
+ assertFalse(SymbolicPage.isSymbolicLink(child));
+ }
+
+ @Test
+ public void symbolicLinkBelowSymbolicLinkIsSymbolicLink() {
+ WikiPage innerLink = new SymbolicPage("InnerLink", new SymbolicPage("InnerLink", pageOne, pageTwo), symPage);
+ assertTrue(SymbolicPage.isSymbolicLink(innerLink));
+ }
}
diff --git a/test/fitnesse/wiki/search/ParallelTraversalTest.java b/test/fitnesse/wiki/search/ParallelTraversalTest.java
new file mode 100644
index 000000000..ad2c8fbb1
--- /dev/null
+++ b/test/fitnesse/wiki/search/ParallelTraversalTest.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2026 by netcare GmbH. All rights reserved.
+// Released under the terms of the CPL Common Public License version 1.0.
+package fitnesse.wiki.search;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import fitnesse.wiki.NoPruningStrategy;
+import fitnesse.wiki.PageData;
+import fitnesse.wiki.PagePruningStrategy;
+import fitnesse.wiki.PathParser;
+import fitnesse.wiki.PrunedPagePruningStrategy;
+import fitnesse.wiki.SymbolicLinkPruningStrategy;
+import fitnesse.wiki.SymbolicPage;
+import fitnesse.wiki.WikiPage;
+import fitnesse.wiki.WikiPageDummy;
+import fitnesse.wiki.WikiPageLoadException;
+import fitnesse.wiki.WikiPageProperty;
+import fitnesse.wiki.WikiPageUtil;
+import fitnesse.wiki.fs.InMemoryPage;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class ParallelTraversalTest {
+ private WikiPage root;
+
+ @Before
+ public void setUp() {
+ root = InMemoryPage.makeRoot("RooT");
+ for (int i = 0; i < 5; i++) {
+ for (int j = 0; j < 5; j++) {
+ WikiPageUtil.addPage(root, PathParser.parse("SuitePage" + i + ".SubSuite" + j + ".TestPage"), "content");
+ }
+ }
+ WikiPage prunedPage = root.getChildPage("SuitePage3");
+ PageData data = prunedPage.getData();
+ data.setAttribute(WikiPageProperty.PRUNE);
+ prunedPage.commit(data);
+ WikiPage linkingPage = root.getChildPage("SuitePage1");
+ data = linkingPage.getData();
+ data.getProperties().set(SymbolicPage.PROPERTY_NAME).set("SymLink", ".SuitePage2");
+ linkingPage.commit(data);
+ }
+
+ @Test
+ public void visitsPagesInSameOrderAsPageCrawler() {
+ assertSameOrderAsPageCrawler(new NoPruningStrategy());
+ }
+
+ @Test
+ public void honoursPruningStrategy() {
+ List paths = assertSameOrderAsPageCrawler(new PrunedPagePruningStrategy());
+ assertFalse(paths.contains("SuitePage3"));
+ assertTrue(paths.contains("SuitePage1.SymLink.SubSuite0.TestPage"));
+ }
+
+ @Test
+ public void skipsSymbolicLinks() {
+ List paths = assertSameOrderAsPageCrawler(new SymbolicLinkPruningStrategy());
+ assertFalse(paths.contains("SuitePage1.SymLink"));
+ }
+
+ @Test(expected = WikiPageLoadException.class)
+ public void passesOnErrorsLoadingPages() {
+ WikiPage brokenPage = new WikiPageDummy("BrokenPage", "", root) {
+ @Override
+ public List getChildren() {
+ throw new WikiPageLoadException(new IOException("broken"));
+ }
+ };
+ new ParallelTraversal(page -> {}, new NoPruningStrategy(), true, 4).traverse(brokenPage);
+ }
+
+ private List assertSameOrderAsPageCrawler(PagePruningStrategy strategy) {
+ List expected = new ArrayList<>();
+ root.getPageCrawler().traverse(page -> expected.add(page.getFullPath().toString()), strategy);
+ List actual = new ArrayList<>();
+ new ParallelTraversal(page -> actual.add(page.getFullPath().toString()), strategy, true, 4).traverse(root);
+ assertEquals(expected, actual);
+ return actual;
+ }
+}