mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
* feat(sources): add the shared source row and credential-free formatting One app-source-row (regular for the Sources page, compact for the playlist switcher) with an app-source-tile, built on pure helpers: - resolvePlaylistSourceKind: one provider rule for icons, filters and counts (the list filter and the panel counts used to disagree). - sourceDisplayName / sourceDisplayUrl: a name or URL never shows a password, token or MAC. URL imports that stored "get.php?username=…&password=…" as the title now show the host. - stripUrlCredentials (shared/logging): drops what the log redactor would mask, plus short user/pass keys. - formatSourceDate / sourceMetaParts / sourceStatusTag: one meta line order and Intl dates and durations in the UI language. - SourceStatusService + SourceHealthHistoryService: one status per source (desktop health check, browser Xtream portal status) and the last conclusive result kept across restarts, so "not responding for 3 days" survives a relaunch. No URL or credential is stored. Also fixes playlistNameHasCredentials answering differently on repeated calls (global regex lastIndex), and adds the UI-kit button, menu and segmented-control mixins (libs/ui/styles/_app-controls.scss); menus opt in with class="app-menu", styled by the components that use them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(sources): rebuild the Sources page on the shared source row - Side list (UI-kit side list): title with the playlist count and an Add button, a Type group, and on desktop a Status group (Any / Responding / Not responding). The two filters combine and persist; each group counts within the other's choice. The chosen row uses one selected-row token (soft tint, outlined in light theme). - Page: a 28px title with the visible count instead of the bordered header bar; Clean up and the sort menu are secondary buttons. Clean up shows how many sources do not respond. Sort adds Last updated, Most channels and (desktop) Not responding first, and keeps Name Z-A and Custom order. - List: app-source-row with hover actions (Refresh, Edit, ⋯); a failing row keeps Retry in view. "No matches" offers Reset filters. Dragging in custom order no longer reuses positions of rows a filter hides. - Delete with Undo: no confirmation; the row leaves at once and the delete runs when the toast closes (its close button commits at once). Deleting the active source switches to the next row. A large Xtream delete shows its row as "Removing…" with progress and Cancel, as before. - The list's refresh flows moved into SourceListRefreshService; E2E and performance drivers follow the new markup and delete flow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(switcher): restyle the toolbar trigger and open it with Cmd/Ctrl+P - The trigger is a 40px secondary button: the active source's tile with its status dot, its name and "type · channels" (or the source count), and a chevron that turns while open. The name comes from sourceDisplayName, so a URL-named playlist no longer shows its URL or credentials in the toolbar. - Cmd+P / Ctrl+P opens the switcher wherever it is shown (Cmd+K stays the command palette) and is listed in the keyboard shortcuts dialog. - The trigger's inline refresh button is gone; refresh stays in the playlist's ⋯ menu and the command palette. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(switcher): rebuild the playlist switcher as an overlay popover The switcher's mat-menu becomes a CDK connected overlay (app-playlist-switcher-panel), 440px wide, with a dimmed, blurred backdrop; only its list scrolls and row menus open above it. - Header with the source count, Add playlist and All sources. - Search by name or host, autofocused; the type filter is one segmented control (All / M3U / Xtream / Stalker with counts) instead of three checkboxes, remembered between openings (the old checkbox setting is migrated). The search is no longer kept between openings. - "Recent" (the three most recently opened, kept across restarts) above "All" in the Sources page order, as compact source rows: the current one tinted with a check. Each row's ⋯ menu holds Refresh, Playlist info, Account info, Check again and Remove (with Undo); the footer's info, account and add actions moved there and to the header. - Keyboard: a listbox driven by aria-activedescendant from the search field. Arrow keys move and wrap, Enter opens, Shift+F10 opens the row's menu, Escape closes and returns focus to the trigger. Hints in the footer. - Deleting from a row menu moves focus to the toast's Undo, since the row (and its focus) left the page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(sources): remove the old source row, filter state and dead styles - Delete app-playlist-item and SourceHealthIndicatorComponent, replaced by app-source-row and its tile dot. - Delete the ngrx type-filter slice (FilterActions, selectedFilters, selectActiveTypeFilters); the Sources filters live in SourcesViewStateService. - Drop the empty state's unused "no results" variant and its styles. - The switcher and Sources list components are no longer in the max-lines baseline. - Docs: UI guidelines (source row, Sources page and switcher, status dot, selected-row tokens, UI-kit mixins), the source-health contract and the zoneless inventory. Release notes for the redesign and the credentials fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(catalog): keep the refine menu whole in short windows The refine menu held 12 rows at 48px plus two section titles (657px). Angular Material 22 sets no max-height on a menu panel, so below ~800px the overlay was pushed up over its own Refine button, and at 600px the last thresholds were clipped behind an invisible scrollbar. The minimum-rating thresholds now open in a submenu from one row that names the active threshold. The menu is about 390px and opens below its button at 600px. The submenu trigger sits in a role="none" wrapper: Material offsets a submenu by the first direct mat-menu-item's offsetTop, and with the sort rows inside a role="group" that item was the trigger itself, which opened the submenu 335px off screen. The Electron E2E opens both menus by keyboard in a 600px-tall window and checks that each lies whole on screen without scrolling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * style(workspace): drop the side panel's divider line The context panel beside Sources, categories, Settings and collections drew a 1px outline-variant border against the content surface, whose background, shadow and rounded top-left corner already mark the edge; the line clashed with that corner. The border stays 1px but transparent, so the panel width, the persisted sidebar widths and the resize handle's position do not move. The handle keeps its hover and drag highlight. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(switcher): keep the search field and type filter whole with many sources The switcher popover is a height-capped flex column. With a long list (hundreds of sources) the browser shrank every part in proportion to its size, not only the list: the type filter, which clips its overflow, collapsed to 2px and showed no labels, and the search field dropped from 36px to its 21px text line. The header, search, type filter, empty state and footer are now flex: none, so only the list shrinks and scrolls. The search field now matches the header and side-panel search fields: the shared --app-search-* tokens, a 10px radius, an 18px icon, the same text size and focus halo. The Electron E2E opens the switcher in a 320px-tall window and checks the search height, the type toggles and the scrolling list; it fails without the fix (search 21px). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): checks behind filters, keyboard Undo in the switcher, check causes Local review round 1 (Codex + Greptile) on the redesign: - A saved Responding / Not responding filter hid every source without a result, and the hidden rows were what started the checks, so the list stayed empty. keepSourcesChecked (portal shared data-access) now keeps every library source checked while the Sources page lives, on desktop where a check is one shared job that rows join. - Removing from the switcher left its focus trap open around the search, outside of which the Undo toast sits: Tab could not reach Undo. The popover now closes first; Undo takes the focus and returns it to the trigger (SourceRemovalOptions.returnFocusTo). Every switcher action closes through one path, so a closing row menu no longer pulls the focus back into the popover. - A running desktop Xtream delete brought its source back into the switcher as an ordinary option that could be opened or removed again; the switcher now leaves it out until the delete ends (the Sources page shows its progress and Cancel). - Undo re-activated the removed source over a source the user had chosen since, when nothing had replaced it (it was the only row a filter showed). Undo now restores only while no other source is active. - The switcher highlight stays on its option, or in range, when the list changes under the same search, so Enter still opens something. - The status dot named every failure "Could not connect". SourceStatus now carries the check's cause (access denied, account disabled, timeout…, kept with the health history and validated on read), and M3U dots again say the check covers the playlist URL, not its channels. Tests: new specs for keepSourcesChecked, the saved-filter check, the Undo guard and focus return, the switcher close/highlight/deleting cases and the dot's cause; the filter and Undo specs fail without their fixes. The switcher keyboard E2E now deletes and undoes from the keyboard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): own Undo timer, per-connection health history, race-free perf waits Local review round 2 (Codex + Greptile): - The delete committed whenever the shared snack bar was dismissed, so another message (a refresh finishing elsewhere) cut the four-second Undo window short and deleted the source early. The window is now the service's own timer; only the toast's close button (commitNow) or a second delete commits sooner. - The health history was keyed by playlist ID only, so an edited address or account inherited the old connection's result, failing run included. Entries now keep a 32-bit FNV-1a fingerprint of the source health key (no URL or credential); a result for another connection starts over and a stale entry no longer shows. - The M3U refresh-cancellation probe still queried the removed row's .cancel-btn, .busy-state__message and .action-spinner, so its cancel never fired; it now uses the source row's selectors (smoke run passes, cancelEffect=true). - The Xtream delete benchmark waited to catch the "Removing…" row coming back before it left, which a fast delete could finish before the wait began. The driver now arms a delete-playlist operation window before the commit click and ends on its completed event and the absent row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): sort by the shown name; start the benchmark's Undo window in its trigger Local review round 3 (Greptile; Codex was clean): - Name order compared the stored title, while the rows show a URL-named or credential-bearing source by its host: a source stored as get.php?username=… sorted by its hidden account details. sortPlaylists takes the list's name function, and Sources and the switcher pass sourceSortName, the name they display. - The Xtream delete benchmark clicked Remove while preparing, which starts the four-second Undo window; capture setup before the trigger could outlast it, so the delete committed on its own before the measurement and the close button was gone. Preparation now stops at the open menu, and the trigger clicks Remove and then the toast's close button, with the trigger time taken right before the commit click as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): never hand over to a leaving source; browser checks per connection Local review round 4 (Codex + Greptile): - Removing the active source handed over to its neighbour even when that neighbour's own delete was still running ("Removing…"), activating a source about to disappear. replacementAfterRemoval (playlist shared util) picks the nearest row that is not leaving, on the Sources page and in the switcher; SourceRemovalService also refuses a leaving replacement. - In the browser build a "Check again" for an account the user then edited could finish last and overwrite the new account's status under the same playlist ID. Portal statuses are now keyed by playlist and connection fingerprint, so the old check lands under the old key. - The switcher scrolled the highlighted option into view through a selector built from the playlist ID, which a restored ID with "." or ":" broke (or made throw). It now finds the row by its exact DOM ID. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): keep ⌘P out of open dialogs; redact refresh errors Local review round 5 (Greptile; Codex was clean): - Ctrl/Cmd+P opened the playlist switcher over an open dialog (an editor, a confirmation), taking the focus out of it and handing it back to the toolbar behind the dialog on close. The shortcut now leaves the key alone while MatDialog has open dialogs. - SourceListRefreshService logged a failed refresh's raw error, which can carry the playlist URL and its credentials. It now logs through the redacting portal logger (createLogger). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): show a new search's first match; keep inconclusive check causes Local review round 6 (Codex; Greptile was clean at 5/5): - Typing a search or picking a type in the switcher highlighted the first match but kept the list scrolled where the previous result left it, so the highlighted row could sit above the view. The list returns to the top whenever the search or type changes. - A source with no kept history whose check was inconclusive (connection checks paused by the connectivity guard, a cancelled check) lost the live cause and time, and its dot said only "Not verified". The status now keeps them; a running check still reads as checking. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(switcher): fall back to All when the remembered type has no sources left Local review round 7 (Greptile 5/5 with one P2; Codex was clean): when the last source of the remembered switcher type was removed while two other types remained, the list kept filtering by the missing type. Its segment was gone, no segment was selected, and the list said "Nothing found". The list now filters by All until a source of that type returns; the remembered choice is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): clean credentials nested in kept URL parts Codex bot (PR review, P1): stripUrlCredentials copied every non-sensitive parameter value unchanged, so a URL nested in one (a proxy's ?url=http%3A%2F%2Fjoe%3Ahunter2%40origin.test%2F…) kept its userinfo and password and reached the Sources row through sourceDisplayUrl. Kept query and fragment values and path segments are now cleaned recursively, up to four levels: a nested URL loses its credentials the same way, a nested parameter list loses its sensitive keys, and a part that still names a secret (joe:hunter2@host, token=…) is dropped. Deeper nesting is dropped rather than shown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): keep fragment and nested URL secrets out of source names Greptile bot (PR review, P1 security): an unnamed import of https://host.test/list.m3u#api_key=secret123 stored list.m3u#api_key=… as its title. isUrlDerivedName only knew names ending in a query, and sourceSecrets only read the top-level query, so the row and the toolbar showed the secret while the displayed URL hid it. A name that ends the URL with a query or a fragment is now URL-derived, and shows the host. The secrets a typed name is checked against now include sensitive fragment parameters and, as stripUrlCredentials cleans them, the credentials of URLs and parameter lists nested in parameters (nested account names included). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): keep deletes busy until settled and on screen; refresh E2E via the switcher PR #1932 round 1 (CI + local Codex/Greptile on the bot fixes): - CI: category-management.e2e refreshed the active playlist through the old switcher trigger's inline refresh button, which the redesign replaced with the switcher row menu (⋯ → Refresh playlist). The helper now goes through that menu; it failed on all three OS shards. - A desktop Xtream delete's completed event cleared its progress while PlaylistsService still cleaned up (parental lock, stored state), so the already-deleted source was actionable again on Sources and back in the switcher until the store dropped it. A terminal event now only ends Cancel; the row stays busy until the delete settles and the removal service clears it. - A running delete (from the switcher, say) disappeared from Sources when the page's saved filters excluded it, hiding its only progress and Cancel. Running deletes stay listed whatever the filters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): keep counting a failure's duration while the list stays open Local Greptile (5/5, one P2) on the PR round: the row's status tag formatted "Not responding · 3 days" against the time the row mounted (or the language last changed), so a new failure never gained its duration and an existing one froze while Sources stayed open. A shared root SourceClock now ticks once a minute and only the status tag reads it, so dates and the meta line do not recompute every minute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): keep the page header in view; mark the active source's row Local Greptile (two P2s) on the PR round, both regressions against master: - The redesigned Sources page scrolled as one, so the title, Clean up and Sort left the view in a long library (master kept a sticky header, and the UI guidelines say headers stay and lists scroll). The header now stays and only the list below it scrolls, which also keeps a row reached by keyboard from scrolling under the header. - Master marked the active playlist on the Sources page; the new row only tinted the current row in the compact (switcher) size. The active source's row is now tinted with the same --app-list-selected-* token on the page too; the switcher keeps its check. The design handoff called the header non-sticky and the current tint switcher-only; both followed master and the guidelines instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): name the Sources sort group; refit the switcher on resize Local review on the PR round: - Greptile: the Sources sort menu's radio group had no accessible name, unlike every other menu group (UI guidelines). It is named by its section, "Sort by". - Codex: the switcher popover capped its height once, when it opened, so shrinking the window while it was open left the footer and the lower rows out of reach. A window resize refits the open popover. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * wip: linear URL-cleaning regexes * fix(sources): linear URL-cleaning patterns; keep the stripper off the startup path PR #1932 CI: - CodeQL (polynomial regular expression on uncontrolled data): the embedded-URL and keyed-pair patterns in stripUrlCredentials could retry from every position of a long run of letters. Matches now start only where a name can start (lookbehinds) and every name part is bounded, as is the bare-userinfo pattern; a spec times 50,000-character runs. The preceding "wip: linear URL-cleaning regexes" commit holds that change. - Initial bytes ratchet (+135 B over after master's #1928 lowered the baseline): stripUrlCredentials sat behind the shared logging barrel, which startup code imports, so esbuild placed it in an initial chunk although only the lazy Sources page and switcher use it. It now lives beside its only consumer in playlist/shared/util source-display, and the logging barrel exports the two redaction helpers it needs. Measured locally on the merge with master: 1,537,497 bytes, within 1,538,700. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): case-insensitive Xtream path markers; no removal during a refresh - Strip and redact the account segments after `/LIVE/`, `/Movie/` or an encoded marker such as `/%6Cive/`, as after `/live/`. The display-name check also treats an Xtream path password as a URL secret. - A source cannot be removed while a switcher refresh clears and refills it: the removal service ignores the request, the switcher disables Remove, and the Sources page shows the refresh as busy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): search URLs nested in path segments for name secrets A proxy URL can carry a whole source URL, or a token pair, in one path segment. The display stripper already cleans such segments; the name check now collects their secrets too, so a name holding them collapses to the host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): decode nested URLs before classifying; narrow Xtream paths - A URL nested in a parameter or path segment is percent-decoded one round at a time until it reads as a URL or parameter list, so one encoded twice is cleaned too; a value still encoded after the last round is dropped. The name check unwraps nested values the same way. - The two segments after `/live/` and its siblings count as an account only when a stream follows them, so a playlist path such as `/live/news/list.m3u` keeps its file name in the URL and the title. - Adding a playlist from the phone drawer closes the drawer first, so two focus-trapped modals never stack. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): clean URL-shaped fragments; one registry for busy sources - A fragment is cleaned whatever its shape: `#https://joe:pw@…` loses its userinfo, an unclassifiable secret is dropped, and the name check reads a URL in the fragment as well as a parameter list. - Every refresh holds its source in the root SourceActivityService, which deletes and the startup auto-refresh already use: Sources-row refreshes, the switcher's M3U refresh and the shared Xtream flow. The switcher no longer offers Refresh or Remove for a source the page is refreshing, the removal service ignores a held source, a second refresh from either entry point is skipped, and the page disables Remove while it is held. - The long-input timing spec allows 3 s: the inputs take about 0.25 s, a backtracking pattern takes minutes, and a loaded runner took 1.6 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): keep a browser refresh busy until the backend answers The browser build's PLAYLIST_UPDATE returned at once while its request ran on, so the row stayed refreshable and removable and the shared registry saw the source as idle. The event now settles when the refresh does (the PWA service resolves after its success or error report; the Electron fallback awaits its handler), and both the Sources row and the switcher keep the source busy and held until then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): name check hides values under short account keys The name check used the log redactor's key list, which leaves out the short keys `pass`, `pas`, `user` and `usr` that the URL stripper drops. Both now share `isDisplaySensitiveKey`, so a name holding `?pass=…` collapses to the host; `user`/`usr` are account names like `username` and stay shown at the top level, as by design. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): read keys, markers and secrets through every encoding layer Each check decoded its input once, so one more encoding layer slipped a secret past it: `?%2570assword=…` kept its value, and `/%256Cive/…` kept its account segments, both on screen and in logs. `decodeFully` (shared logging) undoes up to four percent-encoding layers and is now the one form every name check reads: the redactor's key normaliser and Xtream marker check, the display key predicate and path check, and the name check's secret values. Logs and the Sources lists gain the fix together. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): read Xtream paths decoded; keep short decoded forms out - An Xtream account could hide behind encoded separators (`live%2Fjoe%2Fhunter2%2F1.ts` is one raw segment). `findXtreamAccount` reads the path as decoded parts, maps them back to raw segments, and is the one scanner for the log redactor, the on-screen URL and the name check. Account segments go whole, joined ones included. - The name check required a minimum length only of the raw secret, so a decoded form as short as one letter (`%2542` is `B`) hid ordinary names. Each form must now be long enough on its own. - The decoding helpers move to `url-credential-parts.ts`, which keeps the redactor under the line limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): read an Xtream path both whole-segment and split Reading the path only as decoded parts misplaced the account when the user name itself held an encoded slash (`/live/joe%2Fx/hunter2/1.ts`): the password segment stayed on screen and in logs. `findXtreamAccount` now scans both readings, each raw segment decoded whole and the decoded path split on every separator, and drops what either finds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): drop JSON configs from shown URLs; read their secrets A nested value such as `?config={"password":"…"}` was neither a URL nor a parameter list, and the quoted key slipped past the free-text check. - A nested value that parses as a JSON object or array is dropped from the shown URL (JSON can spell a key so no pattern reads it, such as `w`); brackets that are not JSON, like `[1080p]`, stay. - The free-text check accepts a quoted key (`"token": …`). - The name check parses JSON values and collects what sits under sensitive keys, and follows URLs in its other strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): hold Xtream sources through re-import; match secrets in any case - An Xtream refresh released its hold when navigation ended, while the re-import ran on from the route session, so Remove came back during the import's writes. The route session now takes the hold synchronously on NavigationEnd and keeps it until its sync, and the import it awaits, settle; the store also holds the source for every content initialization, retries included. - Secret matching was case-sensitive against encodeURIComponent's upper-case escapes, so `%3a` or a lower-case MAC slipped through. Text and secrets are now compared lower-cased, the text also fully decoded. - The Xtream `· username` label is left out when it is, or holds, the password. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): read a bracketed key as parameters unless it is JSON The name check returned early for any value starting with `{` or `[`, so `[password]=…` (not JSON) skipped the parameter check and its password stayed in the name. It now returns early only when the value parses as a JSON object or array. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): no opening a source from the switcher mid-refresh While an Xtream refresh was still deleting the catalog, ⌘P could reopen the switcher and selecting that source navigated at once, starting its import before the user data to restore was parked. The refresh flow now exposes `isRunning` (its set of runs is a signal), and the switcher marks a refreshing source aria-disabled and ignores its selection by pointer and Enter alike. Imports alone do not block opening a source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): hand over only to an idle source; hide names that are a short secret - Removing the active source could hand over to a neighbour a refresh was still clearing, opening it and starting its import early. `SourceRemovalService.canTakeOver` (not leaving, not deleting, not held in SourceActivityService) now picks the replacement in both lists and gates it in the service. - Secrets under three characters were dropped from the check entirely, so a name that was just a two-letter password showed it. Short secrets now count as the whole text (trimmed, any case); longer ones still match inside a text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): offer Refresh only when it can run; drag handle never opens - PlaylistRefreshActionService runs one refresh at a time, but canRefresh checked only the source, so the switcher, the command palette and the dashboard offered a Refresh that refresh() then ignored. canRefresh now applies the same global guard. - In Custom order a click on a row's drag handle bubbled to the row and opened the source; the handle now stops it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): no idle Refresh on held rows; Recent counts the filtered list - The Sources page offered Refresh on a source the startup auto-refresh, an import or a delete held, and refresh() then refused it. The row now offers Refresh only while it can run, keeping it (with its spinner) while that source refreshes. - The switcher showed Recent whenever the library had six sources, so a type filter down to one or two listed them twice and the keyboard visited each twice. Recent now depends on the filtered count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): compare passwords in their own case; only a MAC caselessly Matching every secret lower-cased hid any name holding the password word in another case: the Electron E2E source "Stress Xtream Portal" (password `stress`) showed its host, and the deletion test could not find its row. A password is case-sensitive, so it is compared as written; the text is still read fully decoded, so `%3a` escapes count as `:`. A MAC is the same address in either letter case and is compared lower-cased. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sources): match a MAC from the URL in any letter case Only the source's own `macAddress` was compared caselessly, so an M3U URL carrying `?mac=…` (nested ones too) left the MAC visible in a name written in another case. Any MAC-shaped secret, and every form of the source's own MAC, is now compared lower-cased; passwords keep their case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(sources): keep source-health helpers off the startup path Master's #1931 lowered the initial-bytes baseline, and this branch then sat 535 bytes over its slack. Most of its growth was runtime helpers in `@iptvnator/shared/interfaces` (reachability, health history, reason and fingerprint helpers) that only the lazily loaded source services call: the bundler put them in a chunk the app preloads. They now live in `@iptvnator/portal/shared/data-access` beside those services (types stay in interfaces); initialBytes 1,350,438 → 1,349,048, 855 under the limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1398 lines
55 KiB
TypeScript
1398 lines
55 KiB
TypeScript
import { type APIRequestContext } from '@playwright/test';
|
||
import {
|
||
closeSeriesMenu,
|
||
expectSeriesSurfacesInBothThemes,
|
||
seriesMenuRow,
|
||
setInputValue,
|
||
} from './e2e-helpers';
|
||
import {
|
||
verifyStalkerCategorySearch,
|
||
verifyStalkerPlaybackCategoryReturn,
|
||
verifyUncachedStalkerSearch,
|
||
} from './stalker-category-search.fixture';
|
||
import {
|
||
findEmbeddedSeriesItem,
|
||
openEmbeddedSeriesItem,
|
||
releaseExtraEpisodeInSearchResponses,
|
||
} from './stalker-embedded-series.fixture';
|
||
import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture';
|
||
import { verifyStalkerOpenInPlaylist } from './stalker-open-in-playlist.fixture';
|
||
import { playFirstItvChannel } from './stalker-itv-playback.fixture';
|
||
import {
|
||
AUTH_REJECTED_MAC,
|
||
BACKEND_PROXY,
|
||
CONTENT_ACTIONS,
|
||
DEFAULT_MAC,
|
||
EMBEDDED_SERIES_MAC,
|
||
FULL_PORTAL_URL,
|
||
LEGACY_PAGINATION_MAC,
|
||
MINIMAL_MAC,
|
||
MOCK_SERVER,
|
||
STATIC_CMD_MAC,
|
||
type StatefulAuthMacs,
|
||
addFullStalkerPortal,
|
||
addStalkerPortal,
|
||
getStatefulAuthMacs,
|
||
interceptStalkerRequests,
|
||
recordPortalRequests,
|
||
} from './stalker-portal.fixture';
|
||
import { expect, test } from './fixtures';
|
||
|
||
/**
|
||
* Stalker Portal E2E Tests
|
||
*
|
||
* These tests use the stalker-mock-server (apps/stalker-mock-server) to simulate
|
||
* a real Stalker portal. The mock server starts automatically alongside the Angular
|
||
* dev server when running e2e tests (see playwright.config.ts).
|
||
*
|
||
* Default scenario MAC (00:1A:79:00:00:01) provides:
|
||
* - 8 categories per content type (VOD / Series / ITV / Radio)
|
||
* - 40 items per category
|
||
* - 3 seasons × 8 episodes per series item
|
||
*
|
||
* Tag: @stalker — run only stalker tests with: nx e2e web-e2e --grep "@stalker"
|
||
*
|
||
* ISOLATION works on three levels, because one mock-server process is shared by
|
||
* every spec file and its state is keyed by MAC:
|
||
*
|
||
* - ACROSS FILES: `beforeEach` resets only the MACs in `OWNED_MACS`, and the
|
||
* sibling files that touch this server (self-hosted, sources-pwa) own a
|
||
* disjoint `00:1A:79:5F:*` range, so neither can clear the other's state.
|
||
* - WITHIN THIS FILE: tests deliberately share scenario MACs (`default`,
|
||
* `minimal`, `embedded-series` — their fixture shapes are what the
|
||
* assertions are written against), and every `beforeEach` resets all of
|
||
* them. Under the workspace-wide `fullyParallel` preset that would let one
|
||
* test wipe another's data or session mid-run, so each project/repeat group
|
||
* runs its tests serially in one worker.
|
||
*
|
||
* Giving each test its own MAC instead would mean inventing a scenario per
|
||
* test; serializing one file is the cheaper trade.
|
||
*
|
||
* - ACROSS BROWSER PROJECTS/WORKERS: state-sensitive auth tests use one MAC
|
||
* range per Playwright parallel slot. Their `beforeEach` adds only that
|
||
* slot's MACs to the scoped reset, so concurrent projects cannot clear one
|
||
* another's token, invalidated session or pinned device id. A restarted
|
||
* worker retains its bounded `parallelIndex`.
|
||
*/
|
||
|
||
test.describe.configure({ mode: 'serial' });
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Every MAC this file owns; all are cleared in one batched reset request. */
|
||
const OWNED_MACS = [
|
||
DEFAULT_MAC,
|
||
MINIMAL_MAC,
|
||
EMBEDDED_SERIES_MAC,
|
||
LEGACY_PAGINATION_MAC,
|
||
STATIC_CMD_MAC,
|
||
AUTH_REJECTED_MAC,
|
||
];
|
||
|
||
/**
|
||
* Reset only the MACs this file owns, in a single request. Mock state is
|
||
* per-MAC and sibling spec files talk to the same server from parallel
|
||
* workers, so a global reset here would wipe their state mid-test — and
|
||
* theirs would wipe ours.
|
||
*/
|
||
async function resetMockServer(
|
||
request: APIRequestContext,
|
||
statefulAuthMacs: StatefulAuthMacs
|
||
): Promise<void> {
|
||
const resetMacs = [...OWNED_MACS, ...Object.values(statefulAuthMacs)];
|
||
const query = resetMacs
|
||
.map((mac) => `macAddress=${encodeURIComponent(mac)}`)
|
||
.join('&');
|
||
let lastError: unknown;
|
||
|
||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||
try {
|
||
const response = await request.post(
|
||
`${MOCK_SERVER}/reset?${query}`
|
||
);
|
||
if (response.ok()) {
|
||
return;
|
||
}
|
||
|
||
lastError = new Error(
|
||
`Reset failed with status ${response.status()}`
|
||
);
|
||
} catch (error) {
|
||
lastError = error;
|
||
}
|
||
|
||
await new Promise((resolve) =>
|
||
setTimeout(resolve, 250 * (attempt + 1))
|
||
);
|
||
}
|
||
|
||
throw lastError;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test setup
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test.beforeEach(async ({ page, request }, testInfo) => {
|
||
// Reset mock server state (clears in-memory favorites and cache)
|
||
await resetMockServer(request, getStatefulAuthMacs(testInfo));
|
||
|
||
// Playwright creates a fresh browser context per test, so extra
|
||
// IndexedDB cleanup here only risks racing with app-managed DB handles.
|
||
await page.goto('/');
|
||
|
||
// Redirect backend proxy calls to the mock server
|
||
await interceptStalkerRequests(page);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('@stalker health check — mock server is running', async ({ request }) => {
|
||
const response = await request.get(`${MOCK_SERVER}/health`);
|
||
expect(response.ok()).toBeTruthy();
|
||
const body = await response.json();
|
||
expect(body.status).toBe('ok');
|
||
});
|
||
|
||
test('@stalker auth MAC allocation survives worker process restarts', () => {
|
||
const restartedWorker = { parallelIndex: 7, workerIndex: 256 };
|
||
|
||
expect(getStatefulAuthMacs(restartedWorker).loginRequired).toBe(
|
||
'00:1A:79:AE:07:02'
|
||
);
|
||
});
|
||
|
||
test('@stalker add a Stalker portal and see it in the playlist list', async ({
|
||
page,
|
||
}) => {
|
||
await addStalkerPortal(page, { name: 'My Test Portal' });
|
||
|
||
// Portal card should appear on the home page
|
||
await expect(
|
||
page.getByText('My Test Portal', { exact: false })
|
||
).toBeVisible();
|
||
});
|
||
|
||
test('@stalker account info dialog shows subscription facts from the portal', async ({
|
||
page,
|
||
}) => {
|
||
await addStalkerPortal(page);
|
||
|
||
// Open the header playlist switcher and the "Account info" entry in the
|
||
// active stalker portal's row menu.
|
||
await page.locator('[data-test-id="playlist-switcher-trigger"]').click();
|
||
await page
|
||
.locator(
|
||
'app-playlist-switcher-panel [role="option"][aria-current="true"] .source-row__more'
|
||
)
|
||
.click();
|
||
await page.getByRole('menuitem', { name: 'Account info' }).click();
|
||
|
||
const dialog = page.locator('mat-dialog-container');
|
||
await expect(dialog).toBeVisible();
|
||
await expect(dialog.getByText('Account Information')).toBeVisible();
|
||
// Facts served by the mock's account_info/get_main_info handler.
|
||
await expect(dialog.getByText('Mock Premium 180').first()).toBeVisible();
|
||
await expect(dialog.getByText(DEFAULT_MAC).first()).toBeVisible();
|
||
});
|
||
|
||
test('@stalker VOD — categories load from mock server', async ({ page }) => {
|
||
await addStalkerPortal(page);
|
||
|
||
// Default scenario has 8 VOD categories (+ 1 "All categories" prepended by the store)
|
||
const categoryItems = page.locator('.category-item');
|
||
await expect(categoryItems.first()).toBeVisible({ timeout: 10_000 });
|
||
const count = await categoryItems.count();
|
||
expect(count).toBeGreaterThanOrEqual(9);
|
||
});
|
||
|
||
test('@stalker VOD — content list loads after selecting a category', async ({
|
||
page,
|
||
}) => {
|
||
await addStalkerPortal(page);
|
||
|
||
// Click the first non-"All" category
|
||
const categories = page.locator('.category-item');
|
||
await categories.nth(1).click();
|
||
|
||
// Content grid / list should appear with items
|
||
const contentItems = page.locator(
|
||
'.content-card, [data-test-id="channel-item"], mat-card'
|
||
);
|
||
await expect(contentItems).not.toHaveCount(0, { timeout: 10_000 });
|
||
await expect(contentItems.first()).toBeVisible({ timeout: 10_000 });
|
||
});
|
||
|
||
test('@stalker minimal scenario — correct item counts', async ({ page }) => {
|
||
await addStalkerPortal(page, {
|
||
name: 'Minimal Portal',
|
||
mac: MINIMAL_MAC,
|
||
});
|
||
|
||
// Minimal scenario: 2 categories (+ "All" = 3 visible)
|
||
const categories = page.locator('.category-item');
|
||
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
|
||
const count = await categories.count();
|
||
// At least 2 real categories
|
||
expect(count).toBeGreaterThanOrEqual(2);
|
||
});
|
||
|
||
test('@stalker PWA hides EPG for ITV channel', async ({ page }) => {
|
||
await addStalkerPortal(page);
|
||
|
||
const epgInfoRequests: string[] = [];
|
||
const shortEpgRequests: string[] = [];
|
||
page.on('request', (request) => {
|
||
const url = request.url();
|
||
if (url.includes('action=get_epg_info')) {
|
||
epgInfoRequests.push(url);
|
||
}
|
||
if (url.includes('action=get_short_epg')) {
|
||
shortEpgRequests.push(url);
|
||
}
|
||
});
|
||
|
||
// Navigate to ITV tab
|
||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||
await page.waitForURL(/stalker.*itv/);
|
||
|
||
// ITV view requires an explicit category selection before channels render
|
||
const categories = page.locator('.category-item');
|
||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||
await categories.nth(1).click();
|
||
|
||
// Wait for channels to appear
|
||
const channels = page.locator('[data-test-id="channel-item"]');
|
||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||
expect(shortEpgRequests).toHaveLength(0);
|
||
|
||
// Click a channel — PWA/browser playback must not expose Electron EPG UI.
|
||
await channels.first().click();
|
||
await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
|
||
await expect(page.locator('app-web-player-view')).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
await expect(page.locator('app-epg-timeline')).toHaveCount(0);
|
||
expect(epgInfoRequests).toHaveLength(0);
|
||
expect(shortEpgRequests).toHaveLength(0);
|
||
});
|
||
|
||
test('@stalker ITV favorite opens inside its portal from global favorites', async ({
|
||
page,
|
||
}) => {
|
||
await addStalkerPortal(page);
|
||
await verifyStalkerOpenInPlaylist(page, 'Mock Stalker Portal');
|
||
});
|
||
|
||
test('@stalker ITV playback survives a category switch', async ({ page }) => {
|
||
await addStalkerPortal(page);
|
||
await verifyStalkerPlaybackCategoryReturn(page);
|
||
await expect(page.locator('app-web-player-view')).toBeVisible();
|
||
});
|
||
|
||
test('@stalker radio — stations use the inline audio player without EPG', async ({
|
||
page,
|
||
}) => {
|
||
await addStalkerPortal(page);
|
||
|
||
const radioListRequests: string[] = [];
|
||
const epgRequests: string[] = [];
|
||
page.on('request', (request) => {
|
||
const url = request.url();
|
||
if (
|
||
url.includes('type=radio') &&
|
||
url.includes('action=get_ordered_list')
|
||
) {
|
||
radioListRequests.push(url);
|
||
}
|
||
if (
|
||
url.includes('action=get_epg_info') ||
|
||
url.includes('action=get_short_epg')
|
||
) {
|
||
epgRequests.push(url);
|
||
}
|
||
});
|
||
|
||
await page.getByRole('link', { name: /radio/i }).click();
|
||
await page.waitForURL(/stalker.*radio/);
|
||
|
||
const categories = page.locator('.category-item');
|
||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||
await categories.nth(1).click();
|
||
|
||
const stations = page.locator('[data-test-id="channel-item"]');
|
||
await expect(stations.first()).toBeVisible({ timeout: 20_000 });
|
||
await expect.poll(() => radioListRequests.length).toBeGreaterThan(0);
|
||
|
||
await stations.first().click();
|
||
await expect(stations.first()).toHaveClass(/active/, { timeout: 20_000 });
|
||
await expect(page.locator('app-audio-player')).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
|
||
// Level 2: only the categories rail folds; the channels header turns
|
||
// its title into a category dropdown that opens the same rail as a
|
||
// popover, whose footer brings the rail back.
|
||
await page
|
||
.getByRole('button', { name: 'Hide categories', exact: true })
|
||
.click();
|
||
const categoryDropdown = page.locator(
|
||
'[data-test-id="live-category-dropdown"]'
|
||
);
|
||
// The folded rail is 0px wide and inert; its rows keep their own boxes,
|
||
// so the rail's attribute is the reliable signal.
|
||
const categoriesRail = page.locator('aside.context-panel--route');
|
||
await expect(categoryDropdown).toBeVisible();
|
||
await expect(categoriesRail).toHaveAttribute('inert', '');
|
||
await categoryDropdown.click();
|
||
await page
|
||
.getByRole('dialog')
|
||
.getByRole('button', { name: 'Show categories panel' })
|
||
.click();
|
||
await expect(categoryDropdown).toBeHidden();
|
||
await expect(categoriesRail).not.toHaveAttribute('inert');
|
||
await expect(categories.nth(1)).toBeVisible();
|
||
|
||
// Level 3: both rails fold; the floating handle restores them.
|
||
await page
|
||
.getByRole('button', { name: 'Hide categories and channels' })
|
||
.click();
|
||
const restoreButton = page.getByRole('button', {
|
||
name: 'Show categories and channels',
|
||
});
|
||
await expect(restoreButton).toBeVisible();
|
||
await expect(categoriesRail).toHaveAttribute('inert', '');
|
||
await expect(page.locator('.sidebar')).toHaveAttribute('inert', '');
|
||
// The workspace header carries a second toggle that mirrors the state.
|
||
const headerToggle = page.locator(
|
||
'app-workspace-shell-header .header-sidebar-toggle'
|
||
);
|
||
await expect(headerToggle).toHaveAttribute('aria-pressed', 'false');
|
||
await restoreButton.click();
|
||
await expect(restoreButton).toBeHidden();
|
||
await expect(headerToggle).toHaveAttribute('aria-pressed', 'true');
|
||
await expect(categoriesRail).not.toHaveAttribute('inert');
|
||
await expect(page.locator('.sidebar')).not.toHaveAttribute('inert');
|
||
await expect(stations.first()).toBeVisible();
|
||
|
||
await expect(page.locator('app-epg-timeline')).toHaveCount(0);
|
||
expect(epgRequests).toHaveLength(0);
|
||
});
|
||
|
||
test('@stalker radio playback survives a category switch', async ({ page }) => {
|
||
// Radio shares the ITV layout and the same context-panel handler that
|
||
// used to clear the selected item on every category click (#1517). The
|
||
// inline audio player is gated on that selection, so a category switch
|
||
// silenced the station the user never switched away from.
|
||
await addStalkerPortal(page);
|
||
|
||
await page.getByRole('link', { name: /radio/i }).click();
|
||
await page.waitForURL(/stalker.*radio/);
|
||
|
||
const categories = page.locator('.category-item');
|
||
await expect(categories.nth(2)).toBeVisible({ timeout: 10_000 });
|
||
await categories.nth(1).click();
|
||
|
||
const sidebar = page.locator('app-stalker-live-stream-layout .sidebar');
|
||
const sidebarTitle = sidebar.locator('.category-title');
|
||
const stations = page.locator('[data-test-id="channel-item"]');
|
||
await expect(stations.first()).toBeVisible({ timeout: 20_000 });
|
||
const firstCategoryTitle = (await sidebarTitle.textContent())?.trim() ?? '';
|
||
expect(firstCategoryTitle).not.toBe('');
|
||
|
||
await stations.first().click();
|
||
await expect(stations.first()).toHaveClass(/active/, { timeout: 20_000 });
|
||
const audioPlayer = page.locator('app-audio-player');
|
||
await expect(audioPlayer).toBeVisible({ timeout: 20_000 });
|
||
|
||
await categories.nth(2).click();
|
||
|
||
// The sidebar re-filters to the new category (proves the click landed and
|
||
// change detection ran)…
|
||
await expect(sidebarTitle).not.toHaveText(firstCategoryTitle, {
|
||
timeout: 20_000,
|
||
});
|
||
await expect(stations.first()).toBeVisible({ timeout: 20_000 });
|
||
// …while the station picked from the previous category keeps playing.
|
||
await expect(audioPlayer).toBeVisible();
|
||
});
|
||
|
||
test('@stalker PWA skips bulk EPG across channel switches', async ({
|
||
page,
|
||
}) => {
|
||
await addStalkerPortal(page);
|
||
|
||
const epgInfoRequests: string[] = [];
|
||
const shortEpgRequests: string[] = [];
|
||
page.on('request', (request) => {
|
||
if (request.url().includes('action=get_epg_info')) {
|
||
epgInfoRequests.push(request.url());
|
||
}
|
||
if (request.url().includes('action=get_short_epg')) {
|
||
shortEpgRequests.push(request.url());
|
||
}
|
||
});
|
||
|
||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||
await page.waitForURL(/stalker.*itv/);
|
||
|
||
const categories = page.locator('.category-item');
|
||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||
await categories.nth(1).click();
|
||
|
||
const channels = page.locator('[data-test-id="channel-item"]');
|
||
await expect(channels.nth(1)).toBeVisible({ timeout: 20_000 });
|
||
expect(shortEpgRequests).toHaveLength(0);
|
||
|
||
await channels.first().click();
|
||
await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
|
||
await expect(page.locator('app-epg-timeline')).toHaveCount(0);
|
||
|
||
await channels.nth(1).click();
|
||
await expect(channels.nth(1)).toHaveClass(/active/, { timeout: 20_000 });
|
||
await expect(page.locator('app-epg-timeline')).toHaveCount(0);
|
||
expect(epgInfoRequests).toHaveLength(0);
|
||
expect(shortEpgRequests).toHaveLength(0);
|
||
});
|
||
|
||
test('@stalker ITV full channel list loads via get_all_channels and search covers it', async ({
|
||
page,
|
||
}) => {
|
||
await addStalkerPortal(page);
|
||
|
||
const allChannelsRequests: string[] = [];
|
||
page.on('request', (request) => {
|
||
if (request.url().includes('action=get_all_channels')) {
|
||
allChannelsRequests.push(request.url());
|
||
}
|
||
});
|
||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||
await page.waitForURL(/stalker.*itv/);
|
||
|
||
const categories = page.locator('.category-item');
|
||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||
|
||
// BEFORE any category click (Xtream parity): entering the Live TV section
|
||
// preloads the full list, so the main area shows the paginated
|
||
// all-channels grid and the categories already carry count badges.
|
||
const allItemsGrid = page.locator('app-stalker-itv-all-items');
|
||
await expect(allItemsGrid.locator('mat-card').first()).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
// The grid is an infinite-scroll window over the cached full list — no
|
||
// paginator; the subtitle reports the complete channel count.
|
||
await expect(allItemsGrid.locator('mat-paginator')).toHaveCount(0);
|
||
await expect(allItemsGrid.locator('.category-subtitle')).toContainText(
|
||
'320'
|
||
);
|
||
await expect(categories.nth(0).locator('.item-count')).toHaveText('320', {
|
||
timeout: 10_000,
|
||
});
|
||
await expect(categories.nth(1).locator('.item-count')).toHaveText('40');
|
||
|
||
// Starting from All Items leaves the category unset. Its fullscreen
|
||
// list must still show and window the full cache without a search term.
|
||
await allItemsGrid.locator('mat-card').first().click();
|
||
const playerView = page.locator('app-web-player-view');
|
||
await expect(playerView).toBeVisible();
|
||
await playerView.hover();
|
||
await playerView.getByRole('button', { name: 'Enter fullscreen' }).click();
|
||
await expect(
|
||
playerView.getByRole('button', { name: 'Exit fullscreen' })
|
||
).toBeVisible();
|
||
await page
|
||
.locator('[data-test-id="fullscreen-channel-panel-hot-zone"]')
|
||
.hover();
|
||
const panel = page.locator('[data-test-id="fullscreen-channel-panel"]');
|
||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||
const panelRows = panel.locator('[data-test-id="channel-item"]');
|
||
await expect(panelRows).toHaveCount(100);
|
||
await panel.locator('.channels-list').evaluate((element) => {
|
||
element.scrollTop = element.scrollHeight;
|
||
});
|
||
await expect(panelRows).toHaveCount(200);
|
||
await page.evaluate(() => document.exitFullscreen());
|
||
await expect
|
||
.poll(() => page.evaluate(() => !!document.fullscreenElement))
|
||
.toBe(false);
|
||
|
||
await categories.nth(1).click();
|
||
|
||
const channels = page.locator(
|
||
'#live-channels [data-test-id="channel-item"]'
|
||
);
|
||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||
|
||
// Regression for "search only finds the first 14 loaded items": once the
|
||
// full list is cached, the whole category (40 channels) is available
|
||
// without scrolling through 14-item pages.
|
||
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
|
||
timeout: 20_000,
|
||
});
|
||
await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0);
|
||
const firstCategoryNames = await channels
|
||
.locator('.channel-name')
|
||
.allTextContents();
|
||
|
||
// Regression: switching to another category once the full list is cached
|
||
// must serve that category from the cache, not get stuck on an empty
|
||
// skeleton. (The reset-on-category-change effect used to clobber the
|
||
// synchronously served list.)
|
||
await categories.nth(2).click();
|
||
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
|
||
timeout: 20_000,
|
||
});
|
||
await expect(channels.first()).toBeVisible({ timeout: 10_000 });
|
||
// No further get_all_channels request — it's served from the session cache.
|
||
const requestsAfterFirstCategory = allChannelsRequests.length;
|
||
await categories.nth(3).click();
|
||
await expect(channels.first()).toBeVisible({ timeout: 10_000 });
|
||
expect(allChannelsRequests.length).toBe(requestsAfterFirstCategory);
|
||
|
||
// Back to the first category for the search assertions below.
|
||
await categories.nth(1).click();
|
||
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
|
||
timeout: 20_000,
|
||
});
|
||
|
||
// Counts are identical across categories; wait for the actual category
|
||
// rows before choosing a search term, or it may come from the previous one.
|
||
await expect(channels.locator('.channel-name')).toHaveText(
|
||
firstCategoryNames
|
||
);
|
||
|
||
// Search a channel from deep in the list (beyond the first 14 items).
|
||
const deepChannelName = (
|
||
await channels.nth(30).locator('.channel-name').textContent()
|
||
)?.trim();
|
||
expect(deepChannelName).toBeTruthy();
|
||
|
||
const searchInput = page.locator('input[type="search"]');
|
||
await searchInput.fill(deepChannelName as string);
|
||
await searchInput.press('Enter');
|
||
|
||
await expect(
|
||
page
|
||
.locator('[data-test-id="channel-item"] .channel-name')
|
||
.filter({ hasText: deepChannelName as string })
|
||
.first()
|
||
).toBeVisible({ timeout: 10_000 });
|
||
// The "loaded only" degraded-search hint must be gone in full-list mode.
|
||
await expect(page.locator('.search-chip--status')).toHaveCount(0);
|
||
await verifyStalkerCategorySearch(page);
|
||
});
|
||
|
||
test('@stalker ITV censored category pages from the portal and hides its badge', async ({
|
||
page,
|
||
}) => {
|
||
await addStalkerPortal(page);
|
||
|
||
const adultListRequests: string[] = [];
|
||
page.on('request', (request) => {
|
||
const url = request.url();
|
||
if (
|
||
url.includes('action=get_ordered_list') &&
|
||
url.includes('type=itv') &&
|
||
url.includes('genre=1099')
|
||
) {
|
||
adultListRequests.push(url);
|
||
}
|
||
});
|
||
|
||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||
await page.waitForURL(/stalker.*itv/);
|
||
|
||
const categories = page.locator('.category-item');
|
||
// Wait until the full list is cached (a regular category shows its badge).
|
||
await expect(categories.nth(1).locator('.item-count')).toHaveText('40', {
|
||
timeout: 20_000,
|
||
});
|
||
|
||
// The censored genre is excluded from get_all_channels, so its real count
|
||
// is unknown — no badge instead of a misleading "0".
|
||
const adultCategory = page.locator('.category-item', {
|
||
hasText: 'For adults',
|
||
});
|
||
await expect(adultCategory).toBeVisible();
|
||
await expect(adultCategory.locator('.item-count')).toHaveCount(0);
|
||
|
||
// Clicking it falls back to the legacy paged flow and still shows channels.
|
||
await adultCategory.click();
|
||
const channels = page.locator('[data-test-id="channel-item"]');
|
||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||
await expect.poll(() => adultListRequests.length).toBeGreaterThan(0);
|
||
await verifyUncachedStalkerSearch(page, MOCK_SERVER);
|
||
});
|
||
|
||
test('@stalker ITV falls back to page crawling on portals without get_all_channels', async ({
|
||
page,
|
||
}) => {
|
||
await addStalkerPortal(page, {
|
||
name: 'Legacy Stalker Portal',
|
||
mac: LEGACY_PAGINATION_MAC,
|
||
});
|
||
|
||
const allChannelsRequests: string[] = [];
|
||
const crawlRequests: string[] = [];
|
||
page.on('request', (request) => {
|
||
const url = request.url();
|
||
if (url.includes('action=get_all_channels')) {
|
||
allChannelsRequests.push(url);
|
||
}
|
||
// The full-list crawl pages through ALL genres (genre=*).
|
||
if (
|
||
url.includes('action=get_ordered_list') &&
|
||
url.includes('type=itv') &&
|
||
(url.includes('genre=*') || url.includes('genre=%2A'))
|
||
) {
|
||
crawlRequests.push(url);
|
||
}
|
||
});
|
||
|
||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||
await page.waitForURL(/stalker.*itv/);
|
||
|
||
const categories = page.locator('.category-item');
|
||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||
await categories.nth(1).click();
|
||
|
||
const channels = page.locator('[data-test-id="channel-item"]');
|
||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||
|
||
// The crawl collects all 6 × 40 channels; the selected category then
|
||
// shows its full 40 items without manual lazy-load scrolling.
|
||
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
|
||
timeout: 30_000,
|
||
});
|
||
await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0);
|
||
expect(crawlRequests.length).toBeGreaterThan(1);
|
||
});
|
||
|
||
test('@stalker mock server reset clears cached state', async ({ request }) => {
|
||
// Generate data for default MAC
|
||
const before = await request.get(
|
||
`${MOCK_SERVER}/stalker?action=get_categories&type=vod&macAddress=${DEFAULT_MAC}`
|
||
);
|
||
expect(before.ok()).toBeTruthy();
|
||
|
||
// Scoped reset — a global one would clear MACs owned by parallel specs.
|
||
const reset = await request.post(
|
||
`${MOCK_SERVER}/reset?macAddress=${encodeURIComponent(DEFAULT_MAC)}`
|
||
);
|
||
expect(reset.ok()).toBeTruthy();
|
||
expect((await reset.json()).macs).toEqual([DEFAULT_MAC]);
|
||
|
||
// Data is regenerated identically (deterministic seed)
|
||
const after = await request.get(
|
||
`${MOCK_SERVER}/stalker?action=get_categories&type=vod&macAddress=${DEFAULT_MAC}`
|
||
);
|
||
const beforeBody = (await before.json()).payload.js;
|
||
const afterBody = (await after.json()).payload.js;
|
||
expect(afterBody).toEqual(beforeBody);
|
||
});
|
||
|
||
test('@stalker create_link returns a playable stream URL', async ({
|
||
request,
|
||
}) => {
|
||
const response = await request.get(
|
||
`${MOCK_SERVER}/stalker?action=create_link&cmd=ffrt4://vod/20001/index.m3u8&macAddress=${DEFAULT_MAC}`
|
||
);
|
||
expect(response.ok()).toBeTruthy();
|
||
const body = await response.json();
|
||
const streamUrl: string = body.payload.js.cmd;
|
||
expect(streamUrl).toMatch(/^https?:\/\//);
|
||
expect(streamUrl).toMatch(/\.m3u8$/);
|
||
});
|
||
|
||
/**
|
||
* Play the first channel of the first ITV category and report every
|
||
* `create_link` request the page made while doing so.
|
||
*/
|
||
test('@stalker ITV plays an unflagged channel without minting a link', async ({
|
||
page,
|
||
}) => {
|
||
// The reference client only calls create_link when the row sets
|
||
// use_http_tmp_link or use_load_balancing; this portal sets neither, so
|
||
// the static cmd must reach the player untouched. The companion test
|
||
// below proves the recorder does see a create_link when one is due.
|
||
await addStalkerPortal(page, {
|
||
name: 'Static Cmd Portal',
|
||
mac: STATIC_CMD_MAC,
|
||
});
|
||
|
||
expect(await playFirstItvChannel(page)).toEqual([]);
|
||
});
|
||
|
||
test('@stalker ITV mints a link for a channel that asks for one', async ({
|
||
page,
|
||
}) => {
|
||
await addStalkerPortal(page, { name: 'Tmp Link Portal' });
|
||
|
||
const createLinkRequests = await playFirstItvChannel(page);
|
||
|
||
expect(createLinkRequests.length).toBeGreaterThan(0);
|
||
expect(createLinkRequests[0]).toContain('type=itv');
|
||
});
|
||
|
||
test('@stalker mock server returns radio categories and stations', async ({
|
||
request,
|
||
}) => {
|
||
const categoriesResponse = await request.get(
|
||
`${MOCK_SERVER}/stalker?action=get_categories&type=radio&macAddress=${DEFAULT_MAC}`
|
||
);
|
||
expect(categoriesResponse.ok()).toBeTruthy();
|
||
const categoriesBody = await categoriesResponse.json();
|
||
expect(categoriesBody.payload.js.length).toBeGreaterThan(0);
|
||
|
||
const firstCategory = categoriesBody.payload.js[0].id;
|
||
const stationsResponse = await request.get(
|
||
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=radio&category=${firstCategory}&p=1&macAddress=${DEFAULT_MAC}&JsHttpRequest=1-xml`
|
||
);
|
||
expect(stationsResponse.ok()).toBeTruthy();
|
||
const stationsBody = await stationsResponse.json();
|
||
const firstStation = stationsBody.payload.js.data[0];
|
||
expect(firstStation).toEqual(
|
||
expect.objectContaining({
|
||
category_id: firstCategory,
|
||
radio: true,
|
||
})
|
||
);
|
||
});
|
||
|
||
test('@stalker favorites — embedded-series favorite refreshes newly released episodes', async ({
|
||
page,
|
||
request,
|
||
}) => {
|
||
// Find an embedded-series VOD item in the mock catalog first
|
||
const { embeddedItem, episodeCount } =
|
||
await findEmbeddedSeriesItem(request);
|
||
|
||
await addStalkerPortal(page, {
|
||
name: 'Embedded Series Portal',
|
||
mac: EMBEDDED_SERIES_MAC,
|
||
});
|
||
|
||
// Open the embedded-series item from its category and favorite it —
|
||
// this persists a snapshot with the current episode list
|
||
await openEmbeddedSeriesItem(page, embeddedItem.name, episodeCount);
|
||
await page.getByRole('button', { name: 'Add to favorites' }).click();
|
||
// Wait for the async favorite persistence before navigating away
|
||
await expect(
|
||
page.getByRole('button', { name: 'Remove from favorites' })
|
||
).toBeVisible({ timeout: 10_000 });
|
||
|
||
// From now on the portal has "released" one more episode: extend
|
||
// series[] in every search response (the background snapshot refresh
|
||
// re-fetches the item via a title search)
|
||
await releaseExtraEpisodeInSearchResponses(page);
|
||
|
||
// Open the item from the Favorites view: the stored snapshot renders
|
||
// first, then the background refresh patches in the new episode
|
||
const favoritesUrl = new URL(page.url());
|
||
favoritesUrl.pathname = favoritesUrl.pathname.replace(
|
||
/\/vod.*$/,
|
||
'/favorites'
|
||
);
|
||
favoritesUrl.search = '';
|
||
await page.goto(favoritesUrl.toString());
|
||
|
||
const favoriteCard = page.getByText(embeddedItem.name).first();
|
||
await expect(favoriteCard).toBeVisible({ timeout: 10_000 });
|
||
await favoriteCard.click();
|
||
|
||
// Snapshot episodes are visible immediately…
|
||
await expect(
|
||
page.getByRole('heading', { name: 'Episode 1', exact: true })
|
||
).toBeVisible({ timeout: 10_000 });
|
||
// …and the newly released episode appears after the background refresh
|
||
await expect(
|
||
page.getByRole('heading', {
|
||
name: `Episode ${episodeCount + 1}`,
|
||
exact: true,
|
||
})
|
||
).toBeVisible({ timeout: 10_000 });
|
||
});
|
||
|
||
test('@stalker title season markers align lazy VOD labels, TMDB episodes and watched state', async ({
|
||
page,
|
||
}) => {
|
||
await verifyStalkerSeasonMarkers(page, () =>
|
||
addStalkerPortal(page, { name: 'Season Marker Portal' })
|
||
);
|
||
});
|
||
|
||
test('@stalker season watched toggle — embedded series marks and clears every episode', async ({
|
||
page,
|
||
request,
|
||
}, testInfo) => {
|
||
// Reuse the modeled embedded-series flow: find a VOD item carrying an
|
||
// embedded series[] array, open it from its category, and land on the
|
||
// series detail with its episode list.
|
||
const { embeddedItem, episodeCount } =
|
||
await findEmbeddedSeriesItem(request);
|
||
|
||
await addStalkerPortal(page, {
|
||
name: 'Embedded Series Watch Portal',
|
||
mac: EMBEDDED_SERIES_MAC,
|
||
});
|
||
|
||
await openEmbeddedSeriesItem(page, embeddedItem.name, episodeCount);
|
||
|
||
await expectSeriesSurfacesInBothThemes(page, testInfo);
|
||
|
||
// The hero menu's bulk toggle (data-test-id with a dash — getByTestId
|
||
// only matches data-testid in this suite) counts every unwatched episode.
|
||
let seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
|
||
await expect(seasonToggle).toContainText(
|
||
`Mark season as watched (${episodeCount})`
|
||
);
|
||
const watchedCards = page.locator('.episode-item--watched');
|
||
await expect(watchedCards).toHaveCount(0);
|
||
|
||
await seasonToggle.click();
|
||
|
||
// All episodes get full-progress positions: the row flips and every
|
||
// episode card plus per-episode toggle shows the watched state.
|
||
await expect(watchedCards).toHaveCount(episodeCount, { timeout: 15_000 });
|
||
await expect(
|
||
page.locator(
|
||
'[data-testid="episode-watched-toggle"].episode-item__watched-toggle--watched'
|
||
)
|
||
).toHaveCount(episodeCount);
|
||
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
|
||
await expect(seasonToggle).toContainText('Mark season as unwatched');
|
||
|
||
// Second click clears every episode's position again.
|
||
await seasonToggle.click();
|
||
await expect(watchedCards).toHaveCount(0, { timeout: 15_000 });
|
||
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
|
||
await expect(seasonToggle).toContainText(
|
||
`Mark season as watched (${episodeCount})`
|
||
);
|
||
await closeSeriesMenu(page);
|
||
});
|
||
|
||
test('@stalker series watched toggle — embedded series marks and clears from the header menu', async ({
|
||
page,
|
||
request,
|
||
}) => {
|
||
// Same modeled embedded-series flow as the season test above, driven
|
||
// through the series-level ⋮ menu instead of the season button.
|
||
const { embeddedItem, episodeCount } =
|
||
await findEmbeddedSeriesItem(request);
|
||
|
||
await addStalkerPortal(page, {
|
||
name: 'Embedded Series Watch Menu Portal',
|
||
mac: EMBEDDED_SERIES_MAC,
|
||
});
|
||
|
||
const card = await openEmbeddedSeriesItem(
|
||
page,
|
||
embeddedItem.name,
|
||
episodeCount
|
||
);
|
||
|
||
// The series row sits in the hero's "…" menu (data-test-id with a dash —
|
||
// getByTestId only matches data-testid in this suite; the row renders
|
||
// into the CDK overlay).
|
||
let seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
|
||
await expect(seriesToggle).toContainText(
|
||
`Mark series as watched (${episodeCount})`
|
||
);
|
||
await seriesToggle.click();
|
||
|
||
// Every episode of the embedded season flips to watched and the action
|
||
// becomes unwatch-all.
|
||
const watchedCards = page.locator('.episode-item--watched');
|
||
await expect(watchedCards).toHaveCount(episodeCount, {
|
||
timeout: 15_000,
|
||
});
|
||
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
|
||
await expect(seriesToggle).toContainText('Mark series as unwatched');
|
||
await seriesToggle.click();
|
||
|
||
await expect(watchedCards).toHaveCount(0, { timeout: 15_000 });
|
||
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
|
||
await expect(seriesToggle).toContainText(
|
||
`Mark series as watched (${episodeCount})`
|
||
);
|
||
// The menu owns the first Escape; inline Stalker detail owns the next.
|
||
const detailUrl = page.url();
|
||
await page.keyboard.press('Escape');
|
||
await expect(seriesToggle).toBeHidden();
|
||
await page.locator('[data-testid="series-more-menu"]').focus();
|
||
await page.keyboard.press('Escape');
|
||
await expect(page.locator('app-portal-detail-shell')).toHaveCount(0);
|
||
await expect(page).toHaveURL(detailUrl);
|
||
await expect(card).toBeVisible();
|
||
});
|
||
|
||
test('@stalker series — seasons load for a series item', async ({
|
||
request,
|
||
}) => {
|
||
// First fetch a series item to get its ID
|
||
const listResponse = await request.get(
|
||
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=series&category=3001&p=1&macAddress=${DEFAULT_MAC}&JsHttpRequest=1-xml`
|
||
);
|
||
const listBody = await listResponse.json();
|
||
const firstItem = listBody.payload.js.data[0];
|
||
expect(firstItem).toBeDefined();
|
||
|
||
// Fetch seasons for the first series item
|
||
const seasonsResponse = await request.get(
|
||
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=series&movie_id=${firstItem.id}&macAddress=${DEFAULT_MAC}`
|
||
);
|
||
const seasonsBody = await seasonsResponse.json();
|
||
const seasons = seasonsBody.payload.js;
|
||
expect(Array.isArray(seasons)).toBeTruthy();
|
||
// Default scenario has 3 seasons per series
|
||
expect(seasons.length).toBe(3);
|
||
expect(seasons[0].name).toBe('Season 1');
|
||
expect(Array.isArray(seasons[0].series)).toBeTruthy();
|
||
// Default scenario has 8 episodes per season
|
||
expect(seasons[0].series.length).toBe(8);
|
||
});
|
||
|
||
/**
|
||
* Full-portal authentication. The tests above import through the tolerant
|
||
* `/portal.php` alias (simple portal, no auth); these use the canonical
|
||
* Ministra endpoint, which the mock guards like the real middleware:
|
||
*
|
||
* - every action except handshake/get_profile/get_localization/do_auth needs
|
||
* `Authorization: Bearer <token>`
|
||
* - a token only counts once `get_profile` has adopted it
|
||
* - auth failures come back as HTTP 200 with a plain-text body, never a 401
|
||
*/
|
||
test.describe('@stalker full portal authentication', () => {
|
||
// Importing a full portal costs a handshake, a profile call and the first
|
||
// content load — on a cold dev server that alone approaches Playwright's
|
||
// 30s default budget.
|
||
test.beforeEach(() => {
|
||
test.setTimeout(90_000);
|
||
});
|
||
|
||
test('handshakes and authenticates before loading content', async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const requests = recordPortalRequests(page);
|
||
const actionsInOrder = () => requests.map((entry) => entry.action);
|
||
const mac = getStatefulAuthMacs(testInfo).authenticatedFlow;
|
||
|
||
await addFullStalkerPortal(page, { mac });
|
||
|
||
// The portal only answers content actions for an adopted token, so
|
||
// reaching the VOD categories at all proves the whole chain ran.
|
||
await expect(page.locator('.category-item').first()).toBeVisible({
|
||
timeout: 30_000,
|
||
});
|
||
|
||
// Endpoint discovery classifies the portal with a token-less
|
||
// get_genres probe BEFORE any authentication: the plain-text
|
||
// "Authorization failed." answer is what proves this endpoint
|
||
// enforces the token, so the probe must precede the handshake.
|
||
const probeIndex = requests.findIndex(
|
||
(entry) => entry.action === 'get_genres'
|
||
);
|
||
expect(probeIndex).toBeGreaterThanOrEqual(0);
|
||
expect(requests[probeIndex].token).toBeFalsy();
|
||
|
||
const actions = actionsInOrder();
|
||
expect(actions).toContain('handshake');
|
||
expect(actions).toContain('get_profile');
|
||
expect(probeIndex).toBeLessThan(actions.indexOf('handshake'));
|
||
expect(actions.indexOf('handshake')).toBeLessThan(
|
||
actions.indexOf('get_profile')
|
||
);
|
||
|
||
// The first authenticated content request comes after get_profile
|
||
// and must carry the adopted token; the handshake must not.
|
||
const contentEntry = requests
|
||
.slice(actions.indexOf('get_profile') + 1)
|
||
.find((entry) => CONTENT_ACTIONS.includes(entry.action));
|
||
expect(contentEntry).toBeDefined();
|
||
expect(contentEntry?.token).toBeTruthy();
|
||
expect(
|
||
requests.find((entry) => entry.action === 'handshake')?.token
|
||
).toBeFalsy();
|
||
|
||
// The full-portal workflow must also keep the watchdog alive — an
|
||
// authenticated get_events fires immediately (init=1) on activation.
|
||
// Without this assertion the suite would stay green if the watchdog
|
||
// wiring silently died, because its failures are swallowed by design.
|
||
await expect
|
||
.poll(
|
||
() =>
|
||
requests.some(
|
||
(entry) => entry.action === 'get_events' && entry.token
|
||
),
|
||
{ timeout: 30_000 }
|
||
)
|
||
.toBe(true);
|
||
});
|
||
|
||
test('completes the documented login flow behind get_profile status 2', async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const requests = recordPortalRequests(page);
|
||
const mac = getStatefulAuthMacs(testInfo).loginRequired;
|
||
// The second auth step must only be claimed on the retry AFTER
|
||
// do_auth — a client that always sends auth_second_step=1 works
|
||
// against the mock but violates the documented protocol.
|
||
const profileSteps: string[] = [];
|
||
page.on('request', (request) => {
|
||
const url = new URL(request.url());
|
||
if (!url.pathname.endsWith('/stalker')) {
|
||
return;
|
||
}
|
||
if (url.searchParams.get('action') !== 'get_profile') {
|
||
return;
|
||
}
|
||
profileSteps.push(url.searchParams.get('auth_second_step') ?? '');
|
||
});
|
||
|
||
await addFullStalkerPortal(page, {
|
||
mac,
|
||
username: 'user',
|
||
password: 'secret',
|
||
});
|
||
|
||
// The mock only adopts the token (and answers content) after do_auth
|
||
// completed with non-empty credentials, so rendered categories prove
|
||
// the whole status 2 -> do_auth -> profile retry chain ran.
|
||
await expect(page.locator('.category-item').first()).toBeVisible({
|
||
timeout: 30_000,
|
||
});
|
||
|
||
const actions = requests.map((entry) => entry.action);
|
||
expect(actions).toContain('do_auth');
|
||
expect(actions.indexOf('get_profile')).toBeLessThan(
|
||
actions.indexOf('do_auth')
|
||
);
|
||
expect(actions.lastIndexOf('get_profile')).toBeGreaterThan(
|
||
actions.indexOf('do_auth')
|
||
);
|
||
|
||
expect(profileSteps[0]).toBe('0');
|
||
expect(profileSteps).toContain('1');
|
||
|
||
// Content only flows once the token is adopted, which the mock does
|
||
// only after do_auth completed. Look AFTER the last get_profile:
|
||
// discovery's classification probe is itself a token-less
|
||
// `get_genres`, so the first CONTENT_ACTIONS hit is that probe.
|
||
const contentRequest = requests
|
||
.slice(actions.lastIndexOf('get_profile') + 1)
|
||
.find((entry) => CONTENT_ACTIONS.includes(entry.action));
|
||
expect(contentRequest).toBeDefined();
|
||
expect(contentRequest?.token).toBeTruthy();
|
||
});
|
||
|
||
test('explains a login-required portal and recovers once credentials are entered', async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const mac = getStatefulAuthMacs(testInfo).loginRequired;
|
||
// First attempt without credentials: the import must fail with the
|
||
// portal's actual reason, not a generic "check URL and MAC" error.
|
||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||
const dialog = page.locator('mat-dialog-container');
|
||
await expect(dialog).toBeVisible();
|
||
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
|
||
|
||
await setInputValue(dialog.locator('input#title'), 'Login Portal');
|
||
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
|
||
await setInputValue(dialog.locator('input#macAddress'), mac);
|
||
|
||
const addButton = dialog.getByRole('button', {
|
||
name: 'Add playlist',
|
||
exact: true,
|
||
});
|
||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||
await addButton.click();
|
||
|
||
// Inline under the portal URL, like the Xtream connection test.
|
||
await expect(dialog.getByRole('status')).toContainText(
|
||
/requires a login and password/i,
|
||
{ timeout: 15_000 }
|
||
);
|
||
// The dialog stays open so the user can add the credentials.
|
||
await expect(dialog).toBeVisible();
|
||
|
||
// Second attempt with credentials in the same dialog succeeds.
|
||
await setInputValue(dialog.locator('input#username'), 'user');
|
||
await setInputValue(dialog.locator('input#password'), 'secret');
|
||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||
await addButton.click();
|
||
await expect(dialog).toBeHidden({ timeout: 30_000 });
|
||
|
||
await page.waitForURL(/stalker.*vod/, { timeout: 30_000 });
|
||
await expect(page.locator('.category-item').first()).toBeVisible({
|
||
timeout: 30_000,
|
||
});
|
||
});
|
||
|
||
test('reuses the persisted token after a reload instead of re-running get_profile', async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const requests = recordPortalRequests(page);
|
||
const mac = getStatefulAuthMacs(testInfo).tokenReuse;
|
||
|
||
await addFullStalkerPortal(page, { mac });
|
||
|
||
await expect
|
||
.poll(
|
||
() =>
|
||
requests.some(
|
||
(entry) =>
|
||
CONTENT_ACTIONS.includes(entry.action) &&
|
||
entry.token
|
||
),
|
||
{ timeout: 30_000 }
|
||
)
|
||
.toBe(true);
|
||
|
||
const sessionToken = requests.find(
|
||
(entry) => CONTENT_ACTIONS.includes(entry.action) && entry.token
|
||
)?.token;
|
||
expect(sessionToken).toBeTruthy();
|
||
|
||
const requestCountBeforeReload = requests.length;
|
||
await page.reload();
|
||
|
||
// Wait on the UI rather than the request log: a reload restores the
|
||
// route, boots the app and re-authenticates before the first content
|
||
// request goes out, and rendered categories prove that whole chain
|
||
// finished (the portal only answers content for an adopted token).
|
||
await expect(page.locator('.category-item').first()).toBeVisible({
|
||
timeout: 60_000,
|
||
});
|
||
|
||
const afterReload = requests.slice(requestCountBeforeReload);
|
||
|
||
// Content came back under the SAME token, negotiated by re-presenting
|
||
// the persisted one in the handshake.
|
||
expect(
|
||
afterReload.filter(
|
||
(entry) =>
|
||
CONTENT_ACTIONS.includes(entry.action) &&
|
||
entry.token === sessionToken
|
||
).length
|
||
).toBeGreaterThan(0);
|
||
|
||
// The handshake re-presented the persisted token...
|
||
const reloadHandshake = afterReload.find(
|
||
(entry) => entry.action === 'handshake'
|
||
);
|
||
expect(reloadHandshake?.token).toBe(sessionToken);
|
||
|
||
// ...and because the portal returned it unchanged (idempotent
|
||
// handshake, still-adopted session), the profile round trip was
|
||
// skipped entirely.
|
||
expect(
|
||
afterReload.filter((entry) => entry.action === 'get_profile')
|
||
).toHaveLength(0);
|
||
});
|
||
|
||
test('refuses a rejected portal at import and never renders its plain-text failure', async ({
|
||
page,
|
||
request,
|
||
}) => {
|
||
// A MAC outside the Infomir OUI makes the strict endpoint answer
|
||
// get_profile with a bare {status:1}, so no token is ever adopted and
|
||
// every content request keeps returning the plain-text failure. Unlike
|
||
// an invalidated session this cannot be repaired by the app's retry,
|
||
// which is what makes the negative assertion below meaningful instead
|
||
// of vacuous.
|
||
const failureBody = await (
|
||
await request.get(
|
||
`${BACKEND_PROXY}?url=${encodeURIComponent(
|
||
FULL_PORTAL_URL
|
||
)}&macAddress=${encodeURIComponent(
|
||
AUTH_REJECTED_MAC
|
||
)}&action=get_categories&type=vod`
|
||
)
|
||
).json();
|
||
expect(failureBody.payload).toBe('Authorization failed.');
|
||
|
||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||
const dialog = page.locator('mat-dialog-container');
|
||
await expect(dialog).toBeVisible();
|
||
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
|
||
|
||
await setInputValue(dialog.locator('input#title'), 'Rejected Portal');
|
||
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
|
||
await setInputValue(
|
||
dialog.locator('input#macAddress'),
|
||
AUTH_REJECTED_MAC
|
||
);
|
||
|
||
const addButton = dialog.getByRole('button', {
|
||
name: 'Add playlist',
|
||
exact: true,
|
||
});
|
||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||
await addButton.click();
|
||
|
||
// `status: 1` means the portal refused this device, so the import must
|
||
// stop there instead of persisting a portal that can never load. (It
|
||
// used to be treated as success whenever the portal sent no msg text,
|
||
// which imported a dead source.)
|
||
await expect(dialog.getByRole('status')).toContainText(
|
||
/refused access/i,
|
||
{ timeout: 15_000 }
|
||
);
|
||
await expect(dialog).toBeVisible();
|
||
await expect(page).not.toHaveURL(/stalker/);
|
||
|
||
// And the raw portal response is never painted as UI. A portal answers
|
||
// auth failures with HTTP 200 + plain text, so an app that trusts the
|
||
// status code would happily render these strings.
|
||
await expect(page.locator('body')).not.toContainText(
|
||
'Authorization failed.'
|
||
);
|
||
await expect(page.locator('body')).not.toContainText(
|
||
'Unauthorized request.'
|
||
);
|
||
});
|
||
|
||
test('explains a device conflict instead of relaying "STB is damaged"', async ({
|
||
page,
|
||
request,
|
||
}, testInfo) => {
|
||
const mac = getStatefulAuthMacs(testInfo).deviceConflict;
|
||
// Pin a device id the way another client (StbEmu, a set-top box)
|
||
// would have: the stock server binds the first non-empty `device_id`
|
||
// it sees to the MAC and refuses every different one afterwards.
|
||
const pinned = await (
|
||
await request.get(
|
||
`${BACKEND_PROXY}?url=${encodeURIComponent(
|
||
FULL_PORTAL_URL
|
||
)}&macAddress=${encodeURIComponent(
|
||
mac
|
||
)}&action=get_profile&type=stb&device_id=PINNED-DEVICE-A`
|
||
)
|
||
).json();
|
||
// Guard against a vacuous test: if the pin did not take, the import
|
||
// below would fail for some other reason and still show an error.
|
||
expect(pinned.js?.msg).toBeUndefined();
|
||
|
||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||
const dialog = page.locator('mat-dialog-container');
|
||
await expect(dialog).toBeVisible();
|
||
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
|
||
|
||
await setInputValue(dialog.locator('input#title'), 'Conflict Portal');
|
||
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
|
||
await setInputValue(dialog.locator('input#macAddress'), mac);
|
||
await setInputValue(
|
||
dialog.locator('input#deviceId1'),
|
||
'DIFFERENT-DEVICE-B'
|
||
);
|
||
|
||
const addButton = dialog.getByRole('button', {
|
||
name: 'Add playlist',
|
||
exact: true,
|
||
});
|
||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||
await addButton.click();
|
||
|
||
// The conflict gets its own headline. Asserting the generic one is
|
||
// ABSENT is what makes this test fail if the classification is
|
||
// removed — `blocked` would still surface the portal's text.
|
||
const status = dialog.getByRole('status');
|
||
await expect(status).toContainText(/different device ID registered/i, {
|
||
timeout: 15_000,
|
||
});
|
||
await expect(page.getByText(/refused access/i)).toHaveCount(0);
|
||
// The portal's own words still travel with it, markup stripped.
|
||
await expect(status).toContainText(/device_id mismatch/i);
|
||
await expect(page.locator('body')).not.toContainText('<br/>');
|
||
|
||
await expect(dialog).toBeVisible();
|
||
await expect(page).not.toHaveURL(/stalker/);
|
||
});
|
||
|
||
test('re-authenticates after the portal drops the session', async ({
|
||
page,
|
||
request,
|
||
}, testInfo) => {
|
||
const requests = recordPortalRequests(page);
|
||
const mac = getStatefulAuthMacs(testInfo).reauthentication;
|
||
|
||
await addFullStalkerPortal(page, { mac });
|
||
|
||
// `addFullStalkerPortal` only awaits the route change, and on a slow
|
||
// runner the first authenticated content request has not necessarily
|
||
// gone out by then — poll for it instead of reading the log once.
|
||
await expect
|
||
.poll(
|
||
() =>
|
||
requests.some(
|
||
(entry) =>
|
||
CONTENT_ACTIONS.includes(entry.action) &&
|
||
entry.token
|
||
),
|
||
{ timeout: 30_000 }
|
||
)
|
||
.toBe(true);
|
||
|
||
// The token the initial import authenticated with — recovery must end
|
||
// up on a DIFFERENT one, or nothing was actually re-negotiated.
|
||
const tokenBeforeInvalidation = requests.find(
|
||
(entry) => CONTENT_ACTIONS.includes(entry.action) && entry.token
|
||
)?.token;
|
||
expect(tokenBeforeInvalidation).toBeTruthy();
|
||
|
||
// Server-side session loss is what a real expired/replaced token looks
|
||
// like: the next request gets "Authorization failed." with HTTP 200.
|
||
const invalidated = await request.post(
|
||
`${MOCK_SERVER}/invalidate-session?macAddress=${encodeURIComponent(
|
||
mac
|
||
)}`
|
||
);
|
||
expect(invalidated.ok()).toBe(true);
|
||
|
||
const requestCountBeforeNavigation = requests.length;
|
||
|
||
// Navigating to another content type forces a fresh portal request.
|
||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||
|
||
// Recovery is only proven end to end when a CONTENT request goes out
|
||
// under a freshly negotiated token — a re-handshake alone could still
|
||
// leave the original request unreplayed or unauthorized. The mock only
|
||
// answers content for an adopted token, so this doubles as proof the
|
||
// new token was adopted via get_profile.
|
||
await expect
|
||
.poll(
|
||
() =>
|
||
requests
|
||
.slice(requestCountBeforeNavigation)
|
||
.filter(
|
||
(entry) =>
|
||
CONTENT_ACTIONS.includes(entry.action) &&
|
||
entry.token &&
|
||
entry.token !== tokenBeforeInvalidation
|
||
).length,
|
||
{ timeout: 30_000 }
|
||
)
|
||
.toBeGreaterThan(0);
|
||
|
||
const recovered = requests.slice(requestCountBeforeNavigation);
|
||
expect(
|
||
recovered.filter((entry) => entry.action === 'handshake').length
|
||
).toBeGreaterThan(0);
|
||
|
||
// And the recovered session must actually render: the ITV categories
|
||
// can only come from an authorized request against the new token.
|
||
await expect(page.locator('.category-item').first()).toBeVisible({
|
||
timeout: 15_000,
|
||
});
|
||
|
||
await expect(page.locator('body')).not.toContainText(
|
||
'Authorization failed.'
|
||
);
|
||
});
|
||
});
|