Files
iptvnator/apps/web-e2e/src/xtream.e2e.ts
T
4grayandClaude Opus 5.5 90a31d9be9 feat(sources): redesign the Sources page and the playlist switcher (#1932)
* 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>
2026-10-14 08:06:23 +02:00

1391 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { APIRequestContext, Page } from '@playwright/test';
import { expect, test } from './fixtures';
import {
closeSeriesMenu,
pressTab,
rasterizedBorderContrast,
seriesMenuRow,
setInputValue,
surfaceContrast,
waitForScrollIdle,
} from './e2e-helpers';
import {
addXtreamPortal,
DEFAULT_PASSWORD,
DEFAULT_USERNAME,
interceptXtreamRequests,
MOCK_SERVER,
selectWebPlayer,
} from './xtream-series-playback.fixture';
import {
addCollisionFavorite,
collisionCollectionItem,
collisionItems,
expectCollectionDetailRoundTrip,
expectCollisionCollection,
interceptCollidingXtreamItems,
playCollisionItem,
removeCollisionFavorite,
routeCollisionMedia,
selectCollectionType,
} from './xtream-collection-helpers';
/**
* Xtream Codes E2E Tests
*
* These tests use the xtream-mock-server (apps/xtream-mock-server) to simulate
* a real Xtream Codes API portal. The mock server starts automatically alongside
* the Angular dev server when running e2e tests (see playwright.config.ts).
*
* Default scenario (user1:pass1) provides:
* - 8 categories per content type (live / VOD / series)
* - 40 items per category
* - 3 seasons × 8 episodes per series item
*
* Tag: @xtream — run only Xtream tests with: nx e2e web-e2e --grep "@xtream"
*/
const EPG_USERNAME = 'epg';
const EPG_PASSWORD = 'epg';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function openPlaylistDetailsDialog(page: Page, title: string) {
const playlistSettingsButton = page.getByRole('button', {
name: 'Playlist Settings',
});
if (await playlistSettingsButton.isVisible()) {
await playlistSettingsButton.click();
} else {
await page
.locator('[data-test-id="playlist-switcher-trigger"]')
.click();
const sourceRow = page
.locator('app-playlist-switcher-panel [role="option"]')
.filter({ hasText: title })
.first();
await expect(sourceRow).toBeVisible();
await sourceRow.locator('.source-row__more').click();
await page.getByRole('menuitem', { name: 'Playlist info' }).click();
}
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
return dialog;
}
// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------
test.beforeEach(async ({ page, request }) => {
await request.post(`${MOCK_SERVER}/reset`);
// Playwright creates a fresh browser context per test, so extra
// IndexedDB cleanup here only risks racing with app-managed DB handles.
await page.goto('/');
await interceptXtreamRequests(page);
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test('@xtream 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');
expect(body.server).toBe('xtream-mock-server');
});
test('@xtream get_account_info — active account returns correct fields', async ({
request,
}) => {
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}`
);
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.user_info.username).toBe(DEFAULT_USERNAME);
expect(body.user_info.status).toBe('Active');
expect(body.server_info.url).toBeDefined();
expect(Array.isArray(body.user_info.allowed_output_formats)).toBeTruthy();
});
test('@xtream get_account_info — expired account returns Expired status', async ({
request,
}) => {
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=expired&password=expired`
);
expect(response.ok()).toBeTruthy();
const body = await response.json();
// Expired scenario: still returns 'Active' but exp_date is in the past
// The app's portal-status.service.ts detects expiry via exp_date, not status string
expect(body.user_info.status).toBe('Active');
const expDate = new Date(parseInt(body.user_info.exp_date) * 1000);
expect(expDate.getFullYear()).toBe(2020);
});
test('@xtream get_live_categories — returns expected category count', async ({
request,
}) => {
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_categories`
);
expect(response.ok()).toBeTruthy();
const categories = await response.json();
expect(Array.isArray(categories)).toBeTruthy();
// Default scenario: 8 live categories
expect(categories.length).toBe(8);
expect(categories[0]).toHaveProperty('category_id');
expect(categories[0]).toHaveProperty('category_name');
});
test('@xtream get_live_streams — streams have required fields', async ({
request,
}) => {
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_streams`
);
expect(response.ok()).toBeTruthy();
const streams = await response.json();
expect(Array.isArray(streams)).toBeTruthy();
expect(streams.length).toBeGreaterThan(0);
const first = streams[0];
expect(first.stream_type).toBe('live');
expect(first.stream_id).toBeGreaterThan(0);
expect(first.epg_channel_id).toMatch(/channel-\d+\.mock/);
});
test('@xtream get_vod_streams — streams have rating and container_extension', async ({
request,
}) => {
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_vod_streams`
);
expect(response.ok()).toBeTruthy();
const streams = await response.json();
expect(Array.isArray(streams)).toBeTruthy();
const first = streams[0];
expect(first.stream_type).toBe('movie');
expect(typeof first.rating).toBe('number');
expect(['mkv', 'mp4', 'avi']).toContain(first.container_extension);
});
test('@xtream get_series — series list has correct structure', async ({
request,
}) => {
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series`
);
expect(response.ok()).toBeTruthy();
const series = await response.json();
expect(Array.isArray(series)).toBeTruthy();
expect(series.length).toBeGreaterThan(0);
const first = series[0];
expect(first.series_id).toBeGreaterThan(0);
expect(typeof first.name).toBe('string');
expect(typeof first.cover).toBe('string');
expect(Array.isArray(first.backdrop_path)).toBeTruthy();
});
test('@xtream get_series_info — seasons and episodes present', async ({
request,
}) => {
// Get a series ID first
const listResponse = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series`
);
const series = await listResponse.json();
const firstSeriesId = series[0].series_id;
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_info&series_id=${firstSeriesId}`
);
expect(response.ok()).toBeTruthy();
const info = await response.json();
expect(Array.isArray(info.seasons)).toBeTruthy();
// Default scenario: 3 seasons per series
expect(info.seasons.length).toBe(3);
expect(info.seasons[0].name).toBe('Season 1');
// Episodes are keyed by season number string
expect(info.episodes['1']).toBeDefined();
expect(Array.isArray(info.episodes['1'])).toBeTruthy();
// Default scenario: 8 episodes per season
expect(info.episodes['1'].length).toBe(8);
expect(info.episodes['1'][0].episode_num).toBe(1);
});
test('@xtream get_vod_info — returns full movie details', async ({
request,
}) => {
// Get a VOD stream ID first
const listResponse = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_vod_streams`
);
const streams = await listResponse.json();
const firstVodId = streams[0].stream_id;
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_vod_info&vod_id=${firstVodId}`
);
expect(response.ok()).toBeTruthy();
const details = await response.json();
expect(details.info).toBeDefined();
expect(details.movie_data).toBeDefined();
expect(details.info.duration_secs).toBeGreaterThan(0);
expect(details.movie_data.stream_id).toBe(firstVodId);
});
test('@xtream get_short_epg — returns base64-encoded listings', async ({
request,
}) => {
// Get a live stream ID first
const listResponse = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_streams`
);
const streams = await listResponse.json();
const streamId = streams[0].stream_id;
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_short_epg&stream_id=${streamId}`
);
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(Array.isArray(body.epg_listings)).toBeTruthy();
expect(body.epg_listings.length).toBeGreaterThan(0);
const listing = body.epg_listings[0];
// Verify base64 encoding — decode and check it's valid text
const decodedTitle = Buffer.from(listing.title, 'base64').toString('utf-8');
expect(decodedTitle.length).toBeGreaterThan(0);
expect(listing.start_timestamp).toBeDefined();
expect(listing.stop_timestamp).toBeDefined();
});
test('@xtream epg fixture — short epg starts at the current program, respects limit, and exposes timestamp-vs-string mismatches', async ({
request,
}) => {
const stream = await getEpgFixtureStream(request);
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_short_epg&stream_id=${stream.stream_id}&limit=2`
);
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.epg_listings).toHaveLength(2);
const [currentListing, nextListing] = body.epg_listings;
expect(decodeXtreamText(currentListing.title)).toBe('Global Headlines');
expect(decodeXtreamText(nextListing.title)).toBe('Market Wrap');
const now = Math.floor(Date.now() / 1000);
expect(
Number.parseInt(currentListing.start_timestamp, 10)
).toBeLessThanOrEqual(now);
expect(
Number.parseInt(currentListing.stop_timestamp, 10)
).toBeGreaterThanOrEqual(now);
expect(currentListing.start).not.toBe(
formatXtreamDateTime(
Number.parseInt(currentListing.start_timestamp, 10)
)
);
expect(currentListing.end).not.toBe(
formatXtreamDateTime(Number.parseInt(currentListing.stop_timestamp, 10))
);
});
test('@xtream epg fixture — full epg and legacy alias return the same ordered schedule with a midnight boundary program', async ({
request,
}) => {
const stream = await getEpgFixtureStream(request);
const fullResponse = await request.get(
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_simple_data_table&stream_id=${stream.stream_id}`
);
const aliasResponse = await request.get(
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_simple_date_table&stream_id=${stream.stream_id}`
);
expect(fullResponse.ok()).toBeTruthy();
expect(aliasResponse.ok()).toBeTruthy();
const fullBody = await fullResponse.json();
const aliasBody = await aliasResponse.json();
expect(aliasBody).toEqual(fullBody);
const titles = fullBody.epg_listings.map((listing: XtreamRawEpgListing) =>
decodeXtreamText(listing.title)
);
expect(titles).toEqual([
'Earlier Bulletin',
'Global Headlines',
'Market Wrap',
'Overnight Update',
'Late Edition',
'After Midnight',
]);
const boundaryListing = fullBody.epg_listings.find(
(listing: XtreamRawEpgListing) =>
decodeXtreamText(listing.title) === 'Late Edition'
);
if (!boundaryListing) {
throw new Error(
'Expected the full EPG fixture to include Late Edition.'
);
}
const boundaryStart = new Date(
Number.parseInt(boundaryListing.start_timestamp, 10) * 1000
);
const boundaryStop = new Date(
Number.parseInt(boundaryListing.stop_timestamp, 10) * 1000
);
expect(boundaryStart.getUTCDate()).not.toBe(boundaryStop.getUTCDate());
});
test('@xtream reset — data regenerates identically after reset', async ({
request,
}) => {
const url = `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_categories`;
const before = await (await request.get(url)).json();
await request.post(`${MOCK_SERVER}/reset`);
const after = await (await request.get(url)).json();
// Data is deterministic: same after reset
expect(after).toEqual(before);
});
test('@xtream proxy endpoint — returns payload wrapper', async ({
request,
}) => {
const response = await request.get(
`${MOCK_SERVER}/xtream?url=${MOCK_SERVER}&username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_vod_categories`
);
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.action).toBe('get_vod_categories');
expect(Array.isArray(body.payload)).toBeTruthy();
expect(body.payload.length).toBeGreaterThan(0);
});
test('@xtream category filter — get_live_streams filtered by category_id', async ({
request,
}) => {
// Get categories to find a real ID
const catResponse = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_categories`
);
const categories = await catResponse.json();
const categoryId = categories[0].category_id;
// Fetch streams filtered by that category
const streamResponse = await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_streams&category_id=${categoryId}`
);
const streams = await streamResponse.json();
expect(Array.isArray(streams)).toBeTruthy();
expect(streams.length).toBeGreaterThan(0);
// All returned streams should belong to this category
for (const s of streams) {
expect(String(s.category_id)).toBe(String(categoryId));
}
});
test('@xtream add portal and see it in the playlist list', async ({ page }) => {
await addXtreamPortal(page, { name: 'My Xtream Test Portal' });
await expect(
page.getByText('My Xtream Test Portal', { exact: false })
).toBeVisible();
});
test('@xtream @collections PWA favorites keep colliding movie, series and live IDs independent after reload and removal', async ({
page,
request,
}) => {
test.setTimeout(90_000);
const categories = await interceptCollidingXtreamItems(
page,
request,
MOCK_SERVER
);
await addXtreamPortal(page, {
name: 'Collision Favorites Portal',
username: 'minimal',
password: 'minimal',
});
const playlistPath = new URL(page.url()).pathname.replace(/\/vod.*$/, '');
for (const item of collisionItems) {
await addCollisionFavorite(page, item, categories[item.type]);
}
await page.goto(`${playlistPath}/favorites`);
await expectCollisionCollection(page, collisionItems);
await page.reload();
await expectCollisionCollection(page, collisionItems);
await expectCollectionDetailRoundTrip(page, collisionItems[0]);
await expectCollectionDetailRoundTrip(page, collisionItems[1]);
for (let index = 0; index < collisionItems.length; index++) {
const item = collisionItems[index];
await removeCollisionFavorite(
page,
item,
collisionItems.length - index
);
await expect(collisionCollectionItem(page, item)).toHaveCount(0);
await page.reload();
await expectCollisionCollection(page, collisionItems.slice(index + 1));
}
});
test('@xtream @collections PWA recent items require confirmed playback and retain colliding content IDs', async ({
page,
request,
browserName,
}) => {
test.skip(
browserName !== 'chromium',
'Synthetic live media uses Chromium codecs'
);
test.setTimeout(90_000);
const categories = await interceptCollidingXtreamItems(
page,
request,
MOCK_SERVER
);
const allowPlayback = await routeCollisionMedia(page, MOCK_SERVER);
await selectWebPlayer(page, 'HTML5 video player');
await page.goto('/');
await addXtreamPortal(page, {
name: 'Collision Recent Portal',
username: 'minimal',
password: 'minimal',
});
const playlistPath = new URL(page.url()).pathname.replace(/\/vod.*$/, '');
for (const item of collisionItems) {
const failedMedia = page.waitForResponse(
(response) =>
response.url().startsWith(`${MOCK_SERVER}/${item.type}/`) &&
response.status() === 503
);
await playCollisionItem(page, item, categories[item.type], false);
await failedMedia;
await page.goto(`${playlistPath}/recent`);
await expectCollisionCollection(page, []);
}
allowPlayback();
for (let index = 0; index < collisionItems.length; index++) {
const item = collisionItems[index];
await playCollisionItem(page, item, categories[item.type]);
await page.goto(`${playlistPath}/recent`);
await expectCollisionCollection(
page,
collisionItems.slice(0, index + 1)
);
}
await page.reload();
await expectCollisionCollection(page, collisionItems);
await expectCollectionDetailRoundTrip(page, collisionItems[0]);
await expectCollectionDetailRoundTrip(page, collisionItems[1]);
await selectCollectionType(page, collisionItems[0]);
const movie = collisionCollectionItem(page, collisionItems[0]);
await movie.hover();
await movie.locator('.remove-button').click();
await expect(movie).toHaveCount(0);
await page.reload();
await expectCollisionCollection(page, collisionItems.slice(1));
});
test('@xtream playlist details edit is retained in the PWA browser context', async ({
page,
}) => {
await addXtreamPortal(page, { name: 'Editable PWA Xtream Portal' });
let dialog = await openPlaylistDetailsDialog(
page,
'Editable PWA Xtream Portal'
);
await setInputValue(
dialog.locator('input[formcontrolname="title"]'),
'Edited PWA Xtream Portal'
);
await setInputValue(
dialog.locator('input[formcontrolname="serverUrl"]'),
MOCK_SERVER
);
await setInputValue(
dialog.locator('input[formcontrolname="username"]'),
'minimal'
);
await setInputValue(
dialog.locator('input[formcontrolname="password"]'),
'minimal'
);
await dialog
.locator('mat-dialog-actions')
.getByRole('button', { name: 'Save', exact: true })
.click();
await expect(dialog).toBeHidden();
await expect(
page.getByText('Edited PWA Xtream Portal', { exact: false })
).toBeVisible();
dialog = await openPlaylistDetailsDialog(page, 'Edited PWA Xtream Portal');
await expect(dialog.locator('input[formcontrolname="title"]')).toHaveValue(
'Edited PWA Xtream Portal'
);
await expect(
dialog.locator('input[formcontrolname="serverUrl"]')
).toHaveValue(MOCK_SERVER);
await expect(
dialog.locator('input[formcontrolname="username"]')
).toHaveValue('minimal');
await expect(
dialog.locator('input[formcontrolname="password"]')
).toHaveValue('minimal');
});
test('@xtream minimal scenario — reduced item count', async ({ request }) => {
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=minimal&password=minimal&action=get_live_categories`
);
const categories = await response.json();
// Minimal scenario: 2 categories
expect(categories.length).toBe(2);
const streams = await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=minimal&password=minimal&action=get_live_streams`
)
).json();
// 2 categories × 5 items = 10
expect(streams.length).toBe(10);
});
// ---------------------------------------------------------------------------
// Player engine selection on the live route
//
// The Xtream live layout mounts app-web-player-view WITHOUT a playerOverride,
// so the engine must track the saved player setting live. Regression guarded:
// the engine used to come from a one-shot storage snapshot taken at mount, so
// a command-palette switch confirmed via snackbar and persisted the setting
// while the mounted player silently kept the previous engine.
// ---------------------------------------------------------------------------
async function openLiveChannel(page: Page): Promise<void> {
await page.goto(page.url().replace(/\/vod.*$/, '/live'));
// On the live root the category click updates store state without
// navigating; the channel sidebar appearing is the completion signal.
const firstCategory = page.locator('.context-panel .category-item').first();
await expect(firstCategory).toBeVisible();
await firstCategory.click();
const channel = page
.locator('app-live-stream-layout [data-test-id="channel-item"]')
.first();
await expect(channel).toBeVisible();
await channel.click();
}
test('@xtream command palette player switch reaches the mounted live player', async ({
page,
}) => {
await addXtreamPortal(page);
await openLiveChannel(page);
const playerView = page.locator('app-web-player-view');
await expect(playerView.locator('app-vjs-player')).toBeVisible({
timeout: 15_000,
});
// Tag the mounted layout so the final assertion proves the switch reached
// the EXISTING player instead of surviving through a layout remount.
await page.locator('app-live-stream-layout').evaluate((el) => {
(el as HTMLElement & { __e2eSameMount?: boolean }).__e2eSameMount =
true;
});
await page.keyboard.press('Control+k');
const palette = page.locator('.workspace-command-palette-overlay');
await expect(palette).toBeVisible();
await palette.locator('input[type="search"]').fill('html5');
await palette
.getByRole('button', { name: 'Switch player to HTML5 video player' })
.click();
await expect(playerView.locator('app-html-video-player')).toBeVisible({
timeout: 15_000,
});
await expect(playerView.locator('app-vjs-player')).toHaveCount(0);
expect(
await page
.locator('app-live-stream-layout')
.evaluate(
(el) =>
(el as HTMLElement & { __e2eSameMount?: boolean })
.__e2eSameMount
)
).toBe(true);
});
test('@xtream the saved engine mounts the live player first time — no default-engine flash', async ({
page,
}) => {
// Persist HTML5 as the saved player before any player ever mounts.
await page.goto('/workspace/settings/playback');
await page.locator('[data-test-id="select-video-player"]').click();
await page
.getByRole('option', { name: 'HTML5 video player', exact: true })
.click();
const saveButton = page.getByRole('button', { name: 'Save changes' });
await saveButton.click();
await expect(saveButton).toBeHidden();
await page.goto('/');
await addXtreamPortal(page);
await page.goto(page.url().replace(/\/vod.*$/, '/live'));
const firstCategory = page.locator('.context-panel .category-item').first();
await expect(firstCategory).toBeVisible();
await firstCategory.click();
// Record every engine that is ever attached. A polling assertion cannot
// see the defect this guards: mounting Video.js first and correcting to
// the saved engine once the async settings read lands leaves the same
// final DOM.
await page.evaluate(() => {
const seen = new Set<string>();
(window as unknown as { __enginesSeen: Set<string> }).__enginesSeen =
seen;
const record = () => {
for (const selector of [
'app-vjs-player',
'app-html-video-player',
]) {
if (document.querySelector(selector)) {
seen.add(selector);
}
}
};
record();
new MutationObserver(record).observe(document.body, {
childList: true,
subtree: true,
});
});
const channel = page
.locator('app-live-stream-layout [data-test-id="channel-item"]')
.first();
await expect(channel).toBeVisible();
await channel.click();
await expect(
page.locator('app-web-player-view app-html-video-player')
).toBeVisible({ timeout: 15_000 });
expect(
await page.evaluate(() => [
...(window as unknown as { __enginesSeen: Set<string> })
.__enginesSeen,
])
).toEqual(['app-html-video-player']);
});
// ---------------------------------------------------------------------------
// Season-level watched toggle on the serial details page
//
// The season header exposes a bulk toggle (note: data-test-id with a dash —
// getByTestId only matches data-testid in this suite) that writes
// full-progress playback positions for every unwatched episode of the
// selected season and clears them again on the second click. The PWA
// persists positions to localStorage, so the state must survive a reload.
// ---------------------------------------------------------------------------
test('@xtream season watched toggle — marks a season, survives reload, and clears again', async ({
page,
request,
}) => {
// Resolve a concrete series (category + name) from the mock so the card
// click below targets a known item of the default scenario.
const categories = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
)
).json()) as Array<{ category_id: string; category_name: string }>;
const category = categories[0];
const seriesItems = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
)
).json()) as Array<{ name: string; series_id: number }>;
const targetSeries = seriesItems[0];
await addXtreamPortal(page);
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
const categoryItem = page
.locator('.context-panel .category-item')
.filter({ hasText: category.category_name })
.first();
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
await categoryItem.click();
const seriesCard = page
.locator('app-grid-list mat-card')
.filter({ hasText: targetSeries.name })
.first();
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
await seriesCard.click();
// Serial details: default scenario has 3 seasons × 8 episodes and no
// playback positions yet, so season 1 is auto-selected fully unwatched.
// The season toggle is a row of the hero's "…" menu.
let seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as watched (8)');
await closeSeriesMenu(page);
const episodeCards = page.locator('.episode-item');
await expect(episodeCards).toHaveCount(8, { timeout: 10_000 });
const watchedCards = page.locator('.episode-item--watched');
await expect(watchedCards).toHaveCount(0);
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await seasonToggle.click();
// Full-progress rows land for all 8 episodes: the row flips, every
// episode card gets the watched state, and the per-episode toggles show
// the filled check.
await expect(watchedCards).toHaveCount(8, { timeout: 15_000 });
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as unwatched');
await closeSeriesMenu(page);
await expect(
page.locator(
'[data-testid="episode-watched-toggle"].episode-item__watched-toggle--watched'
)
).toHaveCount(8);
// The selected season's tab shows the completed check; the untouched
// seasons stay unmarked.
const seasonTabs = page.locator('.season-tabs__pill');
await expect(seasonTabs).toHaveCount(3);
await expect(
seasonTabs.first().locator('.season-tabs__done')
).toBeVisible();
await expect(seasonTabs.nth(1).locator('.season-tabs__done')).toHaveCount(
0
);
// PWA persistence: positions live in localStorage, so a reload of the
// detail route must come back fully watched. With season 1 completed the
// fresh mount auto-selects the earliest season with unwatched episodes
// (issue #1441), so season 2 opens while season 1's tab keeps the check.
await page.reload();
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as watched (8)');
await closeSeriesMenu(page);
await expect(watchedCards).toHaveCount(0, { timeout: 10_000 });
await expect(
seasonTabs.first().locator('.season-tabs__done')
).toBeVisible();
// Season 1 itself still holds the 8 persisted rows. The row lives in
// the overlay, so it is reacquired after every close.
await seasonTabs.first().click();
await expect(watchedCards).toHaveCount(8, { timeout: 10_000 });
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
await expect(seasonToggle).toContainText('Mark season as unwatched', {
timeout: 15_000,
});
// 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 (8)', {
timeout: 15_000,
});
await closeSeriesMenu(page);
await expect(page.locator('.season-tabs__done')).toHaveCount(0);
});
// ---------------------------------------------------------------------------
// Season cover beside the season tabs (provider tier)
//
// The default scenario's get_series_info carries a distinct seasons[].cover
// per season, which differs from the show cover, so with TMDB enrichment off
// the serial details page shows that cover next to the tabs and swaps it on
// a tab click. Image requests are answered locally so a slow or blocked CDN
// cannot fold the column (the app hides a cover whose image failed).
// ---------------------------------------------------------------------------
test('@xtream season header — chips, no season cover, synopsis on the number column', async ({
page,
request,
}) => {
const TRANSPARENT_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64'
);
await page.route('**/picsum.photos/**', (route) =>
route.fulfill({ contentType: 'image/png', body: TRANSPARENT_PNG })
);
const categories = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
)
).json()) as Array<{ category_id: string; category_name: string }>;
const category = categories[0];
const seriesItems = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
)
).json()) as Array<{ name: string; series_id: number }>;
const targetSeries = seriesItems[0];
await addXtreamPortal(page);
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
const categoryItem = page
.locator('.context-panel .category-item')
.filter({ hasText: category.category_name })
.first();
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
await categoryItem.click();
const seriesCard = page
.locator('app-grid-list mat-card')
.filter({ hasText: targetSeries.name })
.first();
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
await seriesCard.click();
// The mock seeds season art (`season-<id>-<n>`) and a synopsis per
// season. Three seasons → chips; the art is never shown in the header
// or beside the synopsis.
const description = page.getByTestId('season-description');
await expect(description).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.season-tabs__pill')).toHaveCount(3);
await expect(page.getByTestId('season-dropdown')).toHaveCount(0);
await expect(page.locator('[data-testid="season-cover"]')).toHaveCount(0);
await expect(
page.locator('app-season-container img[src*="/season-"]')
).toHaveCount(0);
// Offset to the episode number column, 24px above the list.
const geometry = await description.evaluate((element) => {
const style = getComputedStyle(element);
const list = element.parentElement?.querySelector('.episodes-list');
return {
paddingLeft: style.paddingLeft,
gapToList: list
? list.getBoundingClientRect().top -
element.getBoundingClientRect().bottom
: null,
};
});
expect(geometry.paddingLeft).toBe('52px');
expect(geometry.gapToList).toBeCloseTo(24, 0);
await page.locator('.season-tabs__pill').nth(1).click();
await expect(description).toBeVisible();
await expect(
page.locator('app-season-container img[src*="/season-"]')
).toHaveCount(0);
});
// ---------------------------------------------------------------------------
// Series-level watched toggle: the season header's ⋮ menu marks EVERY season
// in one action (default scenario: 3 seasons × 8 episodes = 24), flips to
// unwatch-all once the whole series is watched, and survives a reload.
// ---------------------------------------------------------------------------
test('@xtream series watched toggle — marks every season from the header menu, survives reload, and clears again', async ({
page,
request,
}) => {
const categories = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
)
).json()) as Array<{ category_id: string; category_name: string }>;
const category = categories[0];
const seriesItems = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
)
).json()) as Array<{ name: string; series_id: number }>;
const targetSeries = seriesItems[0];
await addXtreamPortal(page);
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
const categoryItem = page
.locator('.context-panel .category-item')
.filter({ hasText: category.category_name })
.first();
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
await categoryItem.click();
const seriesCard = page
.locator('app-grid-list mat-card')
.filter({ hasText: targetSeries.name })
.first();
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
await seriesCard.click();
// 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).
const seasonTabs = page.locator('.season-tabs__pill');
await expect(seasonTabs).toHaveCount(3);
let seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText('Mark series as watched (24)');
await seriesToggle.click();
// One batch marks all 24 episodes: every season tab gets the completed
// check and the visible season's episodes flip to watched.
await expect(page.locator('.season-tabs__done')).toHaveCount(3, {
timeout: 15_000,
});
await expect(page.locator('.episode-item--watched')).toHaveCount(8);
// The action now offers unwatch-all.
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText('Mark series as unwatched');
await closeSeriesMenu(page);
// PWA persistence: positions live in localStorage, so a reload must come
// back fully watched across all seasons.
await page.reload();
await expect(page.locator('[data-testid="series-more-menu"]')).toBeVisible({
timeout: 20_000,
});
await expect(page.locator('.season-tabs__done')).toHaveCount(3, {
timeout: 10_000,
});
// Unwatch-all clears every season again.
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText('Mark series as unwatched');
await seriesToggle.click();
await expect(page.locator('.season-tabs__done')).toHaveCount(0, {
timeout: 15_000,
});
await expect(page.locator('.episode-item--watched')).toHaveCount(0);
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText('Mark series as watched (24)');
});
type XtreamLiveStream = {
category_id: string;
name: string;
stream_id: number;
};
type XtreamRawEpgListing = {
end: string;
start: string;
start_timestamp: string;
stop_timestamp: string;
title: string;
};
test('@xtream posters-only wall — hides VOD titles behind a hover caption after Save', async ({
page,
}) => {
await addXtreamPortal(page);
await page.locator('.context-panel .category-item').first().click();
const grid = page.locator('app-grid-list');
const firstCard = grid.locator('mat-card').first();
await expect(firstCard).toBeVisible({ timeout: 10_000 });
await expect(firstCard.locator('.title')).toBeVisible();
await expect(grid).not.toHaveClass(/grid-list--posters-only/);
const catalogUrl = page.url();
await page.goto('/workspace/settings/general');
const toggle = page.locator('[data-test-id="cover-titles-toggle"]');
await expect(toggle.getByRole('switch')).toBeChecked();
await toggle.click();
const saveButton = page.locator('[data-test-id="save-settings"]');
await saveButton.click();
await expect(saveButton).toBeHidden();
await page.goto(catalogUrl);
await page.locator('.context-panel .category-item').first().click();
await expect(firstCard).toBeVisible({ timeout: 10_000 });
await expect(grid).toHaveClass(/grid-list--posters-only/);
await expect(firstCard.locator('.title')).toHaveCount(0);
// The caption is a hover/focus reveal: hidden at rest, shown when the
// pointer or keyboard focus lands on the card.
const caption = firstCard.locator('.cover-title-overlay');
await expect(caption).toHaveCSS('opacity', '0');
await firstCard.hover();
await expect(caption).toHaveCSS('opacity', '1');
await page.mouse.move(0, 0);
await expect(caption).toHaveCSS('opacity', '0');
await firstCard.focus();
await page.keyboard.press('Tab');
await page.keyboard.press('Shift+Tab');
await expect(firstCard).toBeFocused();
await expect(caption).toHaveCSS('opacity', '1');
});
async function getEpgFixtureStream(
request: APIRequestContext
): Promise<XtreamLiveStream> {
const categories = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_live_categories`
)
).json()) as Array<{ category_id: string; category_name: string }>;
const epgCategory = categories.find(
(category) => category.category_name === 'EPG Focus'
);
if (!epgCategory) {
throw new Error('Expected the EPG fixture category to exist.');
}
const streams = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_live_streams&category_id=${epgCategory.category_id}`
)
).json()) as XtreamLiveStream[];
const stream = streams.find((item) => item.name === 'Timezone News');
if (!stream) {
throw new Error('Expected the EPG fixture stream to exist.');
}
return stream;
}
function decodeXtreamText(value: string): string {
return Buffer.from(value, 'base64').toString('utf-8');
}
function formatXtreamDateTime(timestampSeconds: number): string {
return new Date(timestampSeconds * 1000)
.toISOString()
.replace('T', ' ')
.replace('.000Z', '');
}
for (const theme of ['light', 'dark']) {
test(`@xtream detail surfaces stay visible (${theme})`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await addXtreamPortal(page, {
username: 'marketing',
password: 'marketing',
});
await page.getByRole('link', { name: 'Series', exact: true }).click();
await page.locator('app-grid-list mat-card').first().click();
const shell = page.locator('app-portal-detail-shell');
await expect(shell).toBeVisible();
await page.evaluate(
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
const favorite = shell
.locator('[data-testid="series-favorite-toggle"]')
.first();
// List is the default view: borderless rows whose visible edge is
// the thumbnail's hairline.
const row = shell.locator('.episode-list-item').first();
const rowArtwork = row.locator('.episode-item__thumb');
const toggle = shell.locator('mat-button-toggle-group');
await expect(row).toBeVisible();
await expect(shell.locator('.hero__content')).toHaveCSS('opacity', '1');
await page.mouse.move(0, 0);
// A subtle edge must survive compositing on the actual theme surface.
// This catches white-alpha borders that disappear in the light theme.
await expect
.poll(() => rasterizedBorderContrast(favorite))
.toBeGreaterThan(1.1);
for (const surface of [rowArtwork, toggle]) {
await expect
.poll(async () => (await surfaceContrast(surface)).border)
.toBeGreaterThan(1.15);
}
const selected = toggle.locator('.mat-button-toggle-checked');
await expect
.poll(async () => (await surfaceContrast(selected)).fill)
.toBeGreaterThan(1.1);
await shell.screenshot({
path: testInfo.outputPath(`series-list-${theme}.png`),
animations: 'disabled',
});
// Hover reveals the reserved actions without moving the text.
const title = row.locator('.episode-item__title');
const before = await title.boundingBox();
await row.hover();
await expect(row.locator('.episode-item__actions')).toHaveCSS(
'opacity',
'1'
);
expect(await title.boundingBox()).toEqual(before);
await row.screenshot({
path: testInfo.outputPath(`series-row-hover-${theme}.png`),
animations: 'disabled',
});
await page
.getByRole('radio', { name: 'Grid view', exact: true })
.click();
const card = shell.locator('.episode-card').first();
const artwork = card.locator('.episode-card__thumbnail');
await expect(card).toBeVisible();
await page.mouse.move(0, 0);
await expect
.poll(async () => (await surfaceContrast(artwork)).border)
.toBeGreaterThan(1.15);
await expect
.poll(async () => (await surfaceContrast(selected)).fill)
.toBeGreaterThan(1.1);
await shell.screenshot({
path: testInfo.outputPath(`series-grid-${theme}.png`),
animations: 'disabled',
});
await page
.getByRole('radio', { name: 'Grid view', exact: true })
.focus();
await page.keyboard.press('ArrowRight');
await expect(
page.getByRole('radio', { name: 'List view', exact: true })
).toBeChecked();
await expect(row).toBeVisible();
// Phone width: the title keeps readable room and the actions move
// under the text instead of squeezing it.
await page.setViewportSize({ width: 360, height: 740 });
await row.hover();
const narrowTitle = await title.boundingBox();
const narrowActions = await row
.locator('.episode-item__actions')
.boundingBox();
expect(narrowTitle?.width ?? 0).toBeGreaterThan(120);
expect(narrowActions?.y ?? 0).toBeGreaterThanOrEqual(
(narrowTitle?.y ?? 0) + (narrowTitle?.height ?? 0)
);
// A grid card at that width keeps its text under the artwork.
await page
.getByRole('radio', { name: 'Grid view', exact: true })
.click();
const narrowCard = shell.locator('.episode-card').first();
const cardArt = await narrowCard
.locator('.episode-item__thumb')
.boundingBox();
const cardTitle = await narrowCard
.locator('.episode-item__title')
.boundingBox();
expect(cardArt?.width ?? 0).toBeGreaterThan(200);
expect(cardTitle?.y ?? 0).toBeGreaterThanOrEqual(
(cardArt?.y ?? 0) + (cardArt?.height ?? 0)
);
});
test(`@xtream navigation: channel focus and separate scrollbar (${theme})`, async ({
page,
browserName,
}) => {
await addXtreamPortal(page);
await page.getByRole('link', { name: 'Live TV', exact: true }).click();
await page.evaluate(
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
const category = page.locator('.context-panel .category-item').first();
await category.click();
const viewport = page.locator('.scroll-viewport-portals');
await expect(viewport).toBeVisible();
await category.focus();
await page.keyboard.press('ArrowRight');
await expect(viewport).toBeFocused();
await expect(page.locator('app-web-player-view')).toHaveCount(0);
await pressTab(page, browserName);
const firstAction = viewport.locator('button.channel-content').first();
await expect(firstAction).toBeFocused();
await pressTab(page, browserName, 'backward');
await expect(viewport).toBeFocused();
const row = viewport.locator('.channel-name').first();
await row.click();
await expect(viewport).toBeFocused();
await page.keyboard.press('PageDown');
await expect
.poll(() => viewport.evaluate((el) => el.scrollTop))
.toBeGreaterThan(100);
await waitForScrollIdle(viewport);
const position = await viewport.evaluate((el) => el.scrollTop);
await page.keyboard.press('PageDown');
await expect
.poll(() => viewport.evaluate((el) => el.scrollTop))
.toBeGreaterThan(position);
await waitForScrollIdle(viewport);
await page.keyboard.press('Home');
await expect
.poll(() => viewport.evaluate((el) => el.scrollTop))
.toBe(0);
await firstAction.focus();
await page.keyboard.press('Space');
await expect(page.locator('app-web-player-view')).toBeVisible();
await expect(firstAction).toBeFocused();
await pressTab(page, browserName);
await expect(
viewport.locator('.favorite-button').first()
).toBeFocused();
await page.keyboard.press('Space');
await expect(
viewport.locator('.favorite-button').first()
).toBeFocused();
await viewport.focus();
const overlap = await viewport.evaluate((el) => {
const handle = el
.closest('.sidebar')!
.querySelector('.resize-handle')!;
return (
el.getBoundingClientRect().right -
handle.getBoundingClientRect().left
);
});
expect(overlap).toBeLessThanOrEqual(0);
await page.keyboard.press('ArrowLeft');
await expect(category).toBeFocused();
});
for (const section of ['Movies', 'Series']) {
test(`@xtream navigation: detail scroll ${section} (${theme})`, async ({
page,
}) => {
await page.setViewportSize({ width: 1200, height: 540 });
await addXtreamPortal(page);
await page
.getByRole('link', { name: section, exact: true })
.click();
await page.locator('app-grid-list mat-card').first().click();
const shell = page.locator('app-portal-detail-shell');
// The loading skeleton is a scrollable shell of its own; a key
// sent to it is lost when the loaded shell replaces it.
await expect(
shell.getByRole('heading', { level: 1 })
).toBeVisible();
await page.evaluate(
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
await expect(shell).toBeFocused();
await expect
.poll(() =>
shell.evaluate((el) => el.scrollHeight - el.clientHeight)
)
.toBeGreaterThan(0);
expect(
await shell.evaluate(
(el) => getComputedStyle(el).scrollbarWidth
)
).not.toBe('none');
await page.keyboard.press('PageDown');
await expect
.poll(() => shell.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
});
test(`@xtream header detail Back ${section} (${theme})`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1200, height: 540 });
await addXtreamPortal(page);
await page
.getByRole('link', { name: section, exact: true })
.click();
await page.locator('app-grid-list mat-card').first().click();
const shell = page.locator('app-portal-detail-shell');
await expect(
shell.getByRole('heading', { level: 1 })
).toBeVisible();
await shell.focus();
await page.evaluate(
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
// Back is the header's leading control, not a page overlay.
const back = page.locator(
'app-workspace-shell-header [data-test-id="workspace-header-back"]'
);
await expect(back).toBeVisible();
await expect(back).toHaveAccessibleName('Back');
await expect(
shell.getByRole('button', { name: 'Back', exact: true })
).toHaveCount(0);
const backTop = () =>
back.evaluate((el) => el.getBoundingClientRect().top);
const initialTop = await backTop();
await page.keyboard.press('End');
await expect
.poll(() => shell.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
await waitForScrollIdle(shell);
expect(await backTop()).toBe(initialTop);
await expect(back).toBeInViewport();
await page.screenshot({
path: testInfo.outputPath(
`header-back-${section}-${theme}.png`
),
});
await back.click();
await expect(shell).toHaveCount(0);
await page.locator('app-grid-list mat-card').first().click();
await expect(
shell.getByRole('heading', { level: 1 })
).toBeVisible();
await shell.focus();
await page.keyboard.press('End');
// Hover/focus must not make a tooltip consume the advertised Esc,
// even with focus on the header rather than inside the page.
await page.clock.install();
await back.focus();
await back.hover();
await page.clock.runFor(500);
await page.keyboard.press('Escape');
await expect(shell).toHaveCount(0);
});
}
}