A guided, interactive tour that walks users through the most important search and results features of NZBHydra2. The tour is accessible via a button on the search page and operates in a demo mode so that no real indexer calls are made.
- A small button (e.g., a question-mark or "Take a Tour" link) is always visible on the search page, placed near the search form (e.g., below the "Go!" button or in the form's footer area).
- Clicking it activates demo mode and begins the tour from step 1.
- The tour always restarts from the beginning if closed and re-opened.
The tour uses a hybrid automation model: some actions (filling fields, clicking buttons) are performed automatically by the tour, while others (checkbox selection, shift-click) are performed by the user with guidance.
| Step | Type | Description |
|---|---|---|
| 1 | Info | Welcome popover: "This tour will walk you through searching and browsing results." |
| 2 | Highlight | Highlight the category dropdown. Explain that categories control what type of content is searched. |
| 3 | Auto | Automatically select the "All" category (if not already selected). |
| 4 | Highlight | Highlight the search input field. Explain that this is where you enter your search query. |
| 5 | Auto | Automatically type "linux" into the search field (with a visible typing animation, ~50ms per character). |
| 6 | Highlight | Highlight the "Go!" button. Explain "Click Go! to search all configured indexers." |
| 7 | Auto | Automatically click the "Go!" button. The backend demo mode returns mock search results. The tour waits for results to load. |
| Step | Type | Description |
|---|---|---|
| 8 | Highlight | Highlight the results table. Explain the columns: title, indexer, category, size, grabs, age, links. |
| 9 | Highlight | Highlight a column header (e.g., "Age"). Explain that clicking a column header sorts results by that column. |
| 10 | User action | Prompt user: "Try clicking the 'Age' column header to sort by age." Wait for the user to click. |
| 11 | Highlight | Highlight the title filter input. Explain: "Type here to filter results by title. Use !word to exclude, /regex/ for regex." |
| 12 | User action | Prompt user: "Try typing a filter term." Wait briefly, then proceed. |
| 13 | Highlight | Highlight the filter buttons (source/quality). Explain: "Use these buttons to quickly filter by video source or quality." |
| Step | Type | Description |
|---|---|---|
| 14 | Highlight | Highlight the download icon(s) next to a result. Explain: "Click a downloader icon to send this single result to your downloader." |
| 15 | Auto | Automatically click a download icon. The backend demo mode returns a success response. Show the icon change to a success checkmark. |
| 16 | Highlight | Highlight the direct download link. Explain: "You can also download the NZB file directly by clicking this link." |
| Step | Type | Description |
|---|---|---|
| 17 | Highlight | Highlight the checkbox on a result row. Explain: "Use checkboxes to select multiple results for bulk actions." |
| 18 | User action | Prompt user: "Click this checkbox to select a result." |
| 19 | Highlight | Explain: "Hold Shift and click another checkbox to select all results between the two clicks." |
| 20 | User action | Prompt user: "Try Shift+clicking another checkbox now." |
| 21 | Highlight | Highlight the selection button (invert/select all/deselect all). Explain the bulk selection options. |
| 22 | Highlight | Highlight the "Download NZBs" button at the top. Explain: "Send all selected results to your downloader at once." |
| 23 | Auto | Automatically click the download button. The demo mode returns a success response for all selected results. |
| Step | Type | Description |
|---|---|---|
| 24 | Info | "Now let's try a movie search with autocomplete." |
| 25 | Auto | Automatically select the "Movies" category from the dropdown. |
| 26 | Highlight | Highlight the search input. Explain: "When a movie or TV category is selected, typing a title shows autocomplete suggestions with posters." |
| 27 | Auto | Automatically type a movie name (e.g., "Interstellar") into the search field. The demo mode returns mock autocomplete results (a list of movies with posters). |
| 28 | Highlight | Highlight the autocomplete dropdown. Explain: "Select a movie to search by its database IDs for more accurate results." |
| 29 | Auto | Automatically select the first autocomplete result. |
| 30 | Auto | Automatically click "Go!" and wait for mock results. |
| Step | Type | Description |
|---|---|---|
| 31 | Highlight | Highlight the "Display options" button. Explain: "Customize how results are displayed." |
| 32 | Auto | Automatically open the display options dropdown. |
| 33 | Highlight | Highlight key options (group duplicates, show covers, expand groups). Explain each briefly. |
| 34 | User action | Prompt user: "Try toggling an option to see the effect." |
| Step | Type | Description |
|---|---|---|
| 35 | Info | Summary popover: "That's the tour! You now know how to search, filter, sort, download, and customize your results. You can restart this tour anytime using the button on the search page." |
- The user can close the tour at any time by clicking an "X" button on any popover or pressing Escape.
- When the tour ends (completed or closed early), demo mode is deactivated.
- The page returns to its normal state (search results are cleared, category is reset to default).
Library: Adapted local copy of angular-ui-tour (based on v0.9.4)
Why this library:
- Built on
angular-bootstrap(specificallyuib-popover) which NZBHydra2 already uses – guarantees visual consistency. - Promise-based lifecycle hooks (
onNext,onShow, etc.) – essential for waiting on async operations like search results loading. tour.waitFor(stepId)– essential for cross-view tours (search page → results view).createStep()API – steps can be defined programmatically from controllers.- Explicit
ui-routerintegration viauseUiRouterconfig. - MIT license (compatible with the project's Apache-2.0 license).
Local adaptation: The original bower package uses ES2017 async/await internally but also uses AngularJS $q promises. Native await on $q.resolve() stalls because no digest cycle fires from native async context, causing tour
transitions to freeze (see §10.8 for full details). To fix this, the library source was copied and adapted into a single local file.
Location: core/ui-src/js/angular-ui-tour.js (~1126 lines)
Key changes from upstream:
- All
async/awaitremoved — every async method rewritten to use$q.then()chains digest()uses$timeout(angular.noop, 0)instead of$q.resolve()to guarantee a digest cycle fireshandleEvent()wraps results with$q.resolve(result)for consistent promise handlingEventEmitterreplaced with inlineSimpleEventEmitter(removes external dependency)- Templates registered via
$templateCachein a.run()block - Tether and Hone accessed as globals (
window.Tether,window.Hone)
Transitive dependencies (installed via bower as direct deps): cfp-angular-hotkeys, hone, tether, angular-scroll, angular-bind-html-compile.
Integration:
- Add
'bm.uiTour'to the AngularJS module dependencies innzbhydra.js. - The local
angular-ui-tour.jsis included in the Gulpscriptstask (bundled intonzbhydra.js). - Tour CSS rules are in
core/ui-src/less/partials/miscellaneous.less.
Create a new AngularJS service GuidedTourService that:
- Manages tour state – tracks whether the tour is active, current step index.
- Activates/deactivates demo mode – calls the backend to enable/disable demo mode before/after the tour.
- Defines all tour steps – step definitions with element selectors, content, placement, and lifecycle hooks.
- Performs automated actions – types text into fields, clicks buttons, selects dropdown items via Angular's
$scopemanipulation and$timeoutfor animations. - Waits for async results – uses promise-based hooks to wait for search results to load before proceeding to results-related steps.
Key file locations:
core/ui-src/js/guided-tour-service.js– the tour servicecore/ui-src/html/guided-tour-popover.html– custom popover template (optional, for styling)- Tour-specific CSS in existing stylesheets
A new REST endpoint pair:
PUT /internalapi/demomode → activates demo mode for the current user
DELETE /internalapi/demomode → deactivates demo mode for the current user
Demo mode is per-user so that one user taking the tour does not affect other concurrent users. The backend maintains a Set<String> of usernames currently in demo mode.
How the current user is identified:
-
Auth configured (
FORMorBASIC): The current user is identified via Spring Security'sPrincipal(injected into controller methods). In service-layer code wherePrincipalis not available,SessionStorage.usernameis used — this is aThreadLocal<String>that the existingInterceptorpopulates fromrequest.getRemoteUser()at the start of every request. -
Auth not configured (
NONE): All requests arrive as the anonymous user"AnonymousUser"(set byHydraAnonymousAuthenticationFilter). In this case the set contains"AnonymousUser"and demo mode is effectively global — which is correct because without auth there is only one logical user session.
Checking demo mode from anywhere in the request pipeline: The static method DemoModeWeb.isDemoModeActive() reads SessionStorage.username.get() and checks membership in the set. Any controller or service in the request thread can
call it without needing a Principal parameter.
Thread safety: The set uses ConcurrentHashMap.newKeySet(). In practice the set will contain 0 or 1 entries.
When demo mode is active, the following endpoints return mock data instead of performing real operations:
| Endpoint | Mock Behavior |
|---|---|
POST /internalapi/search |
Returns realistic mock search results (see §3.3.3). No indexer calls. |
PUT /internalapi/downloader/addNzbs |
Returns a mock success response. No downloader communication. |
GET /internalapi/downloader/{name}/categories |
Returns mock downloader categories. |
The mock search endpoint should return realistic results. The existing NewznabMockBuilder in shared/mapping already generates rich mock Newznab XML items. However, the internal search flow uses SearchResultWebTO (the web transfer
object), not Newznab XML.
Approach: Create a new class DemoDataProvider (or similar) in the core module that:
- Generates a
SearchResponsewith 20-50SearchResultWebTOitems. - Items have realistic data using Usenet naming conventions (dot-separated words, quality tags, group suffix):
- Varied titles based on the search query (e.g.,
Linux.Mint.21.3.Cinnamon.x64-LiNUX,Ubuntu.24.04.1.LTS.Desktop.amd64-LiNUX,Arch.Linux.2024.12.01.x86_64-LiNUXfor query "linux"; orInterstellar.2014.1080p.BluRay.x264-SPARKS,Interstellar.2014.2160p.UHD.BluRay.x265-TERMINALfor movie searches). - Varied sizes (700MB to 15GB).
- Varied ages (1 hour to 30 days).
- Multiple fake indexers (e.g., "DemoIndexer1", "DemoIndexer2", "DemoIndexer3").
- Categories matching the search category.
- Some results with duplicate hashes (to demonstrate grouping).
- For movie searches: quality ratings and cover image URLs.
- Mix of NZB and optionally torrent results.
- Varied titles based on the search query (e.g.,
- Includes realistic
IndexerSearchMetaDatafor 3 fake indexers. - Includes some sample rejection reasons.
Integration point: In SearchWeb.search(), check the demo mode flag before calling searcher.search(). If demo mode is active, call demoDataProvider.generateSearchResponse(parameters) instead.
During demo mode, autocomplete uses the real TMDB/TVMaze APIs — no mocking is needed. This is the correct approach because:
- Stability: Autocomplete errors are handled gracefully — the existing code returns an empty list on any failure, so the tour never breaks.
- Speed: Results are triple-cached (7-day Guava in-memory cache, Caffeine
@Cacheable, and database). After the first call, subsequent requests are instant. - Real poster images: Using real autocomplete means real poster URLs are returned, making the tour visually polished without bundling placeholder images.
- Zero additional code: No mock data, no demo mode check in
MediaInfoWeb, no maintenance burden.
The tour step (§2.2, Phase 5, step 27) types "Interstellar" into the search field and the real TMDB API returns actual movie results with posters.
When demo mode is active:
- The "send to downloader" endpoint (
PUT /internalapi/downloader/addNzbs) returns a successfulAddNzbsResponsewithout contacting any real downloader. - The frontend should show a mock downloader called "Demo Downloader" in the download buttons, even if no real downloader is configured.
GET /internalapi/downloader/{name}/categoriesreturns a few mock categories (e.g., "Movies", "TV", "Default").
Integration point:
- In
DownloaderWeb.addNzb(), check the demo mode flag. If active, return a mock success response. - The frontend
ConfigServiceorNzbDownloadServiceshould inject a fake downloader entry when demo mode is active.
The search progress modal uses WebSocket (SockJS/STOMP) to show real-time indexer progress. During demo mode:
- The backend should send mock WebSocket messages simulating 3 fake indexers completing their searches over ~2 seconds.
- This makes the progress modal feel realistic.
Integration point: In SearchWeb.search() (or the demo search handler), send mock SearchState updates via SimpMessagingTemplate before returning the mock results.
| File | Purpose |
|---|---|
core/ui-src/js/angular-ui-tour.js |
Adapted local copy of angular-ui-tour (~1126 lines), replaces bower dist |
core/ui-src/js/guided-tour-service.js |
AngularJS service managing tour lifecycle, step definitions, and automated actions |
core/src/main/java/org/nzbhydra/searching/DemoDataProvider.java |
Generates mock search results and download responses |
core/src/main/java/org/nzbhydra/searching/DemoModeWeb.java |
REST controller for activating/deactivating demo mode |
| File | Change |
|---|---|
core/bower.json |
Removed angular-ui-tour, added its transitive deps as direct deps |
core/ui-src/js/nzbhydra.js |
Add 'bm.uiTour' module dependency |
core/gulpfile.js |
Include transitive deps (tether, hone, etc.) in vendor-scripts build |
core/ui-src/html/states/search.html |
Add the "Take a Tour" button and tour-step attributes on key elements |
core/ui-src/html/states/search-results.html |
Add tour-step attributes on results table, column headers, filter inputs, download buttons |
core/ui-src/html/directives/search-result.html |
Add tour-step attributes on checkboxes, download icons |
core/ui-src/js/search-controller.js |
Minor additions to support tour's automated actions (expose methods for the tour service) |
core/ui-src/js/search-results-controller.js |
Minor additions for tour step registration on dynamic elements |
core/src/main/java/org/nzbhydra/searching/SearchWeb.java |
Check demo mode flag, route to DemoDataProvider if active |
core/src/main/java/org/nzbhydra/downloading/downloaders/DownloaderWeb.java |
Check demo mode flag, return mock download success if active |
// DemoModeWeb.java
@RestController
public class DemoModeWeb {
private static final Set<String> usersInDemoMode = ConcurrentHashMap.newKeySet();
@PutMapping("/internalapi/demomode")
@Secured("ROLE_USER")
public void activateDemoMode(Principal principal) {
String username = resolveUsername(principal);
usersInDemoMode.add(username);
}
@DeleteMapping("/internalapi/demomode")
@Secured("ROLE_USER")
public void deactivateDemoMode(Principal principal) {
String username = resolveUsername(principal);
usersInDemoMode.remove(username);
}
/**
* Check if the current request's user is in demo mode.
* Can be called from any controller/service in the request thread
* because SessionStorage.username is a ThreadLocal populated by the Interceptor.
*/
public static boolean isDemoModeActive() {
String username = SessionStorage.username.get();
return username != null && usersInDemoMode.contains(username);
}
private String resolveUsername(Principal principal) {
if (principal != null) {
return principal.getName();
}
// Fallback: when auth is NONE, SessionStorage has "AnonymousUser"
return SessionStorage.username.get();
}
}@Component
public class DemoDataProvider {
public SearchResponse generateSearchResponse(SearchRequestParameters params) {
// Generate 30 realistic SearchResultWebTO items
// Titles based on params.getQuery() or params.getTitle()
// Varied sizes, ages, indexers, categories
// Some duplicates (same hash) for grouping demo
// Realistic IndexerSearchMetaData for 3 fake indexers
}
public AddNzbsResponse generateDownloadResponse(AddFilesRequest request) {
// Return successful response with all requested IDs as "added"
}
}// In SearchWeb.search():
@PostMapping("/internalapi/search")
public SearchResponse search(@RequestBody SearchRequestParameters parameters) {
if (DemoModeWeb.isDemoModeActive()) {
// Send mock WebSocket progress updates
sendMockSearchProgress(parameters.getSearchRequestId());
return demoDataProvider.generateSearchResponse(parameters);
}
// ... existing search logic ...
}// guided-tour-service.js
angular.module('nzbhydraApp').factory('GuidedTourService',
function ($http, $timeout, $rootScope, uiTourService) {
var service = {};
var demoModeActive = false;
service.startTour = function () {
// 1. Activate demo mode on backend
$http.put('internalapi/demomode').then(function () {
demoModeActive = true;
// 2. Inject fake downloader into config
injectFakeDownloader();
// 3. Start the tour
var tour = uiTourService.getTour();
tour.start();
});
};
service.endTour = function () {
// 1. Deactivate demo mode
$http.delete('internalapi/demomode').then(function () {
demoModeActive = false;
// 2. Remove fake downloader
removeFakeDownloader();
// 3. Clear search results, reset category
$rootScope.$broadcast('tourEnded');
});
};
// Automated actions
service.typeIntoField = function (elementId, text, delayPerChar) {
// Animate typing character by character using $timeout
};
service.clickElement = function (selector) {
// Programmatically trigger a click via angular.element
};
return service;
});Steps are defined either declaratively in HTML:
<input id="searchfield"
tour-step="searchField"
tour-step-title="Search Query"
tour-step-content="Type your search terms here."
tour-step-order="4"
tour-step-placement="bottom">Or programmatically in the controller:
var tour = uiTourService.createTour({
name: 'guidedTour',
backdrop: true,
backdropPadding: 5,
useUiRouter: true,
onEnd: function () {
GuidedTourService.endTour();
}
});
tour.createStep({
selector: '#searchfield',
title: 'Search Query',
content: 'Type your search terms here. We\'ll search for "linux" as an example.',
order: 4,
placement: 'bottom',
onNext: function () {
return GuidedTourService.typeIntoField('#searchfield', 'linux', 50);
}
});The programmatic approach is recommended since many steps need custom lifecycle hooks.
The tour starts on the search page (root.search state) and must continue after navigating to the results view (root.search.results state). angular-ui-tour supports this via tour.waitFor(stepId):
- Steps 1-7 are on the search page.
- Step 7's
onNexttriggers the search and navigates to results. - Step 8 is defined in the
SearchResultsControllerand registered withtour.waitFor('resultsTable'). - When the results view loads and the
resultsTablestep is registered, the tour automatically continues.
When demo mode is active, the frontend needs to show download buttons even if no real downloader is configured. The GuidedTourService temporarily injects a fake downloader into ConfigService:
function injectFakeDownloader() {
var config = ConfigService.getSafe();
if (!config.downloading || config.downloading.downloaders.length === 0) {
config.downloading = config.downloading || {};
config.downloading.downloaders = config.downloading.downloaders || [];
config.downloading.downloaders.push({
name: 'Demo Downloader',
enabled: true,
downloaderType: 'SABNZBD',
// other required fields with dummy values
});
fakeDownloaderInjected = true;
}
}Demo mode is per-user via a Set<String> of usernames (see §4.1). Each user's demo mode is independent — one user taking the tour does not affect other users' search/download operations. When auth is not configured (NONE), all requests
use "AnonymousUser", making demo mode effectively global — which is correct since there is only one logical user session in that configuration.
If the user closes the browser tab during the tour, demo mode stays active. Mitigations:
- Add a timeout: demo mode auto-deactivates after 10 minutes.
- Add a check on page load: if demo mode is active when the search page loads (and no tour is running), deactivate it.
- The backend could check
demoModeActiveon each normal search and log a warning.
The tour should work even when no indexers are configured (common for new installations). The demo mode bypasses all indexer logic, so this is handled naturally.
The tour button and demo mode endpoints require ROLE_USER (same as search). No additional permissions needed.
angular-ui-tour repositions popovers based on viewport. Bootstrap 3 popovers handle this reasonably well. However, the tour is primarily designed for desktop use. On very small screens, some steps may need adjusted placement or content.
Generate ~30 results across 3 fake indexers with these characteristics:
| # | Title | Indexer | Size | Age | Category |
|---|---|---|---|---|---|
| 1 | Linux.Mint.21.3.Cinnamon.x64-LiNUX | DemoIndexer1 | 2.8 GB | 2d | PC |
| 2 | Ubuntu.24.04.1.LTS.Desktop.amd64-LiNUX | DemoIndexer2 | 4.1 GB | 5d | PC |
| 3 | Arch.Linux.2024.12.01.x86_64-LiNUX | DemoIndexer1 | 850 MB | 1d | PC |
| 4 | Linux.Mint.21.3.Cinnamon.x64-LiNUX | DemoIndexer3 | 2.8 GB | 2d | PC |
| ... | (more varied results) | ... | ... | ... | ... |
Items 1 and 4 should share the same duplicate hash to demonstrate grouping.
No mock data needed. During demo mode, autocomplete hits the real TMDB API via MediaInfoWeb.autocomplete(). The existing triple-cached pipeline (Guava 7-day TTL, Caffeine @Cacheable, database) ensures fast responses. Errors return
an empty list gracefully, so the tour never breaks. Real poster URLs are returned, providing a polished visual experience without bundled placeholder images.
Generate ~25 results with movie-specific data using Usenet naming conventions:
- Titles like
Interstellar.2014.1080p.BluRay.x264-SPARKS,Interstellar.2014.2160p.UHD.BluRay.x265-TERMINAL,Interstellar.2014.720p.WEB-DL.DD5.1.H.264-FGT,Interstellar.2014.HDTV.x264-LOL - Quality ratings (e.g., 720, 1080, 2160)
- Quality source labels (BluRay, WEB-DL, HDTV)
- Cover image URL (can be null or a bundled placeholder)
- Movie-specific categories
{
"successful": true,
"message": "Successfully added 3 NZBs to Demo Downloader",
"addedIds": [
1,
2,
3
],
"missedIds": []
}Key files and their roles in the search flow (for implementation reference):
| File | Role |
|---|---|
core/src/main/java/org/nzbhydra/searching/SearchWeb.java |
Internal search REST controller (POST /internalapi/search) |
core/src/main/java/org/nzbhydra/searching/Searcher.java |
Core search engine (parallel indexer calls, dedup, pagination) |
core/src/main/java/org/nzbhydra/searching/InternalSearchResultProcessor.java |
Transforms internal results → SearchResultWebTO |
core/src/main/java/org/nzbhydra/mediainfo/MediaInfoWeb.java |
Autocomplete endpoint (GET /internalapi/autocomplete/{type}) |
core/src/main/java/org/nzbhydra/downloading/downloaders/DownloaderWeb.java |
Send-to-downloader endpoint (PUT /internalapi/downloader/addNzbs) |
core/src/main/java/org/nzbhydra/api/MockSearch.java |
Existing mock pattern (for external API mocking during Sonarr/Radarr config) |
core/src/main/java/org/nzbhydra/api/ExternalApi.java |
Shows how inMockingMode static flag intercepts search calls (lines 171-175) |
shared/mapping/src/main/java/org/nzbhydra/searching/dtoseventsenums/SearchResultWebTO.java |
Result transfer object (all fields) |
shared/mapping/src/main/java/org/nzbhydra/searching/SearchResponse.java |
Search response wrapper |
shared/mapping/src/main/java/org/nzbhydra/searching/dtoseventsenums/SearchRequestParameters.java |
Search request from frontend |
shared/mapping/src/main/java/org/nzbhydra/mapping/newznab/mock/NewznabMockBuilder.java |
Reference for generating mock data (not directly used but informative) |
| File | Role |
|---|---|
core/ui-src/js/angular-ui-tour.js |
Adapted local copy of angular-ui-tour (tour engine, directives, services) |
core/ui-src/js/guided-tour-service.js |
Tour service (step definitions, lifecycle, automated actions) |
core/ui-src/js/search-controller.js |
Search form controller (category selection, autocomplete, search initiation) |
core/ui-src/js/search-results-controller.js |
Results display controller (sorting, filtering, grouping, selection) |
core/ui-src/js/search-service.js |
HTTP service for search calls |
core/ui-src/js/nzb-download-service.js |
Download service (send NZBs to downloader) |
core/ui-src/js/directives/search-result.js |
Individual result directive (checkbox, shift-click, expand/collapse) |
core/ui-src/js/directives/download-nzbs-button.js |
Bulk download button directive |
core/ui-src/js/directives/addable-nzb.js |
Per-result download icon directive |
core/ui-src/js/directives/selection-button.js |
Select all / invert / deselect all |
core/ui-src/js/categories-service.js |
Category management service |
core/ui-src/js/directives/multiselect-dropdown.js |
Display options dropdown |
core/ui-src/html/states/search.html |
Search page template (199 lines) |
core/ui-src/html/states/search-results.html |
Results template (359 lines) |
core/ui-src/html/directives/search-result.html |
Result row template (136 lines) |
core/ui-src/html/search-state.html |
Search progress modal (42 lines) |
core/ui-src/js/nzbhydra.js |
App module definition and routing (states: root.search, root.search.results) |
core/bower.json |
Frontend dependencies |
core/gulpfile.js |
Build pipeline |
| Step | Task | Status | Notes |
|---|---|---|---|
| 1 | Install angular-ui-tour |
Done | Adapted angular-ui-tour source into local file core/ui-src/js/angular-ui-tour.js (~1126 lines) with all async/await replaced by $q.then() chains (see §10.8). Added transitive deps (cfp-angular-hotkeys, hone, tether, angular-scroll, angular-bind-html-compile) as direct bower deps. Added 'bm.uiTour' to module deps in nzbhydra.js. Rebuilt alllibs.js via gulp vendor-scripts. |
| 2 | Backend demo mode flag | Done | DemoModeWeb.java created — PUT/DELETE /internalapi/demomode, per-user Set<String> via ConcurrentHashMap.newKeySet(), static isDemoModeActive() reads SessionStorage.username. |
| 3 | DemoDataProvider |
Done | DemoDataProvider.java created — generates 30 SearchResultWebTO items with Usenet naming, 3 fake indexers, duplicate hash groups for grouping demo, quality ratings for movies, rejection reasons map, mock download response, mock downloader categories. Fixed Random(42) seed. |
| 4 | Backend integration | Done | SearchWeb.search() checks DemoModeWeb.isDemoModeActive() before real search; returns demoDataProvider.generateSearchResponse(). DownloaderWeb.addNzb() and getCategories() check demo mode and return mock responses. |
| 5 | Mock WebSocket progress | Done | SearchWeb.sendMockSearchProgress() sends mock SearchState updates via SimpMessageSendingOperations: initial state → indexer selection (3 indexers) → staggered indexer completion (500/700/900ms delays) → search finished. |
| 6 | Frontend GuidedTourService |
Done | Tour lifecycle (start/end/isTourActive), automated action helpers (typeIntoField, clickElement, waitForElement, selectCategory, waitForResults, selectFirstCheckbox), fake downloader injection/removal. Includes duplicate step registration guard (createStepIfNew). |
| 7 | Tour step definitions | Done | All 35 steps across 7 phases defined programmatically via createStepIfNew() in registerSearchSteps() (Phase 1 + 5) and registerResultsSteps() (Phase 2-4 + 6-7). Cross-view transitions use tour.waitFor(). |
| 8 | HTML + Controller modifications | Done | search.html: added ui-tour directive wrapper, "Take a Tour" button, reactive tour-aware ng-if/ng-show. search-controller.js: injected GuidedTourService/uiTourService, added tour scope functions, bypassed indexer check in demo mode, added page-load cleanup. search-results-controller.js: injected GuidedTourService, added registerResultsSteps() call in onFinishRender handler. |
| 9 | Fake downloader injection | Done | Integrated into GuidedTourService — injectFakeDownloader() on tour start, removeFakeDownloader() on tour end. |
| 10 | Cleanup & polish | Done | Demo mode auto-deactivation timeout (10min) via ScheduledExecutorService in DemoModeWeb. Page-load cleanup in SearchController (fire-and-forget DELETE /internalapi/demomode). Tour backdrop + popover CSS in miscellaneous.less. Duplicate step guard in GuidedTourService. All builds pass (IntelliJ + Gulp scripts + Gulp LESS). |
| 11 | Testing | Done | Automated Playwright test (test_tour.py) passes all 7 phases end-to-end. Phase 4→5 $digest bug fixed (§10.4). Phase 5→6 transition validated (§10.7). All phase transitions working correctly. |
- Tour content tone: Friendly and casual. Approachable language, not formal/technical.
- Localization: English only. No translation infrastructure needed.
- Extensibility: The tour system must be designed so that new steps and phases can be easily added later (e.g., for stats page, history page, config page). Step definitions should be modular and self-contained.
- Torrent results: NZB-only. Demo data does not include torrent results.
Status: Fixed.
Symptom: When Phase 1 (Basic Search) completes and the tour triggers a search + navigates to root.search.results, the search results load correctly and the "Searching... please wait" modal closes, but the tour popover does NOT
reappear on the results page.
Fix 1: $stateParams.category null crash (APPLIED, WORKING)
In search-results-controller.js, two lines accessed $stateParams.category.toLowerCase() without null-checking. During demo mode, category can be undefined in the URL params.
- Line 50 (was):
$stateParams.category.toLowerCase().indexOf("tv")Now:var categoryLower = ($stateParams.category || "").toLowerCase();then usescategoryLower - Line 165 (was):
$stateParams.category.toLowerCase().indexOf("tv")Now: usescategoryLower
This fix resolved the crash that prevented search results from rendering at all.
The waitFor/resumeWhenFound mechanism in angular-ui-tour should theoretically work but fails due to a subtle async/digest-cycle mismatch between native JavaScript async/await (used internally by angular-ui-tour) and AngularJS's
$q promise system.
Detailed execution flow:
- User clicks "Next" on
goButtonstep →tour.next()→tour.goTo('$next') goTocallsawait handleEvent(currentStep.config('onNext'))— invokes ourgoButton.onNextgoButton.onNextcallsscope.initiateSearch()(triggers search HTTP +$state.go) and returnstour.waitFor('resultsTable')- Inside
waitFor:- Sets
resumeWhenFoundcallback (synchronously, before any await) - Calls
await self.pause()— hides goButton step, sets status PAUSED - Sets
tourStatus = TourStatus.WAITING - Returns
$q.reject()— halts thegoTochain
- Sets
- Search response arrives →
$state.go("root.search.results")→SearchResultsControllerinitializes onFinishRenderfires →$timeout(..., 1)→registerResultsSteps()→clearResultsSteps()+createStepIfNew({stepId: 'resultsTable', ...})tour.createStep()→tour.addStep(step)— adds step, emits'stepAdded', then callsresumeWhenFound(step)resumeWhenFoundchecksstep.stepId === 'resultsTable'→ MATCH → callssetCurrentStep(step)thenself.resume()self.resume()is anasyncfunction → callsself.showStep(getCurrentStep())showStepcallsawait digest()which returns$q.resolve()— but this$qpromise may never resolve within the native async context because no AngularJS digest cycle is triggered
The core problem: angular-ui-tour's internal functions (resume, showStep, hideStep, pause) are all ES2017 async functions that use native await. However, they also use $q.resolve() / $q.reject() (AngularJS promises).
Native await on a $q promise does NOT trigger an AngularJS digest cycle. The promise resolves eventually only when something else triggers a digest. When resumeWhenFound is called synchronously from within addStep, the subsequent
resume() → showStep() chain runs in native async context, and the await digest() call inside showStep (which returns $q.resolve()) may stall because no $scope.$apply() or $timeout triggers a digest.
This is a known class of bug when mixing native Promises/async-await with AngularJS $q. See: https://www.bennadel.com/blog/3272-mixing-native-promises-and-q-promises-in-angularjs.htm
This is a separate, backup mechanism designed for the case where the ui-tour directive IS destroyed and recreated (new tour instance) during a state transition. In that scenario:
pendingWaitForStepIdis set before the transition- After recreation,
onTourReadyfires →registerSearchSteps(newTour)is called - At the bottom of
registerSearchSteps(), ifpendingWaitForStepIdis set, it sets up atour.on('stepAdded', ...)listener and clearspendingWaitForStepId - When
registerResultsSteps()later adds the step, the listener catches it and callstour.startAt(step)
In the current scenario (parent view root.search stays alive, so the ui-tour directive is NOT destroyed), pendingWaitForStepId is set but registerSearchSteps() is NOT called again, so it sits unused. The waitFor/resumeWhenFound
mechanism on the existing tour instance is what should handle this case — but it fails due to the async/digest issue above.
Instead of trying to fix angular-ui-tour's async/$q internals, a manual fallback was added at the end of registerResultsSteps(). After creating all steps, a $timeout (which triggers a digest cycle) checks if the tour is stuck in
WAITING/PAUSED state and manually starts it at the target step:
// At the end of registerResultsSteps(), after all createStepIfNew calls:
$timeout(function () {
var status = tour.getStatus();
if (status === tour.Status.WAITING || status === tour.Status.PAUSED) {
console.log('[TOUR] Tour stuck in waiting/paused, manually starting at resultsTable');
tour.startAt('resultsTable');
}
}, 200);Why startAt instead of resume: startAt does its own setCurrentStep(step) + showStep(step) and runs inside a $timeout callback (which is inside a digest cycle), avoiding the native-async/$q mismatch. resume() would
require getCurrentStep() to already be correctly set and still suffers from the same async issue.
Why $timeout with 200ms: Gives clearResultsSteps() + createStepIfNew() time to complete all step registrations and ensures we're in a clean digest cycle.
This same pattern applies to the Phase 4→5 (bulkDownloadDone → movieSearchIntro) and Phase 5→6 (movieGoButton → displayOptions) transitions.
The adapted local copy lives at core/ui-src/js/angular-ui-tour.js. Key internal structures (all in a single file):
| Section / Function | Key Functions |
|---|---|
| Tour controller | waitFor(stepId), addStep(step), createStep(options), goTo('$next'), pause(), resume(), showStep(step), hideStep(step), startAt(stepOrStepIdOrIndex) |
| Step service | createStep(options, tour) — builds step object; showStep(step, tour) — resolves selector to DOM element, creates popup + Tether positioning |
ui-tour directive |
Link function — calls ctrl.init(tour) which registers tour with uiTourService |
uiTourService |
getTour() returns tours[0], _registerTour/_unregisterTour manage the array |
TourStatus enum: OFF=0, ON=1, PAUSED=2, WAITING=3 (defined in angular-ui-tour.js)
Navigation interceptors are DISABLED by default. enableNavigationInterceptors() must be explicitly called to activate the $stateChangeStart listener that would call endAllTours(). We don't call it, so state transitions do NOT kill
the tour.
A comprehensive Playwright test exists at the project root for automated tour testing:
- Diagnostics: Tour identity tagging (
__tourId), 50ms status polling,_registerTour/_unregisterTourmonkey-patches, event listeners - Test functions:
test_phase1throughtest_phase7 - Run command:
python test_tour.py --headed(from project root) - Cache busting: URL rewriting for
.jsfiles to avoid stale cached scripts
Maven cannot compile core module from command line due to a pre-existing Lombok annotation processing issue (SearchResultWebTOBuilder not found in InternalSearchResultProcessor.java). The mapping module uses
@Builder @Jacksonized on SearchResultWebTO and the generated builder class isn't visible to Maven's cross-module compilation. IntelliJ handles this correctly via its Lombok plugin. All LSP errors about "cannot resolve" types from
the mapping module are this same pre-existing issue — they are NOT errors in our code.
Symptom: When "Next" is clicked on the bulkDownloadDone step, the onNext handler navigates to root.search and schedules tour.startAt('movieSearchIntro') via $timeout(fn, 300). The startAt call triggers
movieSearchIntro.onShow, which calls scope.$apply() — but since startAt runs inside a $timeout callback (already inside a digest cycle), this throws Error: [$rootScope:inprog] $digest already in progress.
The $digest error caused onShow to fail, which prevented the movieSearchIntro popover from rendering. The popover remained stuck on "Bulk Download Done!".
Fix applied: In movieSearchIntro.onShow, replaced the direct scope.$apply() with a $timeout wrapper:
// Before (broken):
scope.query = '';
scope.selectedItem = null;
scope.$apply();
// After (fixed):
$timeout(function () {
scope.query = '';
scope.selectedItem = null;
});$timeout automatically triggers a digest cycle after its callback runs, so the scope changes are properly propagated without risking a nested digest.
Symptom: clearResultsSteps() crashed with TypeError: Cannot read properties of undefined (reading 'length') when calling tour._getSteps().
Fix applied: Inlined the step ID array directly in clearResultsSteps() (instead of referencing a separate variable), added typeof tour._getSteps === 'function' guards, and wrapped step removal in try/catch. The same defensive
pattern was applied to createStepIfNew().
| Transition | Mechanism | Status |
|---|---|---|
| Phase 1→2 (search → results) | tour.waitFor('resultsTable') + $timeout fallback in registerResultsSteps() |
Fixed |
| Phase 4→5 (results → search) | $state.go('root.search') + $timeout(tour.startAt('movieSearchIntro'), 300) |
Fixed |
| Phase 5→6 (search → results) | tour.waitFor('displayOptions') + clearResultsSteps() + $timeout fallback in registerResultsSteps() |
Fixed |
Symptom: When "Next" is clicked on bulkDownloadDone, the old popover remained visible while the tour navigated back to the search form. The goTo('$next') chain calls handleEvent(onNext) then .then(hideStep) — if onNext returns
$q.reject(), hideStep is skipped, leaving the old popover stuck on screen.
Root cause: The original bulkDownloadDone.onNext returned $q.reject() immediately (to prevent goTo from advancing to the next step). But because the rejection skips the .then(hideStep) in the chain, the bulkDownloadDone
popover was never hidden.
Fix applied: Call tour.pause() first (which internally hides the current step and backdrop), then schedule the startAt('movieSearchIntro') call, then return $q.reject():
onNext: function () {
$state.go('root.search', {}, {inherit: true, notify: true, reload: false});
pendingWaitForStepId = 'movieSearchIntro';
return tour.pause().then(function () {
$timeout(function () {
if (tour && tour.hasStep('movieSearchIntro')) {
pendingWaitForStepId = null;
tour.startAt('movieSearchIntro');
}
}, 300);
return $q.reject();
});
}Symptom: Multiple phase transitions failed because angular-ui-tour's internal async/await functions stall when awaiting $q promises. Native await on $q.resolve() does not trigger an AngularJS digest cycle, causing the tour to
freeze.
Root cause: angular-ui-tour v0.9.4 uses ES2017 async/await internally but returns $q promises. When resumeWhenFound calls resume() (an async function) which calls showStep() which does await digest() (returning
$q.resolve()), the native await never resolves because no digest cycle fires from native async context.
Fix applied: Removed angular-ui-tour from bower.json and created a local adapted copy at core/ui-src/js/angular-ui-tour.js (~1126 lines) with these changes:
- All
async/awaitremoved — every async method rewritten to use$q.then()chains digest()uses$timeout(angular.noop, 0)instead of$q.resolve()to guarantee a digest cycle fireshandleEvent()wraps results with$q.resolve(result)for consistent promise handlingEventEmitterreplaced with inlineSimpleEventEmitter(removes external dependency)- Templates registered via
$templateCachein a.run()block - Tether and Hone accessed as globals (
window.Tether,window.Hone) - Added transitive dependencies (
cfp-angular-hotkeys,hone,tether,angular-scroll,angular-bind-html-compile) as direct bower deps
Added .no-scrolling, .ui-tour-popup:focus, .ui-tour-popup-orphan, and arrow positioning rules to core/ui-src/less/partials/miscellaneous.less to ensure proper tour popover styling and positioning.
Symptom: The tour intermittently reset to "Welcome to the Tour!" while the user was on the results page — not during state transitions, but during normal interaction (sorting, filtering, or any action that caused the results table to re-render).
Root cause: The onFinishRender directive (core/ui-src/js/directives/on-finish-render.js) fires scope.$emit("onFinishRender") whenever scope.$last === true in an ng-repeat. This fires on every re-render of the results
table (sorting by column, typing in the title filter, etc.), not just the initial render.
Each time onFinishRender fired, SearchResultsController called registerResultsSteps() in the tour service. This function called clearResultsSteps() first, which removed ALL result step objects from the tour's internal stepList
array via tour.removeStep() — including the currently displayed step.
When the user then clicked "Next", the getStepByOffset(+1) function in angular-ui-tour.js (line ~622-627) executed:
stepList[stepList.indexOf(getCurrentStep()) + offset]Since the current step object had been removed from the array, indexOf(currentStep) returned -1. Then -1 + 1 = 0, so stepList[0] was returned — which was the welcome step (order 10, the lowest-order step). The tour jumped back
to "Welcome to the Tour!".
Fix applied (in guided-tour-service.js, at the top of registerResultsSteps()):
if (registeredStepIds['resultsTable'] && tourStatus === tour.Status.ON) {
console.log('[TOUR] registerResultsSteps() - results steps already registered and tour is ON, skipping re-registration');
return;
}This guard checks two conditions:
- Results steps are already registered (
registeredStepIds['resultsTable']is truthy) - The tour status is ON (status 1, meaning actively showing steps)
If both are true, re-registration is skipped entirely. This allows re-registration only when:
- The tour is WAITING or PAUSED (status 2 or 3) — the Phase 5→6 transition where we legitimately need to clear and recreate steps after a new search
- Steps haven't been created yet (first visit to results)
Verification: The fix was verified with a --debug-reset stress test mode added to the Playwright test. This mode deliberately triggers re-renders (clicking sort headers, typing in the title filter) between every tour step on the
results page. Health checks confirm currentStepInList is always a valid index, never -1.
Symptom: Users are confused about when they need to interact with the UI vs when the tour will do things automatically. For example, on the "Let's hit Go!" step, users think they should click the Go button themselves, but the tour clicks it automatically.
Root cause: The welcome step does not explain the two modes of interaction (automated actions vs user actions), and there is no visual distinction in the step content between instructions that mean "watch the tour do this" vs "you need to do this yourself".
Fix plan:
- Update the welcome step content to explain the interaction model: some steps are automated (the tour does it for you) and some require user action.
- Use a distinct visual format for user-action instructions vs informational/automated steps — e.g. a styled callout or icon for "Your turn!" prompts.
Symptom: After the tour auto-clicks the download icon and the toast message says "Successfully added 1 NZB to Demo Downloader", the download icon shows a red error cross instead of a green checkmark.
Root cause: Type mismatch between searchResultId and addedIds in demo mode. The success check in addable-nzb.js (line 42) does:
response.data.addedIds.indexOf(Number($scope.searchresult.searchResultId)) > -1Demo results have searchResultId = "DEMO-RESULT-0", "DEMO-RESULT-1", etc. (strings). Number("DEMO-RESULT-0") evaluates to NaN. The mock DemoDataProvider.generateDownloadResponse() returns addedIds = [1, 2, 3, ...] (sequential
longs). indexOf(NaN) always returns -1, so the check fails and the icon shows the error state.
Fix plan: In DemoDataProvider.generateDownloadResponse(), parse the actual searchResultId values from the request and include them in addedIds. Since addedIds is Collection<Long> and demo IDs are strings like
"DEMO-RESULT-0", an alternative approach is to change the demo searchResultId values to numeric strings (e.g. "10001", "10002", etc.) so that Number() conversion works, and return matching longs in addedIds.
Symptom: After "Interstellar" is typed into the search bar and the user selects an autocomplete result, the search field displays [object Object] instead of being cleared.
Root cause: The uib-typeahead directive on the search field is configured as:
uib-typeahead="item as item.label for item in getAutocomplete($viewValue)"The item as item.label syntax means the typeahead uses item.label as the display text but binds the full item object to the model ($scope.query). When the tour's onNext handler in the autocompleteDropdown step triggers a click
on the typeahead match, the full autocomplete item object gets set as $scope.query. Since $scope.query is displayed in the search field, it renders as [object Object].
The normal flow handles this in selectAutocompleteItem($item) (which is bound via typeahead-on-select): it sets $scope.selectedItem = $item and $scope.query = "". However, when the tour programmatically triggers the click, the
typeahead may set the model value to the object before selectAutocompleteItem clears it, or selectAutocompleteItem may not fire at all via triggerHandler('click').
Fix plan: In the autocompleteDropdown.onNext handler, after triggering the click, explicitly clear $scope.query and set $scope.selectedItem from the autocomplete data. Or, in the movieSelected.onShow handler, clear the search
field model value to empty string.
Symptom: Several tour popovers are shown halfway outside the browser window. The affected steps include those related to autocomplete (autocompleteDropdown, movieSelected), and others near the edges of the viewport. The popovers
with messages like "The search will now use" and "Each shows the movie" are cut off.
Root cause: The positionPopup() function in angular-ui-tour.js (lines 497-513) creates a Tether instance without any constraints configuration:
step.tether = new Tether({
element: step.popup[0],
target: step.element[0],
attachment: positionMap[step.config('placement')].popup,
targetAttachment: positionMap[step.config('placement')].target
// NO constraints property!
});Without Tether constraints, popovers are positioned exactly at the attachment points regardless of whether they overflow the viewport. Tether supports a constraints option with to: 'window' and attachment: 'together' that would
automatically flip the popover to the opposite side when it would overflow, and pin: true to clamp it to the viewport edge.
Fix plan: Add Tether constraints to the positionPopup() function:
constraints: [{to: 'window', attachment: 'together', pin: true}]This makes Tether automatically flip and pin popovers within the viewport bounds.
Symptom: The goButton and movieGoButton popovers (both targeting #startsearch with placement: 'bottom') extend partially outside the right edge of the browser viewport on Firefox. Other popovers that were previously off-screen
have been fixed by the Tether constraints addition (§10.14).
Root cause: Tether's together constraint flips the popover vertically (top↔bottom) but doesn't adjust the horizontal position when the target element is near the right edge. The #startsearch button is near the right side of the
form, and the popover (which is wider than the button) overflows rightward.
Fix: Change the placement of goButton and movieGoButton from 'bottom' to 'left' so the popover opens to the left of the button, avoiding the right edge. This is consistent with the button's position in the form layout.
Symptom: Starting from the "Filtering by Title" step, the dark backdrop overlay only covers part of the page. When the tour scrolls the page (e.g., moving from sorting steps to filter steps), the backdrop's overlay rectangles don't extend to the bottom of the document. For the "Quick Filter Buttons" step, the backdrop only reaches the table header area, leaving the entire results table un-overlaid.
Root cause: Two issues:
- Hone doesn't reposition the backdrop when the page scrolls during tour transitions.
- Even when repositioned, Hone's BOTTOM backdrop element height calculation uses
viewportHeight(window.innerHeight) rather than the full document scroll height. InBackdropSide.jsline 39 (BOTTOM case), it computes:height = Math.max(bodyHeight - targetBottom, viewportHeight - targetBottom)— when the document is taller than the viewport (i.e., the page is scrollable), this produces a height that's too short, leaving the lower portion of the page uncovered.
Fix (two parts):
- Scroll listener: After showing each step, call
uiTourBackdrop.reposition()to force Hone to recalculate the backdrop dimensions. Ascrollevent listener is added while a backdrop step is active that callsreposition()to keep the backdrop covering the full page. - Bottom height correction: A
fixBottomBackdropHeight()function in theuiTourBackdropservice (angular-ui-tour.js) runs after everyhone.position()call. It finds the.ui-tour-backdrop-component-bottomelement, reads itstopstyle, computesneededHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight) - top, and if that exceeds what Hone calculated, overrides the element's height. This ensures the BOTTOM backdrop always extends to the full document height.
Symptom: The "Shift+Click for Range" step (shiftClickExplain) just explains the concept, then the next step ("Try Shift+Click") asks the user to do it. Two steps for one action is unnecessary — the explanation and the action prompt should be in a single step.
Fix: Merge the two steps into one. Remove the shiftClickExplain step and update tryShiftClick to include the explanation content + the user-action callout. Update clearResultsSteps() to remove the merged step ID.
Symptom: In the autocompleteDropdown step, the text "Select one to search by its database ID." reads like a user action instruction but doesn't use the tour-user-action callout styling. This is inconsistent with other user-action
steps.
Note: The autocomplete selection is actually done automatically by the tour (the onNext handler clicks the first item). So this is NOT a user-action step. The text should be reworded to clarify that the tour will select one
automatically, not that the user should do it.
Fix: Reword the content to remove the imperative "Select one..." and instead say something like "The tour will automatically select the first result for you."
Symptom: The "Opening Display Options" step says "The dropdown is now open" but the dropdown never actually opens. Even if it did, the tour popover would cover it since both use placement: 'bottom' on the same #display-options
element.
Root cause: The onShow handler triggers angular.element(btn).triggerHandler('click') on #display-options .btn, but the multiselect dropdown's toggleDropdown() function is bound via ng-click on a wrapping <div>, not the
button itself. The triggerHandler('click') on the button may not propagate to the ng-click handler on the parent div. Additionally, even if the dropdown opened, the popover at placement: 'bottom' would overlap it.
Fix: Restructure the display options steps:
- The
displayOptionsstep explains the button and tells the user the tour will open it. - In
displayOptions.onNext, programmatically setscope.open = trueon the multiselect-dropdown's scope. - The
displayOptionsOpenstep should useplacement: 'left'orplacement: 'right'so it doesn't overlap the dropdown. - The
displayOptionsHighlightandtryDisplayOptionssteps should also not cover the dropdown.
Symptom: Users must click the "Next" button with the mouse for every step. It would be more convenient to support pressing Enter to advance. This should be explained in the welcome step.
Fix: Add a keydown event listener on document when the tour is active that listens for the Enter key and calls tour.next(). Remove the listener when the tour ends. Update the welcome step content to mention "Press Enter or click
Next to continue."
Symptom: Sometimes the previous tour popover is not hidden when the next one is shown, resulting in two (or more) overlapping popovers. For example, the "Direct NZB Download" popover stays visible alongside "Download Sent!", or both of those remain visible alongside the current step's popover.
Root cause: The goTo() method in angular-ui-tour.js has no concurrency guard. When a user presses Enter rapidly, double-clicks the Next button, or uses arrow keys in quick succession, multiple goTo() promise chains execute
concurrently. Since onNext handlers can return delayed promises (e.g., the downloadIcon step returns a 1500ms $timeout), a second goTo() call during that window reads the same getCurrentStep(), fires onNext again, and the
resulting interleaved chains skip hideStep() on intermediate steps. Since popovers are never removed from the DOM (only hidden via display: none), any step whose hideStep() is skipped remains visible indefinitely.
Fix: Added a transitioning flag to the uiTourController in angular-ui-tour.js:
goTo()checkstransitioningat entry and returns$q.reject('Transition in progress')if already set, preventing overlapping chains.- The flag is set to
truebefore the promise chain starts, and cleared in.finally()after the chain completes (whether resolved or rejected). startAt()andend()also resettransitioning = falsesince they represent fresh navigation entry points that should override any stuck state.- All navigation paths (
next(),prev(), hotkey handlers, Enter key listener, Next/Prev buttons) funnel throughgoTo(), so the single guard protects against all sources of duplicate invocations.
Symptom: The "Shift+Click for Range" step highlights the first .result-checkbox (same one the user just clicked), but asks the user to Shift+click a different checkbox further down. The backdrop covers all other checkboxes, making
it impossible to perform the action.
Root cause: The step's selector: '.result-checkbox' resolves to the first checkbox via querySelector, which only returns the first match. The backdrop then only exposes that single checkbox.
Fix: Changed the tryShiftClick step to dynamically highlight the 4th checkbox (index 3) instead:
selectorchanged to'#tour-shift-click-target'— a temporary ID assigned at runtime.onShowhandler finds all.result-checkboxelements, picks index 3 (or the last one if fewer exist), and assigns itid="tour-shift-click-target". SinceonShowruns before element resolution inTourStepService.showStep(), the selector finds the correct element.onHidehandler removes the temporary ID to clean up.- Content text updated to say "click this checkbox" since the popover now points at the actual target.
- Playwright test updated to perform the Shift+click on
#tour-shift-click-target.
Automated Playwright test (test_tour.py) run results — both normal and stress-test modes:
Normal mode (python test_tour.py):
Phase 1 (Basic Search): PASS
Phase 1→2 (Search transition): PASS
Phase 2 (Browsing Results): PASS
Phase 3 (Single Download): PASS
Phase 4 (Multi-Select/Bulk): PASS
Phase 4→5 (To Movie Search): PASS
Phase 5 (Movie Search): PASS
Phase 5→6 (To Display Opts): PASS
Phase 6 (Display Options): PASS
Phase 7 (Wrap-Up): PASS
ALL PHASES PASSED!
Stress-test mode (python test_tour.py --debug-reset):
Phase 1 (Basic Search): PASS
Phase 1→2 (Search transition): PASS
Phase 2 (Browsing Results): PASS (with re-render triggers between each step)
Phase 3 (Single Download): PASS (with re-render triggers between each step)
Phase 4 (Multi-Select/Bulk): PASS (with re-render triggers between each step)
Phase 4→5 (To Movie Search): PASS
Phase 5 (Movie Search): PASS
Phase 5→6 (To Display Opts): PASS
Phase 6 (Display Options): PASS (with re-render triggers between each step)
Phase 7 (Wrap-Up): PASS
ALL PHASES PASSED!