From df4f14073bfa2a45af8fb48e1b95028152346e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Schr=C3=B6ter?= Date: Mon, 2 Nov 2026 18:34:48 +0000 Subject: [PATCH 01/12] Skip symbolic links in search by default Text, method and properties search no longer follow symbolic links unless "Include symbolic links" is checked (includeSymLinks input). Following links finds the same pages again under other paths and can make searches on large wikis slow. A search that starts at a symbolic link still covers the linked pages. The search results show whether a page is reached via a symbolic link, mark every symbolic link in the page path and report how long the search took. --- .../FitNesse/ReleaseNotes/content.txt | 1 + .../RestfulServices/content.txt | 2 + src/fitnesse/resources/css/fitnesse_pages.css | 6 ++ .../resources/templates/searchForm.vm | 12 ++++ .../resources/templates/searchResults.vm | 16 +++-- .../responders/search/ResultResponder.java | 47 +++++++++++++- .../wiki/SymbolicLinkPruningStrategy.java | 21 +++++++ src/fitnesse/wiki/SymbolicPage.java | 10 +++ .../search/SearchResponderTest.java | 63 ++++++++++++++++++- .../wiki/SymbolicLinkPruningStrategyTest.java | 55 ++++++++++++++++ test/fitnesse/wiki/SymbolicPageTest.java | 24 +++++++ 11 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 src/fitnesse/wiki/SymbolicLinkPruningStrategy.java create mode 100644 test/fitnesse/wiki/SymbolicLinkPruningStrategyTest.java diff --git a/FitNesseRoot/FitNesse/ReleaseNotes/content.txt b/FitNesseRoot/FitNesse/ReleaseNotes/content.txt index 41bb45ac63..9338c6c60d 100644 --- a/FitNesseRoot/FitNesse/ReleaseNotes/content.txt +++ b/FitNesseRoot/FitNesse/ReleaseNotes/content.txt @@ -1,4 +1,5 @@ !2 Pending Changes + * Text, method and properties search skip symbolic links by default; check ''Include symbolic links'' to search them too. !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 93c9a3c719..2b41e9594b 100644 --- a/FitNesseRoot/FitNesse/UserGuide/AdministeringFitNesse/RestfulServices/content.txt +++ b/FitNesseRoot/FitNesse/UserGuide/AdministeringFitNesse/RestfulServices/content.txt @@ -134,6 +134,7 @@ | | 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. | | | 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. | | 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 +144,7 @@ | | 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. | | 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 194f287833..c0242f3246 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 d33c35c405..1d93ea5b8d 100644 --- a/src/fitnesse/resources/templates/searchForm.vm +++ b/src/fitnesse/resources/templates/searchForm.vm @@ -27,6 +27,10 @@ #end $page.getName() +
+ + Include symbolic links +
@@ -54,6 +58,10 @@ #end $page.getName()
+
+ + Include symbolic links +
Back @@ -75,6 +83,10 @@ #end $page.getName()
+
+ + Include symbolic links +
diff --git a/src/fitnesse/resources/templates/searchResults.vm b/src/fitnesse/resources/templates/searchResults.vm index e49a83645f..1492fe0a9c 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($result.isSymbolicPage())yes#{else}no#end $result.getData().getProperties().getLastModificationTime() @@ -60,9 +68,9 @@
[[Search below !-$page.getName()-! for <description>][$page.getPageCrawler().getFullPath()?$queryString]]
#if ($hits > 1) - + #elseif ($hits == 1) - + #else - + #end diff --git a/src/fitnesse/responders/search/ResultResponder.java b/src/fitnesse/responders/search/ResultResponder.java index d19aa68c9c..6274d6b8ac 100644 --- a/src/fitnesse/responders/search/ResultResponder.java +++ b/src/fitnesse/responders/search/ResultResponder.java @@ -12,10 +12,16 @@ import fitnesse.responders.ChunkingResponder; import fitnesse.wiki.PageCrawler; 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 +43,10 @@ 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"; + + private long searchStartTime; + private long searchEndTime; @Override protected PageCrawler getPageCrawler() { @@ -90,12 +100,47 @@ 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 && !request.hasInput(INCLUDE_SYMBOLIC_LINKS)) { + WikiPage searchScope = getSearchScope(); + ((WikiPageFinder) pageFinder).search(searchScope, new SymbolicLinkPruningStrategy(searchScope)); + } else if (pageFinder != null) { pageFinder.search(getSearchScope()); } } + /** + * @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/wiki/SymbolicLinkPruningStrategy.java b/src/fitnesse/wiki/SymbolicLinkPruningStrategy.java new file mode 100644 index 0000000000..c2177d7a9c --- /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; + +/** + * Skips symbolic links (and everything below them) found while traversing from a start page. + * The start page itself is never skipped, so traversals that start inside a linked tree still + * cover that tree. + */ +public class SymbolicLinkPruningStrategy implements PagePruningStrategy { + private final WikiPage startPage; + + public SymbolicLinkPruningStrategy(WikiPage startPage) { + this.startPage = startPage; + } + + @Override + public boolean skipPageAndChildren(WikiPage page) { + return page != startPage && SymbolicPage.isSymbolicLink(page); + } +} diff --git a/src/fitnesse/wiki/SymbolicPage.java b/src/fitnesse/wiki/SymbolicPage.java index b56e2805c7..bfb32d08c9 100644 --- a/src/fitnesse/wiki/SymbolicPage.java +++ b/src/fitnesse/wiki/SymbolicPage.java @@ -146,6 +146,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/test/fitnesse/responders/search/SearchResponderTest.java b/test/fitnesse/responders/search/SearchResponderTest.java index 6b6b76640c..c08928071a 100644 --- a/test/fitnesse/responders/search/SearchResponderTest.java +++ b/test/fitnesse/responders/search/SearchResponderTest.java @@ -127,6 +127,7 @@ public void testHtml() throws Exception { assertHasRegexp("something", content); assertHasRegexp("SomePage", content); + assertHasRegexp("Found 1 result for your search in \\d+\\.\\d\\d seconds\\.", content); } @Test @@ -151,13 +152,13 @@ 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); } @Test @@ -166,6 +167,64 @@ 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); + } + + 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 0000000000..2f4ebe0c50 --- /dev/null +++ b/test/fitnesse/wiki/SymbolicLinkPruningStrategyTest.java @@ -0,0 +1,55 @@ +// 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 static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class SymbolicLinkPruningStrategyTest { + private WikiPage root; + private WikiPage linkingPage; + private WikiPage symLink; + + @Before + public void setUp() { + root = InMemoryPage.makeRoot("RooT"); + linkingPage = WikiPageUtil.addPage(root, PathParser.parse("LinkingPage"), ""); + WikiPage linkedPage = WikiPageUtil.addPage(root, PathParser.parse("LinkedPage"), ""); + WikiPageUtil.addPage(linkedPage, PathParser.parse("ChildPage"), ""); + symLink = new SymbolicPage("SymLink", linkedPage, linkingPage); + } + + @Test + public void skipsSymbolicLink() { + assertTrue(new SymbolicLinkPruningStrategy(root).skipPageAndChildren(symLink)); + } + + @Test + public void doesNotSkipRealPages() { + PagePruningStrategy strategy = new SymbolicLinkPruningStrategy(root); + assertFalse(strategy.skipPageAndChildren(root)); + assertFalse(strategy.skipPageAndChildren(linkingPage)); + } + + @Test + public void doesNotSkipSymbolicLinkItStartsAt() { + assertFalse(new SymbolicLinkPruningStrategy(symLink).skipPageAndChildren(symLink)); + } + + @Test + public void skipsSymbolicLinkBelowSymbolicLinkItStartsAt() { + WikiPage innerLink = new SymbolicPage("InnerLink", new SymbolicPage("InnerLink", root, symLink.getRealPage()), symLink); + assertTrue(new SymbolicLinkPruningStrategy(symLink).skipPageAndChildren(innerLink)); + } + + @Test + public void doesNotSkipChildrenOfSymbolicLinkItStartsAt() { + WikiPage child = symLink.getChildPage("ChildPage"); + assertTrue(child.isSymbolicPage()); + assertFalse(new SymbolicLinkPruningStrategy(symLink).skipPageAndChildren(child)); + } +} diff --git a/test/fitnesse/wiki/SymbolicPageTest.java b/test/fitnesse/wiki/SymbolicPageTest.java index a464eccd33..378e9a4b82 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)); + } } From f86bf850313b2a8a3747588922a75403cd40eb2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Schr=C3=B6ter?= Date: Mon, 2 Nov 2026 18:50:25 +0000 Subject: [PATCH 02/12] Hide the search results table when nothing matched --- src/fitnesse/resources/templates/searchResults.vm | 3 ++- test/fitnesse/responders/search/SearchResponderTest.java | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/fitnesse/resources/templates/searchResults.vm b/src/fitnesse/resources/templates/searchResults.vm index 1492fe0a9c..5ac6de8ab9 100644 --- a/src/fitnesse/resources/templates/searchResults.vm +++ b/src/fitnesse/resources/templates/searchResults.vm @@ -72,5 +72,6 @@ #elseif ($hits == 1) #else - + #end diff --git a/test/fitnesse/responders/search/SearchResponderTest.java b/test/fitnesse/responders/search/SearchResponderTest.java index c08928071a..14424751f5 100644 --- a/test/fitnesse/responders/search/SearchResponderTest.java +++ b/test/fitnesse/responders/search/SearchResponderTest.java @@ -128,6 +128,7 @@ 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 @@ -159,6 +160,7 @@ public void testLastModifiedSortLink() throws Exception { public void testNoSearchStringBringsUpNoResults() throws Exception { String content = getResponseContentUsingSearchString(""); assertHasRegexp("No pages matched your search criteria\\. Search took \\d+\\.\\d\\d seconds\\.", content); + assertSubString("document.getElementById(\"searchResultsTable\").style.display = 'none'", content); } @Test From c6179ffaeec9e1439f1edfc7d4260e3e0a238df5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Schr=C3=B6ter?= Date: Mon, 2 Nov 2026 19:00:05 +0000 Subject: [PATCH 03/12] Keep replacing in pages behind symbolic links Search and replace uses the search code, which now skips symbolic links by default. Replace has always changed the pages behind symbolic links too, and its form offers no option to include them, so it keeps doing so. --- .../refactoring/SearchReplaceResponder.java | 6 ++++++ src/fitnesse/responders/search/ResultResponder.java | 9 ++++++++- .../refactoring/SearchReplaceResponderTest.java | 13 +++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/fitnesse/responders/refactoring/SearchReplaceResponder.java b/src/fitnesse/responders/refactoring/SearchReplaceResponder.java index f6dc8c3a5f..ead6a1eafc 100644 --- a/src/fitnesse/responders/refactoring/SearchReplaceResponder.java +++ b/src/fitnesse/responders/refactoring/SearchReplaceResponder.java @@ -45,6 +45,12 @@ protected PageFinder getPageFinder(TraversalListener webOutputObserver } } + @Override + protected boolean includeSymbolicLinks() { + // replace has always changed the pages behind symbolic links too + return true; + } + private boolean isMethodReplace() { return request.hasInput("isMethodReplace"); } diff --git a/src/fitnesse/responders/search/ResultResponder.java b/src/fitnesse/responders/search/ResultResponder.java index 6274d6b8ac..1a44554788 100644 --- a/src/fitnesse/responders/search/ResultResponder.java +++ b/src/fitnesse/responders/search/ResultResponder.java @@ -117,7 +117,7 @@ public String getSearchTime() { private void search(TraversalListener observer) { PageFinder pageFinder = getPageFinder(observer); - if (pageFinder instanceof WikiPageFinder && !request.hasInput(INCLUDE_SYMBOLIC_LINKS)) { + if (pageFinder instanceof WikiPageFinder && !includeSymbolicLinks()) { WikiPage searchScope = getSearchScope(); ((WikiPageFinder) pageFinder).search(searchScope, new SymbolicLinkPruningStrategy(searchScope)); } else if (pageFinder != null) { @@ -125,6 +125,13 @@ private void search(TraversalListener observer) { } } + /** + * @return true to also search the pages behind symbolic links, as requested; false by default. + */ + protected boolean includeSymbolicLinks() { + return request.hasInput(INCLUDE_SYMBOLIC_LINKS); + } + /** * @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 diff --git a/test/fitnesse/responders/refactoring/SearchReplaceResponderTest.java b/test/fitnesse/responders/refactoring/SearchReplaceResponderTest.java index 83b0a46d3b..8aed0594e1 100644 --- a/test/fitnesse/responders/refactoring/SearchReplaceResponderTest.java +++ b/test/fitnesse/responders/refactoring/SearchReplaceResponderTest.java @@ -77,6 +77,19 @@ 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()); + } + private String getResponseContentUsingSearchReplaceString(String searchString, String replacementString) throws Exception { request.addInput("searchString", searchString); request.addInput("replacementString", replacementString); From b9b2e3169aa4e66dcecc75755c8f4f5c96b0fa1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Schr=C3=B6ter?= Date: Mon, 2 Nov 2026 18:35:52 +0000 Subject: [PATCH 04/12] Speed up search when symbolic links are skipped Finding the children of a page read the page data (content and properties) to look up symbolic links, so even a title search read every page file. Pages can now list their children without symbolic links (WikiPage.getChildrenWithoutSymbolicLinks), which file based pages do from the directory listing alone. Traversals use it when the pruning strategy skips symbolic links. Also stop looking up a symbolic link for every non-page file in a page directory: symbolic links are collected from the properties anyway. On a wiki with 50,000 pages, title search went from ~2.5s to ~1.3s. --- .../FitNesse/ReleaseNotes/content.txt | 1 + .../responders/search/ResultResponder.java | 3 +- src/fitnesse/wiki/PageCrawler.java | 5 +- src/fitnesse/wiki/PagePruningStrategy.java | 7 +++ .../wiki/SymbolicLinkPruningStrategy.java | 16 +++--- src/fitnesse/wiki/SymbolicPage.java | 9 ++++ src/fitnesse/wiki/WikiPage.java | 17 ++++++ src/fitnesse/wiki/fs/FileSystemPage.java | 5 ++ .../wiki/fs/FileSystemSubWikiPageFactory.java | 7 ++- src/fitnesse/wiki/fs/SubWikiPageFactory.java | 2 + src/fitnesse/wiki/fs/WikiFilePage.java | 5 ++ .../wiki/SymbolicLinkPruningStrategyTest.java | 52 +++++++++++-------- 12 files changed, 94 insertions(+), 35 deletions(-) diff --git a/FitNesseRoot/FitNesse/ReleaseNotes/content.txt b/FitNesseRoot/FitNesse/ReleaseNotes/content.txt index 9338c6c60d..a0e865f4f7 100644 --- a/FitNesseRoot/FitNesse/ReleaseNotes/content.txt +++ b/FitNesseRoot/FitNesse/ReleaseNotes/content.txt @@ -1,5 +1,6 @@ !2 Pending Changes * Text, method and properties search skip symbolic links by default; check ''Include symbolic links'' to search them too. + * Faster title search: when symbolic links are skipped, page files are no longer read just to find child pages. !2 20261101 * Refactor page shows tree structure ([[1617][https://github.com/unclebob/fitnesse/pull/1617]]). diff --git a/src/fitnesse/responders/search/ResultResponder.java b/src/fitnesse/responders/search/ResultResponder.java index 1a44554788..4f6eac5022 100644 --- a/src/fitnesse/responders/search/ResultResponder.java +++ b/src/fitnesse/responders/search/ResultResponder.java @@ -118,8 +118,7 @@ public String getSearchTime() { private void search(TraversalListener observer) { PageFinder pageFinder = getPageFinder(observer); if (pageFinder instanceof WikiPageFinder && !includeSymbolicLinks()) { - WikiPage searchScope = getSearchScope(); - ((WikiPageFinder) pageFinder).search(searchScope, new SymbolicLinkPruningStrategy(searchScope)); + ((WikiPageFinder) pageFinder).search(getSearchScope(), new SymbolicLinkPruningStrategy()); } else if (pageFinder != null) { pageFinder.search(getSearchScope()); } diff --git a/src/fitnesse/wiki/PageCrawler.java b/src/fitnesse/wiki/PageCrawler.java index 8c20f4cd9b..de4c0bdf00 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 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 8261357503..05828302ee 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 index c2177d7a9c..57a55956d2 100644 --- a/src/fitnesse/wiki/SymbolicLinkPruningStrategy.java +++ b/src/fitnesse/wiki/SymbolicLinkPruningStrategy.java @@ -3,19 +3,19 @@ package fitnesse.wiki; /** - * Skips symbolic links (and everything below them) found while traversing from a start page. - * The start page itself is never skipped, so traversals that start inside a linked tree still - * cover that tree. + * 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 { - private final WikiPage startPage; - public SymbolicLinkPruningStrategy(WikiPage startPage) { - this.startPage = startPage; + @Override + public boolean skipPageAndChildren(WikiPage page) { + return false; } @Override - public boolean skipPageAndChildren(WikiPage page) { - return page != startPage && SymbolicPage.isSymbolicLink(page); + public boolean skipSymbolicLinks() { + return true; } } diff --git a/src/fitnesse/wiki/SymbolicPage.java b/src/fitnesse/wiki/SymbolicPage.java index bfb32d08c9..0493fb04dc 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) { diff --git a/src/fitnesse/wiki/WikiPage.java b/src/fitnesse/wiki/WikiPage.java index 77aa3b7d06..d0acd81154 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 7ad7ec3f88..cb9bd09304 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 9b1ca91154..d70aa538ae 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 99a4f2378c..96faf3f1bf 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 c2e692961c..80ab207927 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/test/fitnesse/wiki/SymbolicLinkPruningStrategyTest.java b/test/fitnesse/wiki/SymbolicLinkPruningStrategyTest.java index 2f4ebe0c50..01f557754a 100644 --- a/test/fitnesse/wiki/SymbolicLinkPruningStrategyTest.java +++ b/test/fitnesse/wiki/SymbolicLinkPruningStrategyTest.java @@ -6,50 +6,56 @@ import org.junit.Before; import org.junit.Test; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; +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; - private WikiPage linkingPage; - private WikiPage symLink; @Before public void setUp() { root = InMemoryPage.makeRoot("RooT"); - linkingPage = WikiPageUtil.addPage(root, PathParser.parse("LinkingPage"), ""); + WikiPage linkingPage = WikiPageUtil.addPage(root, PathParser.parse("LinkingPage"), ""); WikiPage linkedPage = WikiPageUtil.addPage(root, PathParser.parse("LinkedPage"), ""); WikiPageUtil.addPage(linkedPage, PathParser.parse("ChildPage"), ""); - symLink = new SymbolicPage("SymLink", linkedPage, linkingPage); + WikiPageUtil.addPage(root, PathParser.parse("OtherPage"), ""); + addSymbolicLink(linkingPage, "SymLink", ".LinkedPage"); + addSymbolicLink(linkedPage, "InnerLink", ".OtherPage"); } @Test - public void skipsSymbolicLink() { - assertTrue(new SymbolicLinkPruningStrategy(root).skipPageAndChildren(symLink)); + public void leavesOutSymbolicLinks() { + assertEquals(asList("", "LinkedPage", "LinkedPage.ChildPage", "LinkingPage", "OtherPage"), + traverse(root, new SymbolicLinkPruningStrategy())); } @Test - public void doesNotSkipRealPages() { - PagePruningStrategy strategy = new SymbolicLinkPruningStrategy(root); - assertFalse(strategy.skipPageAndChildren(root)); - assertFalse(strategy.skipPageAndChildren(linkingPage)); + 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 doesNotSkipSymbolicLinkItStartsAt() { - assertFalse(new SymbolicLinkPruningStrategy(symLink).skipPageAndChildren(symLink)); + public void traversesSymbolicLinkItStartsAtButNoSymbolicLinksBelow() { + WikiPage symLink = root.getChildPage("LinkingPage").getChildPage("SymLink"); + assertEquals(asList("LinkingPage.SymLink", "LinkingPage.SymLink.ChildPage"), + traverse(symLink, new SymbolicLinkPruningStrategy())); } - @Test - public void skipsSymbolicLinkBelowSymbolicLinkItStartsAt() { - WikiPage innerLink = new SymbolicPage("InnerLink", new SymbolicPage("InnerLink", root, symLink.getRealPage()), symLink); - assertTrue(new SymbolicLinkPruningStrategy(symLink).skipPageAndChildren(innerLink)); + 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; } - @Test - public void doesNotSkipChildrenOfSymbolicLinkItStartsAt() { - WikiPage child = symLink.getChildPage("ChildPage"); - assertTrue(child.isSymbolicPage()); - assertFalse(new SymbolicLinkPruningStrategy(symLink).skipPageAndChildren(child)); + 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); } } From 9950d8cd5ac58fadfe033b79f910192b046f459a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Schr=C3=B6ter?= Date: Mon, 2 Nov 2026 19:00:48 +0000 Subject: [PATCH 05/12] Optionally read pages with multiple threads when searching Text, method and properties search accept a searchThreads input (1-4, "Threads" on the search form). With more than one thread, the children and page data of the pages to visit are loaded ahead on worker threads, while pages are still matched and reported one at a time, on the request thread and in the same order. 1, the default, searches sequentially as before. On a wiki with 50,000 pages, 4 threads made content and properties search about 2.3 times and title search about 1.9 times faster. # Conflicts: # src/fitnesse/responders/search/ResultResponder.java --- .../FitNesse/ReleaseNotes/content.txt | 1 + .../RestfulServices/content.txt | 2 + .../resources/templates/searchForm.vm | 18 ++++ .../responders/search/ResultResponder.java | 32 +++++- .../wiki/search/ParallelTraversal.java | 97 +++++++++++++++++++ .../wiki/search/TitleWikiPageFinder.java | 5 + src/fitnesse/wiki/search/WikiPageFinder.java | 26 ++++- .../search/SearchResponderTest.java | 43 ++++++++ .../wiki/search/ParallelTraversalTest.java | 87 +++++++++++++++++ 9 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 src/fitnesse/wiki/search/ParallelTraversal.java create mode 100644 test/fitnesse/wiki/search/ParallelTraversalTest.java diff --git a/FitNesseRoot/FitNesse/ReleaseNotes/content.txt b/FitNesseRoot/FitNesse/ReleaseNotes/content.txt index a0e865f4f7..0a31edf29f 100644 --- a/FitNesseRoot/FitNesse/ReleaseNotes/content.txt +++ b/FitNesseRoot/FitNesse/ReleaseNotes/content.txt @@ -1,6 +1,7 @@ !2 Pending Changes * Text, method and properties search skip symbolic links by default; check ''Include symbolic links'' to search them too. * Faster title search: when symbolic links are skipped, page files are no longer read just to find child pages. + * Searches can read pages with up to 4 threads in parallel (''Threads'' on the search form, searchThreads input). !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 2b41e9594b..7f119bd476 100644 --- a/FitNesseRoot/FitNesse/UserGuide/AdministeringFitNesse/RestfulServices/content.txt +++ b/FitNesseRoot/FitNesse/UserGuide/AdministeringFitNesse/RestfulServices/content.txt @@ -135,6 +135,7 @@ | | isMethodSearch | If present, a method search is performed instead of a text search. Only works together with searchType=content. | | | 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.) | @@ -145,6 +146,7 @@ | | 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/templates/searchForm.vm b/src/fitnesse/resources/templates/searchForm.vm index 1d93ea5b8d..75a1dda1a8 100644 --- a/src/fitnesse/resources/templates/searchForm.vm +++ b/src/fitnesse/resources/templates/searchForm.vm @@ -30,6 +30,12 @@
Include symbolic links + +
@@ -61,6 +67,12 @@
Include symbolic links + +
@@ -86,6 +98,12 @@
Include symbolic links + +
diff --git a/src/fitnesse/responders/search/ResultResponder.java b/src/fitnesse/responders/search/ResultResponder.java index 4f6eac5022..a3a8d615df 100644 --- a/src/fitnesse/responders/search/ResultResponder.java +++ b/src/fitnesse/responders/search/ResultResponder.java @@ -10,7 +10,9 @@ 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; @@ -44,6 +46,7 @@ public abstract class ResultResponder extends ChunkingResponder implements 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; @@ -117,8 +120,10 @@ public String getSearchTime() { private void search(TraversalListener observer) { PageFinder pageFinder = getPageFinder(observer); - if (pageFinder instanceof WikiPageFinder && !includeSymbolicLinks()) { - ((WikiPageFinder) pageFinder).search(getSearchScope(), new SymbolicLinkPruningStrategy()); + if (pageFinder instanceof WikiPageFinder) { + PagePruningStrategy strategy = includeSymbolicLinks() + ? new NoPruningStrategy() : new SymbolicLinkPruningStrategy(); + ((WikiPageFinder) pageFinder).search(getSearchScope(), strategy, getSearchThreads()); } else if (pageFinder != null) { pageFinder.search(getSearchScope()); } @@ -131,6 +136,29 @@ 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 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 diff --git a/src/fitnesse/wiki/search/ParallelTraversal.java b/src/fitnesse/wiki/search/ParallelTraversal.java new file mode 100644 index 0000000000..b13c8700b6 --- /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 listener; + private final PagePruningStrategy pruningStrategy; + private final boolean loadPageData; + private final boolean skipSymbolicLinks; + private final int threads; + private ExecutorService executor; + + ParallelTraversal(TraversalListener 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 a9dc037cdb..e59787c7ba 100644 --- a/src/fitnesse/wiki/search/TitleWikiPageFinder.java +++ b/src/fitnesse/wiki/search/TitleWikiPageFinder.java @@ -12,6 +12,11 @@ public TitleWikiPageFinder(String searchString, TraversalListener 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/search/SearchResponderTest.java b/test/fitnesse/responders/search/SearchResponderTest.java index 14424751f5..da75e242b2 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; @@ -212,6 +219,42 @@ public void searchStartingAtSymbolicLinkSearchesLinkedPages() throws Exception { assertDoesntHaveRegexp("SomePage\\.SymLink\\.InnerLink", 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"); diff --git a/test/fitnesse/wiki/search/ParallelTraversalTest.java b/test/fitnesse/wiki/search/ParallelTraversalTest.java new file mode 100644 index 0000000000..ad2c8fbb12 --- /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; + } +} From ebfd77df2b27f6dffcb6dfd96188257a53c614f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Schr=C3=B6ter?= Date: Mon, 2 Nov 2026 18:47:44 +0000 Subject: [PATCH 06/12] Preselect the most search threads on the search form The REST API still searches with one thread unless searchThreads is given. --- src/fitnesse/resources/templates/searchForm.vm | 6 +++--- src/fitnesse/responders/search/ResultResponder.java | 7 +++++++ .../responders/search/SearchFormResponderTest.java | 7 +++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/fitnesse/resources/templates/searchForm.vm b/src/fitnesse/resources/templates/searchForm.vm index 75a1dda1a8..03c26a8254 100644 --- a/src/fitnesse/resources/templates/searchForm.vm +++ b/src/fitnesse/resources/templates/searchForm.vm @@ -33,7 +33,7 @@
@@ -70,7 +70,7 @@
@@ -101,7 +101,7 @@
diff --git a/src/fitnesse/responders/search/ResultResponder.java b/src/fitnesse/responders/search/ResultResponder.java index a3a8d615df..baac0f4625 100644 --- a/src/fitnesse/responders/search/ResultResponder.java +++ b/src/fitnesse/responders/search/ResultResponder.java @@ -148,6 +148,13 @@ public int getSearchThreads() { } } + /** + * @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. */ diff --git a/test/fitnesse/responders/search/SearchFormResponderTest.java b/test/fitnesse/responders/search/SearchFormResponderTest.java index ce516d25a2..d7c2f3a157 100644 --- a/test/fitnesse/responders/search/SearchFormResponderTest.java +++ b/test/fitnesse/responders/search/SearchFormResponderTest.java @@ -2,6 +2,7 @@ // Released under the terms of the CPL Common Public License version 1.0. package fitnesse.responders.search; +import static util.RegexTestCase.assertDoesntHaveRegexp; import static util.RegexTestCase.assertHasRegexp; import static util.RegexTestCase.assertSubString; import static fitnesse.responders.search.SearchResponder.*; @@ -53,6 +54,12 @@ public void testForTwoSearchTypes() throws Exception { assertSubString("value=\"Search Content\"", content); } + @Test + public void mostSearchThreadsArePreselected() throws Exception { + assertSubString("", content); + assertDoesntHaveRegexp("