mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
Merge remote-tracking branch 'origin/master' into claude/nice-feistel-0670b5
# Conflicts: # libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.spec.ts
This commit is contained in:
commit
4e0d669daf
1055 files changed
+70122
-15121
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: a11y
|
||||
---
|
||||
|
||||
Screen readers now announce every icon-only button by name, including the
|
||||
Sources row actions, the playlist details dialog, channel list actions, the
|
||||
channel details copy button and the release notes arrows. The channel star
|
||||
reads as "Favorite" and reports whether it is on.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: catalog
|
||||
---
|
||||
|
||||
The catalog's sort and rating chips now show a short label, such as "Newest",
|
||||
that fits in every language instead of cutting off. Sort menus mark the
|
||||
chosen option without shifting its label, and screen readers announce them as
|
||||
single-choice options.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: fix
|
||||
area: collections
|
||||
---
|
||||
|
||||
A playlist's recently viewed page now has a single "Clear recently viewed"
|
||||
button, and it always asks before clearing. The second button in the header,
|
||||
which cleared every tab at once without asking, is gone.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: dashboard
|
||||
---
|
||||
|
||||
Continue Watching no longer lists movies you have finished. A series moves on
|
||||
to the episode after the one you watched last, ignoring extras (season 0), and
|
||||
leaves after its final episode. A title counts as finished once 90% of it has
|
||||
played, as in Plex, Jellyfin and Kodi, so the end credits can be skipped. A
|
||||
series page's play button picks the same episode.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: perf
|
||||
area: dashboard
|
||||
---
|
||||
|
||||
Continue Watching and the hero banner no longer sit on a skeleton for many seconds after startup on large libraries: the dashboard reads every playlist's watch progress in one go, and the TMDB trending and recommendation rails wait for it before they search the catalog.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: fix
|
||||
area: dashboard
|
||||
---
|
||||
|
||||
The dashboard no longer jumps while the hero banner rotates on its own. The
|
||||
banner now keeps the height of its tallest slide, so the rails below stay put
|
||||
instead of moving a few pixels every 8 seconds.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: perf
|
||||
area: database
|
||||
---
|
||||
|
||||
The app starts faster on large libraries: the catalog is no longer scanned for duplicates on every launch, and reading the playlist list no longer walks every M3U playlist's channel data (a one-time rebuild of the playlists table on the first start after the update).
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: perf
|
||||
area: database
|
||||
---
|
||||
|
||||
The second launch after setting up a new library no longer rebuilds the whole title search index before the app opens, which took several seconds on large catalogs (about 4.5 s per million titles).
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: fix
|
||||
area: details
|
||||
---
|
||||
|
||||
Seasons without episode pictures or plots show compact text rows, and episode
|
||||
lists load as placeholder rows of the final size instead of a spinner. Stalker
|
||||
series no longer flash "No episodes available" while their seasons load.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: feature
|
||||
area: details
|
||||
highlight: Redesigned episode list
|
||||
---
|
||||
|
||||
Series pages get a calmer episode list: flat rows with runtime and air date,
|
||||
two lines of plot, a check on watched episodes and a bar only on started ones.
|
||||
Actions appear on hover or keyboard focus. Seasons are picked from chips, or a
|
||||
menu with watched counts from five seasons on, without season posters.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: fix
|
||||
area: details
|
||||
---
|
||||
|
||||
The artwork of the dashboard banner and of movie and series pages now fades
|
||||
smoothly into the page under the first row, instead of stopping at a visible
|
||||
edge above it.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: fix
|
||||
area: details
|
||||
---
|
||||
|
||||
Movie and series pages load with placeholders laid out like the finished page,
|
||||
so the title, chips and buttons no longer jump into place, and the Play button
|
||||
of a Stalker series keeps its spot while seasons load.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: fix
|
||||
area: downloads
|
||||
---
|
||||
|
||||
Downloads and recordings whose file is gone from disk show a warning icon next
|
||||
to "File missing" again, instead of the stray text "file_off".
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: perf
|
||||
area: electron
|
||||
---
|
||||
|
||||
On Linux the desktop app's window no longer sometimes appears about a second
|
||||
late at launch: it now opens as soon as the app has loaded, showing the
|
||||
loading screen until the dashboard is ready.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: perf
|
||||
area: epg
|
||||
---
|
||||
|
||||
Live channels start playing with less work competing for the first frame: the
|
||||
programme guide below the player now draws only the programmes around the part
|
||||
you are looking at, and fills in the rest as you scroll.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: i18n
|
||||
---
|
||||
|
||||
Catalog screens, notifications and external player messages now appear in the
|
||||
app's language. This covers "All items" and item counts, channel sort menus,
|
||||
request and backup notifications, and MPV/VLC launch and playback errors.
|
||||
Several playlist messages that showed a raw key now show text.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: fix
|
||||
area: i18n
|
||||
---
|
||||
|
||||
The EPG source settings in 16 languages no longer say that only URLs are
|
||||
accepted: the field label, placeholder, validation message, description and
|
||||
empty-state hint now mention local XMLTV files too, as the English text does.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: i18n
|
||||
---
|
||||
|
||||
Changing the app language now updates labels on screen without a restart.
|
||||
This covers the "All categories" and "All radio" entries for Stalker portals,
|
||||
the version note in Settings → About and the Xtream import progress, along with
|
||||
movie and series details pages that could keep the old language or show raw
|
||||
keys.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: i18n
|
||||
---
|
||||
|
||||
The phone remote control page now follows the phone's language. More labels
|
||||
appear in the app's language too: the Windows and Linux window buttons, the
|
||||
playlist switcher, unnamed Stalker episodes, embedded MPV errors, the dashboard
|
||||
carousel and the recording chip.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: i18n
|
||||
---
|
||||
|
||||
The source cleanup dialog, source connection status, playback error panel and
|
||||
external player status are now translated in every language, along with
|
||||
leftover English in Hungarian, Korean and Greek. Repeated labels such as Close,
|
||||
Settings and Account information now use one wording per language.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: import
|
||||
---
|
||||
|
||||
Adding a Stalker portal with an unlimited subscription no longer announces a bogus 1970 expiry date, and a portal that reports its expiry as a date shows that date instead of "Invalid Date".
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: fix
|
||||
area: m3u
|
||||
issues: [1894]
|
||||
---
|
||||
|
||||
Groups hidden in "Manage groups" stay hidden after the desktop app restarts,
|
||||
instead of every group of the playlist showing again.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: perf
|
||||
area: performance
|
||||
---
|
||||
|
||||
The app loads slightly less code at startup: a developer-only state
|
||||
inspection tool is no longer shipped in the desktop and web builds.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
When a stream fails to play, the recovery cards such as "Open in MPV" now show
|
||||
their full label in every language instead of cutting it off. The whole
|
||||
message, including each card's status line, also fits in the smaller player
|
||||
above the programme guide. A player too short for it scrolls from the top.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
issues: [1902]
|
||||
---
|
||||
|
||||
Embedded MPV no longer skips to the next episode when the provider drops the
|
||||
connection in the middle of an episode. It now reconnects and continues the
|
||||
episode where it stopped; a movie interrupted the same way resumes as well.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: feature
|
||||
area: playback
|
||||
---
|
||||
|
||||
With the Embedded MPV frame-copy engine, a movie's or episode's own chapters now appear as sections on the timeline, with their titles in the seek bar's label and read out by screen readers. When an episode has a closing-credits chapter, the "Up next" card appears as the credits start.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
With "Reuse player instance" on, IPTVnator now waits for MPV to confirm each
|
||||
command sent to the running player. A command MPV rejects or never answers
|
||||
falls back to a fresh MPV launch instead of reporting the stream as opened
|
||||
while nothing changed.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
With "Reuse player instance" on, the next episode, "Play from beginning" and
|
||||
a resumed episode sent to MPV or VLC now start where they should. The reused
|
||||
player kept the first launch's resume offset for every later title.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: player
|
||||
---
|
||||
|
||||
Programme names on the seek bar now reach keyboard, touch and screen-reader
|
||||
users: the bar announces the programme with the time, and its label appears
|
||||
while you seek with the arrow keys or drag on a touch screen. Long names are
|
||||
shortened instead of spilling past the player, and boundaries between
|
||||
programmes are easier to see. LIVE is translated.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: playlist
|
||||
---
|
||||
|
||||
In the desktop app, emptying an optional playlist field such as User-Agent, Referrer, Origin, the login details, the EPG sources or the file path now stays empty after saving instead of bringing the old value back.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: portals
|
||||
---
|
||||
|
||||
Movie and series details and catalog cards from Stalker and Xtream portals no longer show "N/A", "-" or a zero rating in place of missing information. Fields the provider left empty are simply hidden, and TMDB can now fill in the rating and release year for them.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: portals
|
||||
---
|
||||
|
||||
Opening a title in its portal from favorites, history or dashboard keeps the correct playlist and categories. Switching portals no longer lets an older loading request replace the current selection, and Stalker details wait for their destination portal before opening.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: portals
|
||||
---
|
||||
|
||||
Cancelling a portal request or hitting an expired login no longer floods the
|
||||
desktop log with stack traces: a cancelled Stalker or Xtream request stays
|
||||
silent, an HTTP 401/403 is a single warning, and the message shown for a
|
||||
refused request now names the real status instead of a generic connection
|
||||
failure.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: perf
|
||||
area: search
|
||||
---
|
||||
|
||||
Search results appear about twice as fast after you stop typing: the search now waits once for typing to pause instead of twice.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: feature
|
||||
area: self-hosted
|
||||
issues: [305]
|
||||
---
|
||||
|
||||
The Docker image now keeps a server database on a `/data` volume, the groundwork for sharing playlists and watch progress across devices. Mount a volume to keep it; nothing changes in the browser yet.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
type: feature
|
||||
area: settings
|
||||
highlight: Reorganised settings
|
||||
---
|
||||
|
||||
Settings are easier to scan: the sidebar groups pages into App, Library,
|
||||
Devices and Data with About pinned at the bottom, each page sorts its rows into
|
||||
titled cards with one aligned control column, and checkboxes became switches.
|
||||
Reset now lives on "Backup & data", subtitles moved to Playback, and the save
|
||||
bar counts your unsaved changes and answers Cmd/Ctrl+S. Esc leaves settings.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: fix
|
||||
area: settings
|
||||
---
|
||||
|
||||
A long nightly version no longer pushes "About" out of the settings sidebar:
|
||||
the version shortens with an ellipsis and shows in full on hover. Side panels
|
||||
also keep their bottom spacing instead of running into the window edge.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: fix
|
||||
area: settings
|
||||
---
|
||||
|
||||
Settings always show the installed version, also offline or when GitHub
|
||||
cannot be reached; before, it was missing from the sidebar and the About page
|
||||
until the update check answered.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: perf
|
||||
area: tmdb
|
||||
---
|
||||
|
||||
On large libraries the TMDB trending and recommendation rails, and the "In your library" lookups on Discover, actor and Similar views, no longer hold up the rest of the app while they search your playlists: the search now runs in small steps that let other work in between.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: fix
|
||||
area: ui
|
||||
---
|
||||
|
||||
Focus rings now share one colour across the app, chosen to stay clearly
|
||||
visible on every surface in both themes, including selected rows and
|
||||
highlighted items where some rings used to fade into the background.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: ui
|
||||
---
|
||||
|
||||
A sidebar no longer jumps wider the moment you start dragging its edge. It now follows the pointer from its current width, so a resized sidebar ends up exactly as wide as you dragged it.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: ui
|
||||
---
|
||||
|
||||
A sidebar you widen now keeps its width after you visit a page with a narrower sidebar, such as Settings. Opening that page no longer saves its smaller width over yours.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: ui
|
||||
---
|
||||
|
||||
Loading placeholders now take the size of what replaces them: an M3U
|
||||
playlist's channel list no longer jumps down when it finishes loading, and
|
||||
the dashboard's rail placeholders match the cards that arrive, so the rails
|
||||
below stay put. Their shimmer is also easier to see in the light theme.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: ui
|
||||
---
|
||||
|
||||
Moving through the app with Tab now always shows where focus is: buttons,
|
||||
season tabs, filter chips, list rows, switches and other controls get a
|
||||
clear focus ring in both themes. Clicking with the mouse still leaves no
|
||||
ring.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: ui
|
||||
---
|
||||
|
||||
The welcome screen, the empty Sources page and the drop-a-playlist overlay now follow the theme chosen in Settings instead of the system's light or dark mode. Panels, borders and the search box that were invisible in the light theme now show, and a few labels that fell back to the wrong colour use the theme's colours.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: workspace
|
||||
---
|
||||
|
||||
Back on Settings, search, Discover and actor pages now works when that page is the first one you opened, for example after a reload or from a link: it takes you to the dashboard or to the playlist's movies or series instead of doing nothing or leaving the app.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: workspace
|
||||
---
|
||||
|
||||
Opening an already-imported Xtream portal no longer flashes the "Syncing
|
||||
playlist" overlay for a split second before the catalog appears. The overlay
|
||||
still shows for real downloads and for saved catalogs that take a while to
|
||||
load.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: perf
|
||||
area: workspace
|
||||
---
|
||||
|
||||
On Retina displays the dashboard and the movie and series pages no longer run
|
||||
out of GPU tile memory: the content area's rounded corner is now painted
|
||||
instead of clipped, so scrolling no longer leaves blank patches behind the
|
||||
rails and heroes.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: workspace
|
||||
---
|
||||
|
||||
On macOS, zooming the app out no longer slides the Back button and the playlist switcher under the window's close, minimize and zoom buttons; the top bar also stays tall enough that the buttons never overlap the page below.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: workspace
|
||||
---
|
||||
|
||||
Opening a source from the dashboard no longer makes the page jump: the content
|
||||
slides aside for the category panel, and the header controls slide over for
|
||||
the Back button. The category list's search and sort buttons are in place
|
||||
while the list loads.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: fix
|
||||
area: workspace
|
||||
---
|
||||
|
||||
Sources can now be opened from the keyboard: on the Sources page, Tab stops
|
||||
on each source row before its buttons, and Enter or Space opens it just like
|
||||
a click. The focused row shows the focus ring.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: xtream
|
||||
---
|
||||
|
||||
Xtream backups preserve imported favorites, history, hidden categories, resume positions, and pinned sources even before the catalog loads. Storage failures now report a backup or restore error; failed position restores preserve the previous positions and remain available to retry.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: xtream
|
||||
---
|
||||
|
||||
Xtream favorites and recently viewed in the web app keep movies, live channels, and series separate when providers reuse IDs, including after reloading. Collection changes no longer wait for unrelated catalog loads. Saved items remain readable and removable when storage is full, and new saves take priority over cache updates. Confirmed live playback also appears in recently viewed.
|
||||
@@ -15,13 +15,14 @@ description: Use when changing IPTVnator SCSS tokens, shared layout mixins, port
|
||||
`libs/ui/styles/_detail-view-actions.scss`
|
||||
- UI policy and migration debt: `docs/architecture/iptvnator-ui-guidelines.md`
|
||||
|
||||
The index is a barrel, not a configured Sass include path. Production
|
||||
consumers currently use relative `@use` paths to the needed partial.
|
||||
The index is a barrel, not a Sass include path: consumers `@use` each
|
||||
partial by relative path.
|
||||
|
||||
## Token Boundary
|
||||
|
||||
- App-owned surfaces, text, separators, hover states, selections, and provider
|
||||
accents use `--app-*` tokens from `m3-theme.scss`.
|
||||
accents use `--app-*` tokens from `m3-theme.scss`, switched by
|
||||
`.dark-theme`, not `prefers-color-scheme`.
|
||||
- Use `--app-selection-on-color` for foregrounds placed on the selection
|
||||
accent; do not assume white has enough contrast in both themes.
|
||||
- Angular Material mixins and Material-component overrides may use Material
|
||||
@@ -57,6 +58,20 @@ consumers currently use relative `@use` paths to the needed partial.
|
||||
cap in rules that set that family. Read the
|
||||
guidelines' Typography section before changing bundled fonts.
|
||||
|
||||
## Rendering Cost
|
||||
|
||||
- A composited layer the size of the content area costs about 24 MB of
|
||||
tile memory at 2x against Chromium's 512 MB budget; contract in
|
||||
`docs/architecture/performance-journeys.md` (Compositing budget).
|
||||
- No `border-radius`, `mask` or `clip-path` on a scroller or another
|
||||
ancestor of `backdrop-filter`, render-surface `filter`, running
|
||||
animations, `isolation: isolate` or `will-change`: Blink then clips each
|
||||
through a mask layer the size of the clipped area. Paint the corner, as
|
||||
`.workspace-content-frame` does.
|
||||
- Adding or changing those properties: run `pnpm run perf:compositing`
|
||||
before and after, quote the numbers in the PR, and extend
|
||||
`compositing.e2e.ts` in `apps/electron-backend-e2e` for a new route.
|
||||
|
||||
## Validation
|
||||
|
||||
Run the affected consumer's Nx lint/test/build target. Inspect light and dark
|
||||
|
||||
@@ -36,9 +36,15 @@ not UI or util.
|
||||
instead of rebuilding those views.
|
||||
- Shared changes require checking every affected M3U, Xtream, Stalker,
|
||||
workspace, and collection consumer in light and dark themes.
|
||||
- `<mat-icon>` names must exist in the bundled Material Icons font;
|
||||
`pnpm run styles:icon-ligatures:validate` checks them (UI guidelines, Icon
|
||||
Names).
|
||||
|
||||
## Validation
|
||||
|
||||
Run the focused component/unit target and the closest Playwright workflow for a
|
||||
visible change. Electron CDP is a fallback for Electron-only gaps, not a
|
||||
replacement for an available E2E flow. Record any uncovered visual state.
|
||||
A change that adds `backdrop-filter`, `filter`, animations or a rounded
|
||||
scroller also runs `pnpm run perf:compositing` (see the theme/style skill's
|
||||
Rendering Cost section) and quotes the numbers.
|
||||
@@ -72,5 +72,11 @@ runs:
|
||||
(($j.wallClock // {}) | to_entries[] | "| `\(.key)` | \(.value) | |"),
|
||||
"",
|
||||
([($j.iterations // [])[] | select(.warmup | not) | .evidence.ipcSerialDepth // empty][0] // empty |
|
||||
"Serial IPC chain (first measured iteration): \(.chain | map("`\(.)`") | join(" → "))", ""))
|
||||
"Serial IPC chain (first measured iteration): \(.chain | map("`\(.)`") | join(" → "))", ""),
|
||||
([($j.iterations // [])[] | select(.warmup | not) | .evidence.perKeystroke // empty][0] // empty |
|
||||
"Per keystroke (first measured iteration):", "",
|
||||
"| Key | Query calls | Bridge calls | SQL statements | DOM mutations | CD ticks |",
|
||||
"| --- | ---: | ---: | ---: | ---: | ---: |",
|
||||
(.[] | "| `\(.key)` | \(.queryCalls) | \(.ipcCalls) | \(.sqlStatements) | \(.domMutations) | \(.cdTicks) |"),
|
||||
""))
|
||||
' "$SUMMARY" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
@@ -416,7 +416,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
uses: pnpm/action-setup@v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
|
||||
+60
-10
@@ -98,7 +98,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
uses: pnpm/action-setup@v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
uses: pnpm/action-setup@v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
@@ -296,8 +296,8 @@ jobs:
|
||||
if: needs.performance-journeys-scope.outputs.run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# The electron-performance build is the bulk of the time; each journey
|
||||
# (launch, open-source, playback) is six fresh Electron processes plus
|
||||
# one seeding run.
|
||||
# (launch, open-source, playback, search) is six fresh Electron
|
||||
# processes plus one seeding run.
|
||||
timeout-minutes: 30
|
||||
# Warn-only for the first two weeks of plan item B3: a failure is
|
||||
# visible on the run but does not fail the workflow.
|
||||
@@ -310,7 +310,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
uses: pnpm/action-setup@v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
@@ -324,8 +324,38 @@ jobs:
|
||||
# Electron dependency check, the xvfb run, the summary lookup and
|
||||
# the job-summary report; shared with performance-ratchet.yml.
|
||||
- name: Run the performance journeys
|
||||
id: journeys
|
||||
uses: ./.github/actions/performance-journeys
|
||||
|
||||
# Only the counters identical in every measured iteration of
|
||||
# recent master runs; their entries say whether a counter is
|
||||
# validated against wall-clock or a guard only (see Ratchet in
|
||||
# docs/architecture/performance-journeys.md). Here and not in the
|
||||
# composite action, so the weekly tightening still measures a run
|
||||
# that would fail it. tools/performance tests keep this list equal
|
||||
# to the journey entries of journey-baselines.json. It also runs
|
||||
# when a later step of the action (the job-summary report) failed
|
||||
# after the summary was written, so the counters are still checked.
|
||||
- name: Check the journey counters against the baselines
|
||||
if: ${{ !cancelled() && steps.journeys.outputs.summary != '' }}
|
||||
env:
|
||||
SUMMARY: ${{ steps.journeys.outputs.summary }}
|
||||
run: >-
|
||||
node tools/performance/check-journey-ratchet.mjs
|
||||
--summary "$SUMMARY"
|
||||
--only launch/renderer.ipcCallsToFirstCard
|
||||
--only launch/renderer.domMutationsToFirstCard
|
||||
--only launch/main.modulesRegisteredBeforeWindow
|
||||
--only launch/renderer.layoutShiftScore
|
||||
--only launch/renderer.layoutShiftScoreSettled
|
||||
--only open-source/main.mockHttpRequestsToSettled
|
||||
--only open-source/renderer.ipcCallsToFirstPage
|
||||
--only open-source/renderer.layoutShiftScore
|
||||
--only playback/renderer.httpRequestsToPlaying
|
||||
--only playback/renderer.layoutShiftScore
|
||||
--only search/renderer.ipcCallsPerSearch
|
||||
--only search/renderer.sqlStatementsPerSearch
|
||||
|
||||
- name: Upload journey summaries
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -349,7 +379,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
uses: pnpm/action-setup@v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
@@ -381,6 +411,25 @@ jobs:
|
||||
- name: Validate Angular Material token overrides
|
||||
run: pnpm run styles:material-tokens:validate
|
||||
|
||||
# The global :focus-visible fallback, and one ring colour for
|
||||
# every component (tools/nx/check-focus-ring-colour.mjs).
|
||||
- name: Validate the keyboard focus ring
|
||||
run: pnpm run styles:focus-visible:validate
|
||||
|
||||
# The icon font renders an unknown ligature name as plain text.
|
||||
- name: Validate Material icon names
|
||||
run: pnpm run styles:icon-ligatures:validate
|
||||
|
||||
# A var() read nothing declares always falls back, and a
|
||||
# prefers-color-scheme query follows the OS, not the app theme.
|
||||
- name: Validate theme references
|
||||
run: pnpm run styles:theme-references:validate
|
||||
|
||||
# A tooltip is not an accessible name: an icon-only <button> needs
|
||||
# aria-label, an aria-label binding or aria-labelledby.
|
||||
- name: Validate icon-only button names
|
||||
run: pnpm run a11y:icon-buttons:validate
|
||||
|
||||
# Nx rejects a non-parallel task with continuous dependencies, and
|
||||
# the Playwright plugin infers serve dependencies only when CI is
|
||||
# unset, so this checks both the local and the CI inference.
|
||||
@@ -393,10 +442,11 @@ jobs:
|
||||
- name: Typecheck Jest spec programs
|
||||
run: pnpm run typecheck:spec:test && pnpm run typecheck:spec
|
||||
|
||||
# Fails on missing or extra keys and on values identical to
|
||||
# English that tools/i18n/identical-en-baseline.json does not list.
|
||||
# The baseline only changes through a reviewed
|
||||
# `pnpm run i18n:baseline:update`; CI never rewrites it.
|
||||
# Fails on missing or extra keys, on values identical to English
|
||||
# that tools/i18n/identical-en-baseline.json does not list, and on
|
||||
# keys the renderer uses but en.json lacks. The baseline only
|
||||
# changes through a reviewed `pnpm run i18n:baseline:update`; CI
|
||||
# never rewrites it.
|
||||
- name: Check i18n drift and untranslated values
|
||||
run: pnpm run i18n:validate
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
# commit checked out above (the modern default); JavaScript is
|
||||
# interpreted, so no build step is needed before analysis.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4.37.7
|
||||
uses: github/codeql-action/init@v4.38.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# Excludes the localhost dev/E2E mock servers from analysis; see the
|
||||
@@ -66,4 +66,4 @@ jobs:
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4.37.7
|
||||
uses: github/codeql-action/analyze@v4.38.2
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
uses: pnpm/action-setup@v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
uses: pnpm/action-setup@v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
uses: pnpm/action-setup@v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
uses: pnpm/action-setup@v6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
@@ -240,7 +240,7 @@ jobs:
|
||||
git diff "$HEAD_SHA" HEAD -- "$baselines"
|
||||
echo '```'
|
||||
echo
|
||||
echo "If \`master\` moved since \`$HEAD_SHA\`, make sure the Initial bytes ratchet job passes on this PR before merging."
|
||||
echo "If \`master\` moved since \`$HEAD_SHA\`, make sure the Initial bytes ratchet and Performance journeys jobs pass on this PR before merging."
|
||||
} > "$BODY"
|
||||
gh api -X PATCH "repos/$REPOSITORY/pulls/$pr" -F "body=@$BODY" --silent
|
||||
echo "Pull request: ${GITHUB_SERVER_URL}/$REPOSITORY/pull/$pr"
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
||||
# Server Storage for the Self-hosted PWA — Overview
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-10-08-server-storage-design.md`.
|
||||
Branch base: `origin/master` at `7abf479de` (2026-10-11).
|
||||
|
||||
## Order of work
|
||||
|
||||
Three plans, three pull requests, merged in this order. Each one leaves the
|
||||
app working and fully tested.
|
||||
|
||||
| Plan | PR title | Release note |
|
||||
| --- | --- | --- |
|
||||
| [Phase 1: seams](2026-10-11-server-storage-phase-1-seams.md) | `refactor(storage): seams for server-backed playlist storage` | none, label `no-release-note` (behavior-preserving) |
|
||||
| [Phase 2a: backend](2026-10-11-server-storage-phase-2a-backend.md) | `feat(self-hosted): server storage API in web-backend` | none yet, label `no-release-note`; the PWA does not use it until 2b |
|
||||
| [Phase 2b: PWA](2026-10-11-server-storage-phase-2b-pwa.md) | `feat(self-hosted): keep playlists on the server for every device` | `.changes/self-hosted-server-storage.md`, closes #305 |
|
||||
|
||||
Phase 2a only depends on Phase 1 Tasks 2 and 3 (shared codec, data-dir
|
||||
variable). Phase 2b depends on all of Phase 1 and 2a.
|
||||
|
||||
## Branches and worktrees
|
||||
|
||||
- One worktree per PR under `.claude/worktrees/`, each with its own
|
||||
`pnpm install --frozen-lockfile` (AGENTS.md).
|
||||
- Phase 1 branch: `claude/server-storage` (this worktree, already holds the
|
||||
spec and the plans).
|
||||
- Phase 2a branch: `claude/server-storage-backend`, created from
|
||||
`claude/server-storage` after Phase 1 is committed, rebased onto `master`
|
||||
once Phase 1 merges.
|
||||
- Phase 2b branch: `claude/server-storage-pwa`, created from the 2a branch the
|
||||
same way.
|
||||
|
||||
## Gates for every PR (from AGENTS.md)
|
||||
|
||||
1. Targeted unit tests and lint of each touched project.
|
||||
2. `pnpm run typecheck:ci`.
|
||||
3. Docs updated in the canonical owner (listed per plan).
|
||||
4. Local review gate: commit, then `codex review --base origin/master -c model_reasoning_effort=high`
|
||||
and `greptile review --json` on the same commit, fix, repeat until both are clean (max five rounds).
|
||||
5. Push once, open the PR, report checks, skipped checks with reasons, docs and release-note status.
|
||||
|
||||
## Decisions to keep in mind while implementing
|
||||
|
||||
- Do not change `PlaylistsService`'s public API. All storage selection goes
|
||||
through `PlaylistStorageBackend`.
|
||||
- Never add files to `tools/eslint/max-lines-baseline.mjs`. New production
|
||||
files stay under 300 lines.
|
||||
- Storage errors on the server are logged by error name and route only;
|
||||
Drizzle messages can carry SQL parameters (portal credentials).
|
||||
- The server-mode browser cache uses the key prefix `iptvnator-server:`;
|
||||
never write server data into the legacy `xtream-*` keys.
|
||||
- Translate every new i18n key in all 19 locales; `pnpm run i18n:check`
|
||||
must pass.
|
||||
@@ -399,6 +399,9 @@ $ pnpm run perf:journeys
|
||||
The journeys, their counters and the summary written under
|
||||
`dist/performance/journeys/` are described in
|
||||
[docs/architecture/performance-journeys.md](docs/architecture/performance-journeys.md).
|
||||
`pnpm run perf:compositing` measures the renderer's tile memory and composited
|
||||
layers on the dashboard, the detail pages, Live TV and settings (the
|
||||
"compositing budget" section of the same document).
|
||||
|
||||
## Disclaimer
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { workspaceRoot } from '@nx/devkit';
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Compositing report (`pnpm run perf:compositing`,
|
||||
* docs/architecture/performance-journeys.md#compositing-budget). One worker,
|
||||
* no retries. The Xtream mock runs on a dedicated loopback port so a normal
|
||||
* E2E server on 3211 cannot be reused by accident; the test fixtures read the
|
||||
* port from `XTREAM_MOCK_PORT`, which this config sets for the worker.
|
||||
*/
|
||||
const xtreamMockPort =
|
||||
process.env['IPTVNATOR_COMPOSITING_XTREAM_MOCK_PORT'] ?? '3233';
|
||||
process.env['XTREAM_MOCK_PORT'] = xtreamMockPort;
|
||||
|
||||
export default defineConfig({
|
||||
fullyParallel: false,
|
||||
outputDir: '../../dist/test-results/electron-backend-e2e/compositing',
|
||||
reporter: [['list']],
|
||||
retries: 0,
|
||||
testDir: './src/compositing',
|
||||
testMatch: '**/*.report.ts',
|
||||
timeout: 15 * 60 * 1_000,
|
||||
use: {
|
||||
testIdAttribute: 'data-test-id',
|
||||
},
|
||||
webServer: {
|
||||
command: 'node --import tsx apps/xtream-mock-server/src/main.ts',
|
||||
cwd: workspaceRoot,
|
||||
env: {
|
||||
HOST: '127.0.0.1',
|
||||
NODE_ENV: 'development',
|
||||
PORT: xtreamMockPort,
|
||||
TSX_TSCONFIG_PATH: 'tsconfig.base.json',
|
||||
},
|
||||
reuseExistingServer: !process.env['CI'],
|
||||
url: `http://127.0.0.1:${xtreamMockPort}/health`,
|
||||
},
|
||||
workers: 1,
|
||||
});
|
||||
@@ -68,6 +68,16 @@
|
||||
"command": "playwright test --config=playwright.journeys.config.ts"
|
||||
}
|
||||
},
|
||||
"compositing-report": {
|
||||
"dependsOn": ["electron-backend:build-e2e"],
|
||||
"executor": "nx:run-commands",
|
||||
"cache": false,
|
||||
"parallelism": false,
|
||||
"options": {
|
||||
"cwd": "apps/electron-backend-e2e",
|
||||
"command": "playwright test --config=playwright.compositing.config.ts"
|
||||
}
|
||||
},
|
||||
"packaged-frame-copy-smoke": {
|
||||
"dependsOn": ["test-packaged-frame-copy-fixtures"],
|
||||
"executor": "nx:run-commands",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
import type { PlaylistBackupManifestV1 } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
closeElectronApp,
|
||||
@@ -21,20 +22,20 @@ import { readVisibleSidebarCategories } from './sidebar-categories.e2e-support';
|
||||
|
||||
/**
|
||||
* Full backup round-trip through the real UI, DB worker and IPC stack:
|
||||
* hide a category, export the backup, delete the source, import the file
|
||||
* back and verify the restored portal hides the same category again after
|
||||
* its content is re-imported from the mock server (regression for #1017 —
|
||||
* exported hidden categories lost their xtream IDs and the restore either
|
||||
* hid everything or nothing).
|
||||
* Preserve hidden categories, collections, positions and source pins through
|
||||
* an immediate re-export while catalog restoration is still pending, then
|
||||
* verify the restored database after reopening the portal. The category UI
|
||||
* assertions also guard #1017's missing provider category IDs.
|
||||
*/
|
||||
test.describe('Electron playlist backup round-trip', () => {
|
||||
test('exports a backup and re-imports it with hidden categories restored', async ({
|
||||
test('preserves pending backup state on re-export and restores it after restart', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const portalName = 'Backup Roundtrip Xtream';
|
||||
const exportPath = join(dataDir, 'roundtrip-backup.json');
|
||||
const pendingExportPath = join(dataDir, 'pending-backup.json');
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
@@ -61,6 +62,36 @@ test.describe('Electron playlist backup round-trip', () => {
|
||||
sidebarCategoryById(app.mainWindow, targetCategory.id)
|
||||
).toHaveCount(0);
|
||||
|
||||
const playlistId =
|
||||
app.mainWindow.url().match(/xtreams\/([^/]+)/)?.[1] ?? '';
|
||||
expect(playlistId).not.toEqual('');
|
||||
// Seed realistic resume/collection state through the same IPC used
|
||||
// by playback. No player runs here to overwrite the saved position.
|
||||
const movieXtreamId = await app.mainWindow.evaluate(async (id) => {
|
||||
const [movie] = await window.electron.dbGetContent(id, 'movie');
|
||||
if (!movie) throw new Error('Mock catalog has no movie.');
|
||||
const results = await Promise.all([
|
||||
window.electron.dbAddFavorite(movie.id, id),
|
||||
window.electron.dbAddRecentItem(movie.id, id),
|
||||
window.electron.dbSavePlaybackPosition(id, {
|
||||
contentXtreamId: movie.xtream_id,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 123,
|
||||
durationSeconds: 7200,
|
||||
}),
|
||||
window.electron.dbSetVodSourcePin({
|
||||
matchKey: 'title:backup-roundtrip',
|
||||
playlistId: id,
|
||||
contentId: movie.xtream_id,
|
||||
portalType: 'xtream',
|
||||
}),
|
||||
]);
|
||||
if (results.some((result) => !result.success)) {
|
||||
throw new Error('Seeding backup state through IPC failed.');
|
||||
}
|
||||
return movie.xtream_id;
|
||||
}, playlistId);
|
||||
|
||||
// Export through the real settings flow with the native save
|
||||
// dialog stubbed to a fixed path inside the test data dir.
|
||||
await app.electronApp.evaluate(({ dialog: nativeDialog }, path) => {
|
||||
@@ -83,17 +114,9 @@ test.describe('Electron playlist backup round-trip', () => {
|
||||
// The exported manifest must reference hidden categories by
|
||||
// numeric xtream ID — the #1017 regression exported anonymous
|
||||
// { categoryType } entries.
|
||||
const manifest = JSON.parse(readFileSync(exportPath, 'utf-8')) as {
|
||||
playlists: Array<{
|
||||
portalType: string;
|
||||
userState?: {
|
||||
hiddenCategories?: Array<{
|
||||
categoryType?: string;
|
||||
xtreamId?: unknown;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(exportPath, 'utf-8')
|
||||
) as PlaylistBackupManifestV1;
|
||||
const xtreamEntry = manifest.playlists.find(
|
||||
(entry) => entry.portalType === 'xtream'
|
||||
);
|
||||
@@ -107,6 +130,18 @@ test.describe('Electron playlist backup round-trip', () => {
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(xtreamEntry?.userState.favorites).toHaveLength(1);
|
||||
expect(xtreamEntry?.userState.recentlyViewed).toHaveLength(1);
|
||||
expect(xtreamEntry?.userState.sourcePins).toHaveLength(1);
|
||||
expect(xtreamEntry?.userState.playbackPositions).toEqual([
|
||||
expect.objectContaining({
|
||||
contentXtreamId: movieXtreamId,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 123,
|
||||
durationSeconds: 7200,
|
||||
}),
|
||||
]);
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await deleteSource(app.mainWindow, portalName);
|
||||
await expect(
|
||||
@@ -128,6 +163,46 @@ test.describe('Electron playlist backup round-trip', () => {
|
||||
app.mainWindow.getByText(/Backup import finished: 1 imported/)
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// No catalog has been opened since import. Verify this is a
|
||||
// genuinely pending restore, then export it again through the UI.
|
||||
const pendingBeforeExport = await app.mainWindow.evaluate(
|
||||
async (id) => ({
|
||||
pending: localStorage.getItem(`xtream-restore-${id}`),
|
||||
hasMovies: await window.electron.dbHasContent(id, 'movie'),
|
||||
positions:
|
||||
await window.electron.dbGetAllPlaybackPositions(id),
|
||||
}),
|
||||
playlistId
|
||||
);
|
||||
expect(pendingBeforeExport.pending).not.toBeNull();
|
||||
expect(pendingBeforeExport.hasMovies).toBe(false);
|
||||
expect(pendingBeforeExport.positions).toEqual([]);
|
||||
await app.electronApp.evaluate(({ dialog: nativeDialog }, path) => {
|
||||
nativeDialog.showSaveDialog = async () => ({
|
||||
canceled: false,
|
||||
filePath: path,
|
||||
});
|
||||
}, pendingExportPath);
|
||||
await backupSection
|
||||
.getByRole('button', { name: 'Export', exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
app.mainWindow.getByText('Playlist backup exported.')
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
const pendingManifest = JSON.parse(
|
||||
readFileSync(pendingExportPath, 'utf-8')
|
||||
) as PlaylistBackupManifestV1;
|
||||
const pendingEntry = pendingManifest.playlists.find(
|
||||
(entry) => entry.portalType === 'xtream'
|
||||
);
|
||||
expect(pendingEntry?.userState).toEqual(xtreamEntry?.userState);
|
||||
expect(
|
||||
await app.mainWindow.evaluate(
|
||||
(id) => localStorage.getItem(`xtream-restore-${id}`),
|
||||
playlistId
|
||||
)
|
||||
).toEqual(pendingBeforeExport.pending);
|
||||
|
||||
// Restart before opening the restored portal: the root-provided
|
||||
// XtreamStore still holds the deleted portal's in-memory state
|
||||
// under the same playlist id and would skip content
|
||||
@@ -179,6 +254,40 @@ test.describe('Electron playlist backup round-trip', () => {
|
||||
.map((row) => row.name)
|
||||
).toEqual([targetCategory.name]);
|
||||
|
||||
const restoredState = await app.mainWindow.evaluate(
|
||||
async (id) => ({
|
||||
positions:
|
||||
await window.electron.dbGetAllPlaybackPositions(id),
|
||||
favorites: await window.electron.dbGetFavorites(id),
|
||||
recent: await window.electron.dbGetRecentItems(id),
|
||||
pins: await window.electron.dbListVodSourcePins(id),
|
||||
pending: localStorage.getItem(`xtream-restore-${id}`),
|
||||
}),
|
||||
restoredPlaylistId
|
||||
);
|
||||
expect(restoredState.positions).toEqual([
|
||||
expect.objectContaining({
|
||||
contentXtreamId: movieXtreamId,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 123,
|
||||
durationSeconds: 7200,
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
restoredState.favorites.map((item) => item.xtream_id)
|
||||
).toEqual([movieXtreamId]);
|
||||
expect(restoredState.recent.map((item) => item.xtream_id)).toEqual([
|
||||
movieXtreamId,
|
||||
]);
|
||||
expect(restoredState.pins).toEqual([
|
||||
expect.objectContaining({
|
||||
matchKey: 'title:backup-roundtrip',
|
||||
playlistId: restoredPlaylistId,
|
||||
contentId: movieXtreamId,
|
||||
}),
|
||||
]);
|
||||
expect(restoredState.pending).toBeNull();
|
||||
|
||||
dialog = await openManageCategoriesDialog(app.mainWindow);
|
||||
await dialog
|
||||
.locator('input[type="search"]')
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickCategoryByNameExact,
|
||||
closeElectronApp,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openSources,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
saveSettings,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import { fetchXtreamLiveFixture } from './portal-mock-fixtures';
|
||||
import { applyTheme, expectTextContrast } from './theme-contrast';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The catalog header's sort chip shows a short label for the active sort at
|
||||
// every width ("Newest", not "Sort: Date Added (Latest First)"), so it never
|
||||
// truncates, also in the long translations; the full text is its name.
|
||||
//
|
||||
// Single-choice menus (catalog refine, live channels, categories, sources)
|
||||
// are radio groups: every row reserves the same leading check slot, so the
|
||||
// labels share one left edge, and `aria-checked` marks the active choice.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const locales = ['en', 'de', 'ru', 'hu'] as const;
|
||||
type Locale = (typeof locales)[number];
|
||||
|
||||
/**
|
||||
* Per locale, in menu order: the visible chip text of the six content sorts
|
||||
* and the full text screen readers get instead.
|
||||
*/
|
||||
function sortChipLabels(
|
||||
locale: Locale
|
||||
): { visible: string; screenReader: string }[] {
|
||||
const workspace = (
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
join(__dirname, `../../web/src/assets/i18n/${locale}.json`),
|
||||
'utf8'
|
||||
)
|
||||
) as {
|
||||
WORKSPACE: {
|
||||
SORT_LABEL: string;
|
||||
SORT_DATE_DESC: string;
|
||||
SORT_DATE_ASC: string;
|
||||
SORT_NAME_ASC: string;
|
||||
SORT_NAME_DESC: string;
|
||||
SORT_TOP_RATED: string;
|
||||
SORT_LOWEST_RATED: string;
|
||||
SORT_CHIP: {
|
||||
NEWEST: string;
|
||||
OLDEST: string;
|
||||
TOP_RATED: string;
|
||||
LOWEST_RATED: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
).WORKSPACE;
|
||||
return (
|
||||
[
|
||||
[workspace.SORT_CHIP.NEWEST, workspace.SORT_DATE_DESC],
|
||||
[workspace.SORT_CHIP.OLDEST, workspace.SORT_DATE_ASC],
|
||||
[workspace.SORT_NAME_ASC, workspace.SORT_NAME_ASC],
|
||||
[workspace.SORT_NAME_DESC, workspace.SORT_NAME_DESC],
|
||||
[workspace.SORT_CHIP.TOP_RATED, workspace.SORT_TOP_RATED],
|
||||
[workspace.SORT_CHIP.LOWEST_RATED, workspace.SORT_LOWEST_RATED],
|
||||
] as const
|
||||
).map(([visible, full]) => ({
|
||||
visible,
|
||||
screenReader: `${workspace.SORT_LABEL}${full}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function openMenuPanel(page: Page): Locator {
|
||||
return page.locator('.cdk-overlay-pane .mat-mdc-menu-panel').last();
|
||||
}
|
||||
|
||||
async function openRefineMenu(page: Page): Promise<Locator> {
|
||||
await page.locator('app-category-content-view .refine-action').click();
|
||||
const menu = openMenuPanel(page);
|
||||
await expect(menu).toBeVisible();
|
||||
await settleAnimations(menu);
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function settleAnimations(menu: Locator): Promise<void> {
|
||||
await menu.evaluate((element) =>
|
||||
Promise.all(
|
||||
element
|
||||
.getAnimations()
|
||||
.map((animation) => animation.finished.catch(() => undefined))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function closeMenu(page: Page, menu: Locator): Promise<void> {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(menu).toBeHidden();
|
||||
}
|
||||
|
||||
interface RadioRow {
|
||||
checked: string | null;
|
||||
labelLeft: number;
|
||||
checkVisible: boolean;
|
||||
}
|
||||
|
||||
async function radioRows(group: Locator): Promise<RadioRow[]> {
|
||||
return group.getByRole('menuitemradio').evaluateAll((rows) =>
|
||||
rows.map((row) => {
|
||||
const check = row.querySelector('.app-menu-item-radio-check');
|
||||
return {
|
||||
checked: row.getAttribute('aria-checked'),
|
||||
labelLeft: Math.round(
|
||||
row
|
||||
.querySelector('.mat-mdc-menu-item-text')
|
||||
?.getBoundingClientRect().left ?? Number.NaN
|
||||
),
|
||||
checkVisible:
|
||||
check !== null &&
|
||||
getComputedStyle(check).visibility === 'visible',
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One radio group: every label on one left edge, exactly the expected row
|
||||
* checked, and the check glyph visible on that row only.
|
||||
*/
|
||||
async function expectRadioGroup(
|
||||
group: Locator,
|
||||
checkedIndex: number,
|
||||
label: string
|
||||
): Promise<void> {
|
||||
const rows = await radioRows(group);
|
||||
expect(rows.length, label).toBeGreaterThan(1);
|
||||
expect(
|
||||
[...new Set(rows.map((row) => row.labelLeft))],
|
||||
`${label}: label offsets ${JSON.stringify(rows)}`
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
rows.map((row) => row.checked),
|
||||
label
|
||||
).toEqual(rows.map((_, index) => String(index === checkedIndex)));
|
||||
expect(
|
||||
rows.map((row) => row.checkVisible),
|
||||
label
|
||||
).toEqual(rows.map((_, index) => index === checkedIndex));
|
||||
}
|
||||
|
||||
/** Visible chip labels and whether each one shows its whole text. */
|
||||
async function chipLabelFits(page: Page) {
|
||||
return page
|
||||
.locator('app-category-content-view .refinement-chip')
|
||||
.evaluateAll((chips) =>
|
||||
chips.flatMap((chip) =>
|
||||
[
|
||||
chip,
|
||||
...chip.querySelectorAll<HTMLElement>(
|
||||
'.refinement-chip-label'
|
||||
),
|
||||
]
|
||||
.filter((element) => element.getClientRects().length > 0)
|
||||
.map((element) => ({
|
||||
text: element.textContent?.trim() ?? '',
|
||||
fits: element.scrollWidth <= element.clientWidth,
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function switchLanguage(page: Page, locale: Locale): Promise<void> {
|
||||
const catalogUrl = page.url();
|
||||
await openSettings(page);
|
||||
await page.getByTestId('select-language').click();
|
||||
await page.getByTestId(locale).click();
|
||||
await saveSettings(page);
|
||||
await page.goBack();
|
||||
await page.waitForURL(catalogUrl);
|
||||
await expect(
|
||||
page.locator('app-category-content-view mat-card').first()
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
test.describe('Electron catalog sort menus', () => {
|
||||
test('keeps the refine chips whole at 1280px in en, de, ru and hu, with the refine menu as aligned radio groups', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
|
||||
try {
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await openWorkspaceSection(page, 'Movies');
|
||||
await expect(
|
||||
page.locator('app-category-content-view mat-card').first()
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// A rating threshold shows the second chip next to the sort one.
|
||||
let menu = await openRefineMenu(page);
|
||||
await menu
|
||||
.getByRole('group')
|
||||
.nth(1)
|
||||
.getByRole('menuitemradio')
|
||||
.nth(5)
|
||||
.click();
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
for (const locale of locales) {
|
||||
if (locale !== 'en') await switchLanguage(page, locale);
|
||||
const chipLabels = sortChipLabels(locale);
|
||||
|
||||
for (const [index, chipLabel] of chipLabels.entries()) {
|
||||
menu = await openRefineMenu(page);
|
||||
await menu
|
||||
.getByRole('group')
|
||||
.first()
|
||||
.getByRole('menuitemradio')
|
||||
.nth(index)
|
||||
.click();
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
const sortChip = page.locator(
|
||||
'app-category-content-view .sort-refinement-chip'
|
||||
);
|
||||
await expect(
|
||||
sortChip.locator('.refinement-chip-label')
|
||||
).toHaveText(chipLabel.visible);
|
||||
// The accessibility tree holds the full text only; the
|
||||
// short label is aria-hidden.
|
||||
await expect(sortChip).toMatchAriaSnapshot(
|
||||
`- text: ${JSON.stringify(chipLabel.screenReader)}`
|
||||
);
|
||||
const fits = await chipLabelFits(page);
|
||||
expect(
|
||||
fits.filter((chip) => !chip.fits),
|
||||
`${locale}: ${JSON.stringify(fits)}`
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
menu = await openRefineMenu(page);
|
||||
const groups = menu.getByRole('group');
|
||||
await expect(groups).toHaveCount(2);
|
||||
await expectRadioGroup(groups.nth(0), 5, `${locale} sort`);
|
||||
await expectRadioGroup(groups.nth(1), 5, `${locale} rating`);
|
||||
const lefts = (await radioRows(menu)).map(
|
||||
(row) => row.labelLeft
|
||||
);
|
||||
expect(
|
||||
[...new Set(lefts)],
|
||||
`${locale}: both groups`
|
||||
).toHaveLength(1);
|
||||
await closeMenu(page, menu);
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('shows aligned radio rows with a legible check in the live, category and source sort menus, in light and dark themes', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const liveFixture = await fetchXtreamLiveFixture(request, {
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
});
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
|
||||
try {
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await openWorkspaceSection(page, 'Live TV');
|
||||
await clickCategoryByNameExact(page, liveFixture.categoryName);
|
||||
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Sort channels', exact: true })
|
||||
.click();
|
||||
let menu = openMenuPanel(page);
|
||||
await expect(menu).toBeVisible();
|
||||
await settleAnimations(menu);
|
||||
await expectRadioGroup(menu, 0, `${theme} live channels`);
|
||||
await expectCheckedRowLegible(menu);
|
||||
await menu
|
||||
.getByRole('menuitemradio', { name: 'Name Z-A' })
|
||||
.click();
|
||||
await expect(menu).toBeHidden();
|
||||
await page
|
||||
.getByRole('button', { name: 'Sort channels', exact: true })
|
||||
.click();
|
||||
menu = openMenuPanel(page);
|
||||
await settleAnimations(menu);
|
||||
await expectRadioGroup(menu, 2, `${theme} live channels`);
|
||||
await menu
|
||||
.getByRole('menuitemradio', { name: 'Server order' })
|
||||
.click();
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Sort categories',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
menu = openMenuPanel(page);
|
||||
await expect(menu).toBeVisible();
|
||||
await settleAnimations(menu);
|
||||
await expectRadioGroup(menu, 0, `${theme} categories`);
|
||||
await expectCheckedRowLegible(menu);
|
||||
await closeMenu(page, menu);
|
||||
}
|
||||
|
||||
await openSources(page);
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
await page
|
||||
.locator('app-workspace-sources .sort-trigger')
|
||||
.click();
|
||||
const menu = openMenuPanel(page);
|
||||
await expect(menu).toBeVisible();
|
||||
await settleAnimations(menu);
|
||||
await expectRadioGroup(menu, 0, `${theme} sources`);
|
||||
await expectCheckedRowLegible(menu);
|
||||
await closeMenu(page, menu);
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** The check glyph is a graphic (3:1); the label is text (4.5:1). */
|
||||
async function expectCheckedRowLegible(menu: Locator): Promise<void> {
|
||||
const row = menu.locator('[role="menuitemradio"][aria-checked="true"]');
|
||||
await expectTextContrast(row.locator('.app-menu-item-radio-check'), 3);
|
||||
await expectTextContrast(row.locator('.mat-mdc-menu-item-text'), 4.5);
|
||||
}
|
||||
@@ -361,10 +361,10 @@ const largeXtreamCredentials = {
|
||||
|
||||
async function setLiveSortMode(
|
||||
page: Page,
|
||||
label: 'Server Order' | 'Name A-Z' | 'Name Z-A'
|
||||
label: 'Server order' | 'Name A-Z' | 'Name Z-A'
|
||||
): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Sort channels' }).click();
|
||||
await page.getByRole('menuitem', { name: label, exact: true }).click();
|
||||
await page.getByRole('menuitemradio', { name: label, exact: true }).click();
|
||||
}
|
||||
|
||||
async function setContentSortMode(
|
||||
@@ -376,7 +376,7 @@ async function setContentSortMode(
|
||||
| 'Name Z-A'
|
||||
): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Refine', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: label, exact: true }).click();
|
||||
await page.getByRole('menuitemradio', { name: label, exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectVisibleChannelTitles(
|
||||
|
||||
@@ -60,7 +60,7 @@ test.describe('Electron Xtream Category Management', () => {
|
||||
.getByRole('button', { name: 'Sort categories', exact: true })
|
||||
.click();
|
||||
await app.mainWindow
|
||||
.getByRole('menuitem', { name: 'Name A-Z' })
|
||||
.getByRole('menuitemradio', { name: 'Name A-Z' })
|
||||
.click();
|
||||
|
||||
const categories = await app.mainWindow.evaluate(async () => {
|
||||
@@ -619,38 +619,43 @@ async function pickSidebarCategory(
|
||||
} | null = null;
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const candidates = (
|
||||
await readVisibleSidebarCategories(page)
|
||||
).filter(
|
||||
(candidate) =>
|
||||
candidate.id && candidate.name && candidate.itemCount > 0
|
||||
);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
preferredCandidate = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
const nameCounts = new Map<string, number>();
|
||||
for (const candidate of candidates) {
|
||||
nameCounts.set(
|
||||
candidate.name,
|
||||
(nameCounts.get(candidate.name) ?? 0) + 1
|
||||
.poll(
|
||||
async () => {
|
||||
const candidates = (
|
||||
await readVisibleSidebarCategories(page)
|
||||
).filter(
|
||||
(candidate) =>
|
||||
candidate.id &&
|
||||
candidate.name &&
|
||||
candidate.itemCount > 0
|
||||
);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
preferredCandidate = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
const nameCounts = new Map<string, number>();
|
||||
for (const candidate of candidates) {
|
||||
nameCounts.set(
|
||||
candidate.name,
|
||||
(nameCounts.get(candidate.name) ?? 0) + 1
|
||||
);
|
||||
}
|
||||
|
||||
preferredCandidate =
|
||||
candidates.find(
|
||||
(candidate) => nameCounts.get(candidate.name) === 1
|
||||
) ?? candidates[0];
|
||||
|
||||
return preferredCandidate !== null;
|
||||
},
|
||||
{
|
||||
message: 'No visible Xtream category with content was found.',
|
||||
timeout: 15000,
|
||||
}
|
||||
|
||||
preferredCandidate =
|
||||
candidates.find(
|
||||
(candidate) => nameCounts.get(candidate.name) === 1
|
||||
) ?? candidates[0];
|
||||
|
||||
return preferredCandidate !== null;
|
||||
})
|
||||
.toBe(true, {
|
||||
message: 'No visible Xtream category with content was found.',
|
||||
timeout: 15000,
|
||||
});
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
return preferredCandidate!;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickCategoryByNameExact,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expect,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
addCurrentDetailToFavorites,
|
||||
goBackFromDetail,
|
||||
toggleFavoriteForChannel,
|
||||
} from './dashboard-e2e-flows';
|
||||
import {
|
||||
collectCompositedLayers,
|
||||
describeLayerSize,
|
||||
findOversizedClipMasks,
|
||||
measureContentArea,
|
||||
routeDeterministicArtwork,
|
||||
} from './performance/compositing-probe';
|
||||
import {
|
||||
fetchXtreamLiveFixture,
|
||||
fetchXtreamSeriesFixture,
|
||||
fetchXtreamVodFixture,
|
||||
getXtreamTitle,
|
||||
} from './portal-mock-fixtures';
|
||||
|
||||
test.describe('Compositing budget', () => {
|
||||
// A `border-radius` on the content scroller made Blink clip every
|
||||
// composited effect inside it (the rail chevrons' and hero controls'
|
||||
// backdrop filters, running animations) through a synthesized mask layer
|
||||
// the size of the whole content area: ~24 MB of tile memory each at 2x,
|
||||
// a dozen on the dashboard, which took the renderer past Chromium's tile
|
||||
// budget ("tile memory limits exceeded") and left blank tiles while
|
||||
// scrolling. The corner is painted instead (workspace shell contract).
|
||||
// Blink synthesizes the masks while layerizing, before any GPU work:
|
||||
// under `--disable-gpu` (the Linux CI launch, software compositing and
|
||||
// raster) the controls are still composited and the old radius still
|
||||
// produced seven masks, so the check guards the same path everywhere.
|
||||
test('synthesizes no content-area-sized clip masks on the dashboard, the detail pages and Live TV', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const credentials = {
|
||||
password: defaultXtreamPassword,
|
||||
username: defaultXtreamUsername,
|
||||
};
|
||||
const live = await fetchXtreamLiveFixture(request, credentials);
|
||||
const vod = await fetchXtreamVodFixture(request, credentials);
|
||||
const series = await fetchXtreamSeriesFixture(request, credentials);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
|
||||
try {
|
||||
await routeDeterministicArtwork(page);
|
||||
const cdp = await app.electronApp.context().newCDPSession(page);
|
||||
const clipMasks = async () => {
|
||||
const area = await measureContentArea(page);
|
||||
const layers = await collectCompositedLayers(cdp, page);
|
||||
return {
|
||||
backdropFilterLayers: layers.filter((layer) =>
|
||||
layer.reasons.includes('BackdropFilter')
|
||||
).length,
|
||||
masks: findOversizedClipMasks(layers, area).map(
|
||||
describeLayerSize
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await openWorkspaceSection(page, 'Live TV');
|
||||
await clickCategoryByNameExact(page, live.categoryName);
|
||||
await toggleFavoriteForChannel(page, getXtreamTitle(live.items[0]));
|
||||
await expect(
|
||||
page.getByTestId('channel-item').first()
|
||||
).toBeVisible();
|
||||
expect((await clipMasks()).masks, 'Live TV').toEqual([]);
|
||||
|
||||
await page
|
||||
.getByRole('link', { exact: true, name: 'Movies' })
|
||||
.click();
|
||||
await clickCategoryByNameExact(page, vod.categoryName);
|
||||
await clickFirstGridListCard(page);
|
||||
await expect(
|
||||
page.locator('app-content-hero .hero__backdrop-image')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await addCurrentDetailToFavorites(page);
|
||||
const movie = await clipMasks();
|
||||
// The detail actions' ghost surfaces are backdrop-filtered.
|
||||
expect(movie.backdropFilterLayers, 'movie detail').toBeGreaterThan(
|
||||
0
|
||||
);
|
||||
expect(movie.masks, 'movie detail').toEqual([]);
|
||||
await goBackFromDetail(page);
|
||||
|
||||
await page
|
||||
.getByRole('link', { exact: true, name: 'Series' })
|
||||
.click();
|
||||
await clickCategoryByNameExact(page, series.categoryName);
|
||||
await clickFirstGridListCard(page);
|
||||
await expect(page.locator('app-content-hero')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await addCurrentDetailToFavorites(page);
|
||||
expect((await clipMasks()).masks, 'series detail').toEqual([]);
|
||||
|
||||
await goToDashboard(page);
|
||||
await expect(page.getByTestId('dashboard-hero')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId('dashboard-xtream-recently-added-rail')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
const dashboard = await clipMasks();
|
||||
// The composited effects this guards: every rail has two
|
||||
// backdrop-filtered chevrons, the hero its controls. Without this
|
||||
// the mask check would pass on a page with nothing to clip.
|
||||
expect(dashboard.backdropFilterLayers, 'dashboard').toBeGreaterThan(
|
||||
0
|
||||
);
|
||||
expect(dashboard.masks, 'dashboard').toEqual([]);
|
||||
await cdp.detach();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { CDPSession, Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickCategoryByNameExact,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expect,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
workspaceRoot,
|
||||
type LaunchedElectronApp,
|
||||
} from '../electron-test-fixtures';
|
||||
import {
|
||||
addCurrentDetailToFavorites,
|
||||
goBackFromDetail,
|
||||
toggleFavoriteForChannel,
|
||||
} from '../dashboard-e2e-flows';
|
||||
import {
|
||||
captureTileMemory,
|
||||
collectCompositedLayers,
|
||||
describeLayerOwner,
|
||||
describeLayerSize,
|
||||
findOversizedClipMasks,
|
||||
layerDevicePixels,
|
||||
layerScale,
|
||||
measureContentArea,
|
||||
routeDeterministicArtwork,
|
||||
TILE_POOL_SETTLE_MS,
|
||||
} from '../performance/compositing-probe';
|
||||
import {
|
||||
COMPOSITING_SUMMARY_SCHEMA_VERSION,
|
||||
formatCompositingTable,
|
||||
resolveCompositingSummaryPath,
|
||||
writeCompositingSummary,
|
||||
type CompositingRouteReading,
|
||||
type CompositingSummary,
|
||||
} from '../performance/compositing-report';
|
||||
import {
|
||||
fetchXtreamLiveFixture,
|
||||
fetchXtreamSeriesFixture,
|
||||
fetchXtreamVodFixture,
|
||||
getXtreamTitle,
|
||||
} from '../portal-mock-fixtures';
|
||||
|
||||
/**
|
||||
* Compositing report: the renderer's tile memory and composited layers on
|
||||
* the routes with the most artwork and effects, one fresh profile seeded
|
||||
* through the app's dialogs against the Xtream mock, at a fixed window size.
|
||||
* It measures, it does not assert; the deterministic guard is
|
||||
* `compositing.e2e.ts`. Contract:
|
||||
* docs/architecture/performance-journeys.md#compositing-budget.
|
||||
*/
|
||||
const WINDOW = { height: 1000, width: 1600 };
|
||||
const LARGEST_LAYERS = 8;
|
||||
/** The hero backdrop's zoom transition (`transform 8s` in its stylesheet). */
|
||||
const HERO_ZOOM_MS = 8_000;
|
||||
/** Into the 700 ms crossfade, where the animations are frozen for sampling. */
|
||||
const CROSSFADE_SAMPLE_MS = 300;
|
||||
const TILE_WARNING = /tile memory limits exceeded/;
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function setWindowContentSize(app: LaunchedElectronApp): Promise<void> {
|
||||
await app.electronApp.evaluate(({ BrowserWindow }, size) => {
|
||||
const [window] = BrowserWindow.getAllWindows();
|
||||
window.setPosition(0, 0);
|
||||
window.setContentSize(size.width, size.height);
|
||||
}, WINDOW);
|
||||
}
|
||||
|
||||
async function readGpuCompositing(app: LaunchedElectronApp): Promise<string> {
|
||||
const status = await app.electronApp.evaluate(({ app: electronApp }) =>
|
||||
electronApp.getGPUFeatureStatus()
|
||||
);
|
||||
return String(
|
||||
(status as unknown as Record<string, unknown>)['gpu_compositing']
|
||||
);
|
||||
}
|
||||
|
||||
async function readRoute(
|
||||
cdp: CDPSession,
|
||||
page: Page,
|
||||
route: string
|
||||
): Promise<CompositingRouteReading> {
|
||||
const area = await measureContentArea(page);
|
||||
const layers = await collectCompositedLayers(cdp, page);
|
||||
const largest = [...layers]
|
||||
.filter((layer) => layer.drawsContent)
|
||||
.sort((a, b) => layerDevicePixels(b) - layerDevicePixels(a))
|
||||
.slice(0, LARGEST_LAYERS);
|
||||
const largestLayers = [];
|
||||
for (const layer of largest) {
|
||||
largestLayers.push({
|
||||
devicePixelsMB: Number(
|
||||
((layerDevicePixels(layer) * 4) / 1_048_576).toFixed(1)
|
||||
),
|
||||
height: layer.height,
|
||||
owner: await describeLayerOwner(cdp, layer),
|
||||
reasons: layer.reasons,
|
||||
scale: Number(layerScale(layer).x.toFixed(3)),
|
||||
width: layer.width,
|
||||
});
|
||||
}
|
||||
const memory = await captureTileMemory(cdp);
|
||||
return {
|
||||
backdropFilterLayers: layers.filter((layer) =>
|
||||
layer.reasons.includes('BackdropFilter')
|
||||
).length,
|
||||
clipMasks: findOversizedClipMasks(layers, area).map(describeLayerSize),
|
||||
drawingLayers: layers.filter((layer) => layer.drawsContent).length,
|
||||
imageMB: memory.imageMB,
|
||||
largestLayers,
|
||||
route,
|
||||
tileMB: memory.tileMB,
|
||||
tileResources: memory.resourceCount,
|
||||
tileWarnings: 0,
|
||||
totalLayers: layers.length,
|
||||
url: new URL(page.url()).pathname.replace(/^.*\/dist\/apps\/web/, ''),
|
||||
};
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('compositing report', async ({ dataDir, request }) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const credentials = {
|
||||
password: defaultXtreamPassword,
|
||||
username: defaultXtreamUsername,
|
||||
};
|
||||
const live = await fetchXtreamLiveFixture(request, credentials);
|
||||
const vod = await fetchXtreamVodFixture(request, credentials);
|
||||
const series = await fetchXtreamSeriesFixture(request, credentials);
|
||||
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: { ELECTRON_ENABLE_LOGGING: '1' },
|
||||
});
|
||||
const page = app.mainWindow;
|
||||
let tileWarnings = 0;
|
||||
// A chunk can end mid-line: only complete lines are counted, the rest
|
||||
// waits for the next chunk.
|
||||
let stderrRemainder = '';
|
||||
app.electronApp.process().stderr?.on('data', (chunk: Buffer) => {
|
||||
const lines = (stderrRemainder + chunk.toString()).split('\n');
|
||||
stderrRemainder = lines.pop() ?? '';
|
||||
tileWarnings += lines.filter((line) => TILE_WARNING.test(line)).length;
|
||||
});
|
||||
const routes: CompositingRouteReading[] = [];
|
||||
|
||||
try {
|
||||
await routeDeterministicArtwork(page);
|
||||
// The same motion on every machine: with the OS set to reduced
|
||||
// motion the hero neither rotates nor renders its pause button, and
|
||||
// the crossfade reading would measure a different animation set.
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await setWindowContentSize(app);
|
||||
await sleep(500);
|
||||
const cdp = await app.electronApp.context().newCDPSession(page);
|
||||
let warningsAtLastReading = 0;
|
||||
const measure = async (route: string, settleMs: number) => {
|
||||
await sleep(settleMs);
|
||||
const reading = await readRoute(cdp, page, route);
|
||||
// One boundary for the delta and the checkpoint, taken after the
|
||||
// sampling: a warning logged while the layers or the dump were
|
||||
// read belongs to this route, not to no route.
|
||||
const warningsNow = tileWarnings;
|
||||
reading.tileWarnings = warningsNow - warningsAtLastReading;
|
||||
warningsAtLastReading = warningsNow;
|
||||
routes.push(reading);
|
||||
console.log(
|
||||
`[compositing] ${route}: tile ${reading.tileMB ?? '-'} MB, ${reading.drawingLayers}/${reading.totalLayers} layers, ${reading.clipMasks.length} masks, ${reading.tileWarnings} warnings`
|
||||
);
|
||||
};
|
||||
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await openWorkspaceSection(page, 'Live TV');
|
||||
await clickCategoryByNameExact(page, live.categoryName);
|
||||
await toggleFavoriteForChannel(page, getXtreamTitle(live.items[0]));
|
||||
await measure('live-tv', TILE_POOL_SETTLE_MS);
|
||||
|
||||
await page.getByRole('link', { exact: true, name: 'Movies' }).click();
|
||||
await clickCategoryByNameExact(page, vod.categoryName);
|
||||
await clickFirstGridListCard(page);
|
||||
await expect(
|
||||
page.locator('app-content-hero .hero__backdrop-image')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await measure('movie-detail', TILE_POOL_SETTLE_MS);
|
||||
await addCurrentDetailToFavorites(page);
|
||||
await goBackFromDetail(page);
|
||||
|
||||
await page.getByRole('link', { exact: true, name: 'Series' }).click();
|
||||
await clickCategoryByNameExact(page, series.categoryName);
|
||||
await clickFirstGridListCard(page);
|
||||
await expect(page.locator('app-content-hero')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await measure('series-detail', TILE_POOL_SETTLE_MS);
|
||||
await addCurrentDetailToFavorites(page);
|
||||
|
||||
await goToDashboard(page);
|
||||
await expect(page.getByTestId('dashboard-hero')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect
|
||||
.poll(() => page.getByTestId('dashboard-hero-dot').count(), {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
// Idle means no motion: rotation paused (it would advance after 8 s,
|
||||
// into another slide's zoom), the current slide's zoom finished and
|
||||
// the tile pool settled. The pointer stays off the hero.
|
||||
const pause = page.getByTestId('dashboard-hero-pause');
|
||||
await pause.click();
|
||||
await expect(pause).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.mouse.move(1, 1);
|
||||
await measure('dashboard', HERO_ZOOM_MS + TILE_POOL_SETTLE_MS);
|
||||
|
||||
const dots = page.getByTestId('dashboard-hero-dot');
|
||||
const active = await dots.evaluateAll((all) =>
|
||||
all.findIndex((dot) => dot.getAttribute('aria-current') === 'true')
|
||||
);
|
||||
// Deliberately mid-motion: the outgoing and incoming slides are
|
||||
// both composited while the crossfade and the zoom run. The page's
|
||||
// animations are frozen at a known point of the 700 ms fade while
|
||||
// the layers and the memory are read, so the reading does not
|
||||
// depend on how long the sampling takes.
|
||||
await dots.nth((active + 1) % (await dots.count())).click();
|
||||
await page.mouse.move(1, 1);
|
||||
await sleep(CROSSFADE_SAMPLE_MS);
|
||||
await cdp.send('Animation.enable');
|
||||
await cdp.send('Animation.setPlaybackRate', { playbackRate: 0 });
|
||||
try {
|
||||
await measure('dashboard-crossfade', 0);
|
||||
} finally {
|
||||
await cdp.send('Animation.setPlaybackRate', { playbackRate: 1 });
|
||||
await cdp.send('Animation.disable');
|
||||
}
|
||||
// The incoming slide's zoom runs on after the freeze; the scrolled
|
||||
// reading is taken once it has finished.
|
||||
await sleep(HERO_ZOOM_MS);
|
||||
|
||||
await page.mouse.move(WINDOW.width / 2, WINDOW.height / 2);
|
||||
for (let step = 0; step < 8; step += 1) {
|
||||
await page.mouse.wheel(0, 250);
|
||||
await sleep(100);
|
||||
}
|
||||
await page.mouse.move(1, 1);
|
||||
await measure('dashboard-scrolled', TILE_POOL_SETTLE_MS);
|
||||
|
||||
await openSettings(page);
|
||||
await measure('settings', TILE_POOL_SETTLE_MS);
|
||||
|
||||
const summary: CompositingSummary = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
harness: {
|
||||
arch: process.arch,
|
||||
electronVersion: await app.electronApp.evaluate(
|
||||
() => process.versions['electron'] ?? 'unknown'
|
||||
),
|
||||
gpuCompositing: await readGpuCompositing(app),
|
||||
platform: process.platform,
|
||||
window: {
|
||||
...WINDOW,
|
||||
dpr: await page.evaluate(() => window.devicePixelRatio),
|
||||
},
|
||||
},
|
||||
routes,
|
||||
schemaVersion: COMPOSITING_SUMMARY_SCHEMA_VERSION,
|
||||
};
|
||||
const summaryPath = resolveCompositingSummaryPath(workspaceRoot);
|
||||
await writeCompositingSummary(summaryPath, summary);
|
||||
console.log(formatCompositingTable(summary));
|
||||
console.log(`[compositing] summary: ${summaryPath}`);
|
||||
expect(routes.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -888,7 +888,12 @@ test('@electron @dash ClearKey DASH filters DRM fallback and reports external la
|
||||
await expect(mpvFallback).toHaveClass(
|
||||
/web-player-diagnostic__player-card--primary/
|
||||
);
|
||||
await expect(dock).toContainText('E2E player launch failed');
|
||||
// A failure without an error code shows the translated generic
|
||||
// status; the raw main-process detail stays available as a tooltip.
|
||||
await expect(dock).toContainText('External player error');
|
||||
await expect(
|
||||
dock.locator('.external-playback-dock__status-text')
|
||||
).toHaveAttribute('title', 'E2E player launch failed');
|
||||
await expect(
|
||||
dock.getByRole('button', { name: 'Dismiss' })
|
||||
).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickCategoryByNameExact,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expect,
|
||||
expectVisibleContentCardTitle,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
openGlobalRecent,
|
||||
openSources,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
xtreamMockServer,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
fetchXtreamSeriesFixture,
|
||||
fetchXtreamVodFixture,
|
||||
} from './portal-mock-fixtures';
|
||||
import {
|
||||
routePlayableStreams,
|
||||
startAndConfirmPlayback,
|
||||
} from './playable-stream-fixture';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Continue Watching lists only unfinished titles
|
||||
//
|
||||
// A title counts as watched once its saved position reaches 90% of the
|
||||
// runtime (PORTAL_WATCHED_PROGRESS_PERCENT), so a movie stopped during its end
|
||||
// credits leaves the dashboard's Continue Watching rail, as does one the user
|
||||
// marks watched from the card menu. Both stay in the watch history. The
|
||||
// positions are written through the real SQLite bridge: the clip the tests
|
||||
// stream lasts seconds, far from any real runtime.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RAIL = 'dashboard-continue-watching-rail';
|
||||
|
||||
type MoviePick = { title: string; playlistId: string; vodId: number };
|
||||
|
||||
async function playMovieAt(page: Page, index: number): Promise<MoviePick> {
|
||||
const card = page.locator('.category-content-layout mat-card').nth(index);
|
||||
await expect(card).toBeVisible({ timeout: 20_000 });
|
||||
let title = '';
|
||||
await expect(async () => {
|
||||
title = ((await card.locator('.title').textContent()) ?? '').trim();
|
||||
expect(title.length).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: 10_000 });
|
||||
await card.click();
|
||||
await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/);
|
||||
const [, playlistId, vodId] =
|
||||
new URL(page.url()).pathname.match(
|
||||
/\/workspace\/xtreams\/([^/]+)\/vod\/[^/]+\/([^/]+)$/
|
||||
) ?? [];
|
||||
|
||||
// Recorded as recently viewed once it has really played.
|
||||
await startAndConfirmPlayback(page, async () => {
|
||||
const playButton = page.locator('button.play-btn').first();
|
||||
await expect(playButton).toBeVisible({ timeout: 20_000 });
|
||||
await playButton.click();
|
||||
});
|
||||
const backButton = page.getByTestId('workspace-header-back');
|
||||
await expect(backButton).toBeVisible({ timeout: 20_000 });
|
||||
await backButton.click();
|
||||
await expect(page.locator('app-web-player-view')).toHaveCount(0, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
return { title, playlistId, vodId: Number(vodId) };
|
||||
}
|
||||
|
||||
async function saveMoviePosition(
|
||||
page: Page,
|
||||
movie: MoviePick,
|
||||
positionSeconds: number,
|
||||
durationSeconds: number
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ playlistId, data }) =>
|
||||
window.electron.dbSavePlaybackPosition(playlistId, data),
|
||||
{
|
||||
playlistId: movie.playlistId,
|
||||
data: {
|
||||
contentXtreamId: movie.vodId,
|
||||
contentType: 'vod' as const,
|
||||
positionSeconds,
|
||||
durationSeconds,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function railCard(page: Page, title: string) {
|
||||
return page
|
||||
.locator(`[data-test-id="${RAIL}-card"]`)
|
||||
.filter({ hasText: title });
|
||||
}
|
||||
|
||||
/** The ids of a mock series' season 1 episodes, in episode order. */
|
||||
async function fetchSeasonOneEpisodeIds(
|
||||
request: APIRequestContext,
|
||||
seriesId: number
|
||||
): Promise<number[]> {
|
||||
const params = new URLSearchParams({
|
||||
action: 'get_series_info',
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
series_id: String(seriesId),
|
||||
});
|
||||
const response = await request.get(
|
||||
`${xtreamMockServer}/player_api.php?${params}`
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const info = (await response.json()) as {
|
||||
episodes?: Record<string, { id: string; episode_num: number }[]>;
|
||||
};
|
||||
return [...(info.episodes?.['1'] ?? [])]
|
||||
.sort((a, b) => a.episode_num - b.episode_num)
|
||||
.map((episode) => Number(episode.id));
|
||||
}
|
||||
|
||||
async function playFirstEpisode(page: Page): Promise<void> {
|
||||
const seasonCard = page.locator('.season-card').first();
|
||||
const episodeCard = page
|
||||
.locator('.episode-card, .episode-list-item')
|
||||
.first();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await seasonCard.count()) + (await episodeCard.count()),
|
||||
{ timeout: 20_000 }
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
if ((await seasonCard.count()) > 0) {
|
||||
await seasonCard.click();
|
||||
}
|
||||
await episodeCard.click();
|
||||
}
|
||||
|
||||
test.describe('Dashboard Continue Watching', () => {
|
||||
test('@dashboard @persistence @electron leaves finished movies off Continue Watching and keeps them in the history', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const vodFixture = await fetchXtreamVodFixture(request, {
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
});
|
||||
const app = await launchElectronApp(dataDir);
|
||||
await routePlayableStreams(app.mainWindow);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Continue Watching Source',
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
vodFixture.categoryName
|
||||
);
|
||||
const finished = await playMovieAt(app.mainWindow, 0);
|
||||
const halfway = await playMovieAt(app.mainWindow, 1);
|
||||
expect(halfway.title).not.toBe(finished.title);
|
||||
|
||||
// Stopped while the end credits rolled: 61 s of 59 min left.
|
||||
await saveMoviePosition(app.mainWindow, finished, 3491, 3552);
|
||||
await saveMoviePosition(app.mainWindow, halfway, 2700, 5400);
|
||||
|
||||
await goToDashboard(app.mainWindow);
|
||||
await expect(railCard(app.mainWindow, halfway.title)).toHaveCount(
|
||||
1,
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
await expect(railCard(app.mainWindow, finished.title)).toHaveCount(
|
||||
0
|
||||
);
|
||||
|
||||
await railCard(app.mainWindow, halfway.title)
|
||||
.locator(`[data-test-id="${RAIL}-card-actions"]`)
|
||||
.click();
|
||||
await app.mainWindow
|
||||
.getByRole('menuitem', { name: /mark as watched/i })
|
||||
.click();
|
||||
|
||||
// Nothing unfinished is left, so the rail itself goes away.
|
||||
await expect(
|
||||
app.mainWindow.locator(`[data-test-id="${RAIL}"]`)
|
||||
).toHaveCount(0, { timeout: 20_000 });
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.mainWindow.evaluate(
|
||||
async ({ playlistId, vodId }) =>
|
||||
(
|
||||
await window.electron.dbGetAllPlaybackPositions(
|
||||
playlistId
|
||||
)
|
||||
).find(
|
||||
(row) =>
|
||||
row.contentType === 'vod' &&
|
||||
row.contentXtreamId === vodId
|
||||
),
|
||||
halfway
|
||||
)
|
||||
)
|
||||
.toMatchObject({
|
||||
positionSeconds: 5400,
|
||||
durationSeconds: 5400,
|
||||
});
|
||||
|
||||
// The page lists only movies here, so it shows no content toggle.
|
||||
await openGlobalRecent(app.mainWindow);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, finished.title);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, halfway.title);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@dashboard @xtream @electron keeps a series on Continue Watching with the episode after the one watched last', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const seriesFixture = await fetchXtreamSeriesFixture(request, {
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
});
|
||||
const app = await launchElectronApp(dataDir);
|
||||
await routePlayableStreams(app.mainWindow);
|
||||
const page = app.mainWindow;
|
||||
|
||||
try {
|
||||
await addXtreamPortal(page, { name: 'Continue Watching Series' });
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await page
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(page, seriesFixture.categoryName);
|
||||
const seriesTitle = await clickFirstGridListCard(page);
|
||||
const playlistId = new URL(page.url()).pathname.match(
|
||||
/\/workspace\/xtreams\/([^/]+)\//
|
||||
)?.[1];
|
||||
expect(playlistId).toBeTruthy();
|
||||
await startAndConfirmPlayback(page, () => playFirstEpisode(page));
|
||||
|
||||
const episodeRow = () =>
|
||||
page.evaluate(
|
||||
async (id) =>
|
||||
(
|
||||
await window.electron.dbGetAllPlaybackPositions(id)
|
||||
).find((row) => row.contentType === 'episode') ?? null,
|
||||
playlistId as string
|
||||
);
|
||||
await expect.poll(episodeRow).not.toBeNull();
|
||||
const played = await episodeRow();
|
||||
if (!played) {
|
||||
throw new Error('expected the played episode to be saved');
|
||||
}
|
||||
expect(played).toMatchObject({ seasonNumber: 1, episodeNumber: 1 });
|
||||
|
||||
const backButton = page.getByTestId('workspace-header-back');
|
||||
await expect(backButton).toBeVisible({ timeout: 20_000 });
|
||||
await backButton.click();
|
||||
await expect(page.locator('app-web-player-view')).toHaveCount(0, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
// Stopped during the end credits.
|
||||
await page.evaluate(
|
||||
({ id, row }) =>
|
||||
window.electron.dbSavePlaybackPosition(id, {
|
||||
contentXtreamId: row.contentXtreamId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: row.seriesXtreamId,
|
||||
seasonNumber: row.seasonNumber,
|
||||
episodeNumber: row.episodeNumber,
|
||||
positionSeconds: 3491,
|
||||
durationSeconds: 3552,
|
||||
}),
|
||||
{ id: playlistId as string, row: played }
|
||||
);
|
||||
|
||||
await goToDashboard(page);
|
||||
const card = railCard(page, seriesTitle);
|
||||
await expect(card).toHaveCount(1, { timeout: 20_000 });
|
||||
await expect(card).toContainText('S1·E2');
|
||||
|
||||
await card.locator(`[data-test-id="${RAIL}-card-actions"]`).click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: /resume episode/i })
|
||||
.click();
|
||||
await expect(
|
||||
page.locator('.player-shell__episode-meta')
|
||||
).toContainText('S01E02', { timeout: 30_000 });
|
||||
|
||||
// Skipping ahead: episode 3 is finished after episode 2 was
|
||||
// left. The series goes on after the episode watched last, not
|
||||
// back to the one left behind.
|
||||
await backButton.click();
|
||||
await expect(page.locator('app-web-player-view')).toHaveCount(0, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
const seasonOne = await fetchSeasonOneEpisodeIds(
|
||||
request,
|
||||
Number(played.seriesXtreamId)
|
||||
);
|
||||
expect(seasonOne.length).toBeGreaterThanOrEqual(4);
|
||||
expect(seasonOne[0]).toBe(played.contentXtreamId);
|
||||
await page.evaluate(
|
||||
({ id, row, episodeId }) =>
|
||||
window.electron.dbSavePlaybackPosition(id, {
|
||||
contentXtreamId: episodeId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: row.seriesXtreamId,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 3,
|
||||
positionSeconds: 3491,
|
||||
durationSeconds: 3552,
|
||||
}),
|
||||
{
|
||||
id: playlistId as string,
|
||||
row: played,
|
||||
episodeId: seasonOne[2],
|
||||
}
|
||||
);
|
||||
|
||||
// Back from the player returned to the dashboard, which read the
|
||||
// positions before episode 3 was saved: open it anew.
|
||||
await openSources(page);
|
||||
await goToDashboard(page);
|
||||
await expect(card).toContainText('S1·E4', { timeout: 20_000 });
|
||||
await card.locator(`[data-test-id="${RAIL}-card-actions"]`).click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: /resume episode/i })
|
||||
.click();
|
||||
await expect(
|
||||
page.locator('.player-shell__episode-meta')
|
||||
).toContainText('S01E04', { timeout: 30_000 });
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -119,7 +119,9 @@ async function showSlide(page: Page, index: number): Promise<Locator> {
|
||||
page.evaluate(() =>
|
||||
[
|
||||
document.querySelector('.hero__backdrop--active'),
|
||||
document.querySelector('.hero__content'),
|
||||
document.querySelector(
|
||||
'[data-test-id=dashboard-hero-slide]'
|
||||
),
|
||||
].every(
|
||||
(element) =>
|
||||
element &&
|
||||
@@ -135,9 +137,16 @@ async function showSlide(page: Page, index: number): Promise<Locator> {
|
||||
return page.getByTestId('dashboard-hero-slide');
|
||||
}
|
||||
|
||||
/** Sum of non-input layout shifts while the hero runs through every slide
|
||||
* on its own, at a shortened interval. */
|
||||
async function rotationLayoutShift(page: Page): Promise<number> {
|
||||
interface RotationShift {
|
||||
/** Sum of the non-input layout-shift scores. */
|
||||
score: number;
|
||||
/** What moved and by how much, e.g. `LIB-DASHBOARD-RAIL 0,7`. */
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
/** Non-input layout shifts while the hero runs through every slide on its
|
||||
* own, at a shortened interval. */
|
||||
async function rotationLayoutShift(page: Page): Promise<RotationShift> {
|
||||
const hero = page.getByTestId('dashboard-hero');
|
||||
const dots = page.getByTestId('dashboard-hero-dot');
|
||||
const count = await dots.count();
|
||||
@@ -147,18 +156,38 @@ async function rotationLayoutShift(page: Page): Promise<number> {
|
||||
'--hero-rotation-ms',
|
||||
'600ms'
|
||||
);
|
||||
const shifts: number[] = [];
|
||||
const shifts: RotationShift = { score: 0, sources: [] };
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries() as (PerformanceEntry & {
|
||||
value: number;
|
||||
hadRecentInput: boolean;
|
||||
sources: {
|
||||
node: Node | null;
|
||||
previousRect: DOMRectReadOnly;
|
||||
currentRect: DOMRectReadOnly;
|
||||
}[];
|
||||
})[]) {
|
||||
if (!entry.hadRecentInput) {
|
||||
shifts.push(entry.value);
|
||||
if (entry.hadRecentInput) {
|
||||
continue;
|
||||
}
|
||||
shifts.score += entry.value;
|
||||
for (const {
|
||||
node,
|
||||
previousRect,
|
||||
currentRect,
|
||||
} of entry.sources) {
|
||||
const name =
|
||||
node instanceof Element
|
||||
? [node.nodeName, ...node.classList].join('.')
|
||||
: (node?.nodeName ?? 'removed');
|
||||
const dx = Math.round(currentRect.x - previousRect.x);
|
||||
const dy = Math.round(currentRect.y - previousRect.y);
|
||||
shifts.sources.push(`${name} ${dx},${dy}`);
|
||||
}
|
||||
}
|
||||
}).observe({ type: 'layout-shift' });
|
||||
(window as unknown as { __heroShifts: number[] }).__heroShifts = shifts;
|
||||
(window as unknown as { __heroShifts: RotationShift }).__heroShifts =
|
||||
shifts;
|
||||
});
|
||||
// Back to the first slide after one full cycle, then a quiet moment.
|
||||
const first = await dots.evaluateAll((all) =>
|
||||
@@ -172,19 +201,37 @@ async function rotationLayoutShift(page: Page): Promise<number> {
|
||||
);
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
return page.evaluate(() =>
|
||||
(window as unknown as { __heroShifts: number[] }).__heroShifts.reduce(
|
||||
(sum, value) => sum + value,
|
||||
0
|
||||
)
|
||||
return page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __heroShifts: RotationShift }).__heroShifts
|
||||
);
|
||||
}
|
||||
|
||||
/** Adds what a TMDB-enriched slide shows: a rating chip and an overview. */
|
||||
/** One unattended rotation moves next to nothing on the page, and nothing
|
||||
* at all inside the hero. */
|
||||
async function expectStableRotation(
|
||||
page: Page,
|
||||
label: string,
|
||||
results: string[]
|
||||
): Promise<void> {
|
||||
const shift = await rotationLayoutShift(page);
|
||||
const sources = shift.sources.join('; ');
|
||||
results.push(
|
||||
`${label} rotation layout shift ${shift.score.toFixed(5)} [${sources}]`
|
||||
);
|
||||
expect(shift.score, `${label}: ${sources}`).toBeLessThan(0.001);
|
||||
expect(
|
||||
shift.sources.filter((source) => source.includes('hero__')),
|
||||
`${label}: nothing in the hero moves`
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
/** Adds what a TMDB-enriched slide shows: a rating chip and an overview.
|
||||
* Once per slide: a slide keeps its content while another one is shown. */
|
||||
async function enrichSlide(slide: Locator): Promise<void> {
|
||||
await slide.evaluate((content, overview) => {
|
||||
const chip = content.querySelector('.hero__pill');
|
||||
if (chip) {
|
||||
if (chip && !content.querySelector('.meta-chip--rating')) {
|
||||
const rating = chip.cloneNode() as HTMLElement;
|
||||
rating.classList.add('meta-chip--rating');
|
||||
rating.textContent = '★ 7.4';
|
||||
@@ -287,15 +334,16 @@ test.describe('Dashboard hero legibility', () => {
|
||||
).toHaveCount(1);
|
||||
|
||||
// An unattended rotation, counted like the launch journey's
|
||||
// settled layout-shift counter (non-input shifts only). Slides of
|
||||
// different heights still resize the hero by a few pixels and
|
||||
// move the rails below (0.005 here, 0.013 before this change);
|
||||
// a scrim or heading that reflowed the slide would add lines.
|
||||
const shift = await rotationLayoutShift(page);
|
||||
results.push(`rotation layout shift ${shift.toFixed(3)}`);
|
||||
expect(shift).toBeLessThan(0.02);
|
||||
// settled layout-shift counter (non-input shifts only), moves
|
||||
// nothing. The hero lays out every slide and is as tall as the
|
||||
// tallest; with only the shown slide in flow, slides of different
|
||||
// heights resized it and moved every rail below by 7px (a score
|
||||
// of 0.005). Every rotation dot keeps its width, so the active
|
||||
// one no longer pushes its neighbours either.
|
||||
await expectStableRotation(page, 'wide', results);
|
||||
|
||||
await page.getByTestId('dashboard-hero-pause').click();
|
||||
const pause = page.getByTestId('dashboard-hero-pause');
|
||||
await pause.click();
|
||||
const kinds = await slideKinds(page);
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
@@ -304,7 +352,7 @@ test.describe('Dashboard hero legibility', () => {
|
||||
// The narrow layout is the dashboard container's
|
||||
// ≤720px query, not the window width.
|
||||
const narrow = await page
|
||||
.locator('.hero__content')
|
||||
.getByTestId('dashboard-hero-slide')
|
||||
.evaluate(
|
||||
(content) =>
|
||||
getComputedStyle(content).maxWidth === 'none'
|
||||
@@ -342,6 +390,12 @@ test.describe('Dashboard hero legibility', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Again at the narrow width, where slides wrap the most, now that
|
||||
// every slide also carries a rating and a two-line overview.
|
||||
await pause.click();
|
||||
await expect(pause).toHaveAttribute('aria-pressed', 'false');
|
||||
await expectStableRotation(page, 'narrow enriched', results);
|
||||
} finally {
|
||||
const report = testInfo.outputPath('contrast.txt');
|
||||
writeFileSync(report, results.join('\n'));
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
expectRendererReloadedOnRoute,
|
||||
reloadFromMainProcess,
|
||||
} from './renderer-reload.support';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A detail page's Back lives in the workspace header's leading slot, not in
|
||||
@@ -29,6 +33,10 @@ import {
|
||||
// header's history fallback while an in-app previous page exists; on a
|
||||
// phone it yields to the drawer toggle, and with nowhere to go the slot is
|
||||
// empty rather than a disabled arrow.
|
||||
//
|
||||
// A page with a parent route (settings here) that opened the session, as
|
||||
// after a reload, has no in-app entry for history Back: its Back leads to
|
||||
// the parent instead and replaces the page's entry.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const widths = [1280, 780, 375];
|
||||
@@ -228,7 +236,7 @@ async function openFirstSeries(page: Page): Promise<string> {
|
||||
timeout: 20_000,
|
||||
}
|
||||
);
|
||||
await expect(page.locator('.episode-card').first()).toBeVisible({
|
||||
await expect(page.locator('.episode-item').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
return page.url();
|
||||
@@ -236,7 +244,7 @@ async function openFirstSeries(page: Page): Promise<string> {
|
||||
|
||||
async function startFirstEpisode(page: Page): Promise<void> {
|
||||
const shell = page.locator('app-portal-detail-shell');
|
||||
await page.locator('.episode-card').first().click();
|
||||
await page.locator('.episode-item').first().click();
|
||||
await expect(shell).toHaveClass(/shell-host--watch/);
|
||||
await expect(
|
||||
shell.locator('app-portal-inline-player app-web-player-view')
|
||||
@@ -329,7 +337,9 @@ test.describe('Portal detail header Back', () => {
|
||||
// On a phone the list's drawer toggle keeps the slot: it is the
|
||||
// only way into the categories.
|
||||
await page.setViewportSize({ width: 375, height: 800 });
|
||||
await expect(page.getByTestId('context-drawer-toggle')).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId('context-drawer-toggle')
|
||||
).toBeVisible();
|
||||
await expect(headerBack(page)).toBeHidden();
|
||||
|
||||
await page.setViewportSize({ width: widths[0], height: 800 });
|
||||
@@ -346,4 +356,33 @@ test.describe('Portal detail header Back', () => {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@electron @settings settings Back after a reload leads to the dashboard', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
await page.waitForURL(/\/workspace\/dashboard$/);
|
||||
await openSettings(page);
|
||||
|
||||
// The reloaded document re-boots on the settings route; the
|
||||
// dashboard entry before it belongs to the old document.
|
||||
await reloadFromMainProcess(app);
|
||||
await expectRendererReloadedOnRoute(
|
||||
page,
|
||||
/\/workspace\/settings\/general$/
|
||||
);
|
||||
await expect(page.getByTestId('settings-container')).toBeVisible();
|
||||
|
||||
await headerBack(page).click();
|
||||
await expect(page).toHaveURL(/\/workspace\/dashboard$/);
|
||||
// The dashboard replaced the settings entry: nothing in this
|
||||
// document precedes it, so the header offers no Back.
|
||||
await expect(headerBack(page)).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -32,34 +32,13 @@ import {
|
||||
RANGE_SERVER_ETAG,
|
||||
startDownload,
|
||||
} from './downloads.e2e-support';
|
||||
import { navigateWithinWorkspace } from './workspace-route.e2e-support';
|
||||
|
||||
async function openDownloadsPage(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Open downloads' }).click();
|
||||
await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/);
|
||||
}
|
||||
|
||||
async function navigateWithinWorkspace(
|
||||
page: Page,
|
||||
path: string
|
||||
): Promise<void> {
|
||||
const targetPathname = await page.evaluate((target) => {
|
||||
const targetUrl = new URL(window.location.href);
|
||||
const workspaceIndex = targetUrl.pathname.lastIndexOf('/workspace');
|
||||
const rendererPath =
|
||||
workspaceIndex >= 0
|
||||
? targetUrl.pathname.slice(0, workspaceIndex)
|
||||
: targetUrl.pathname.replace(/\/$/, '');
|
||||
|
||||
targetUrl.pathname = `${rendererPath}${target}`;
|
||||
targetUrl.search = '';
|
||||
targetUrl.hash = '';
|
||||
window.history.pushState(null, '', targetUrl);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
return targetUrl.pathname;
|
||||
}, path);
|
||||
await page.waitForURL((url) => url.pathname === targetPathname);
|
||||
}
|
||||
|
||||
async function getPlaylistId(page: Page, title: string): Promise<string> {
|
||||
const playlists = await page.evaluate(
|
||||
async () => (await window.electron?.dbGetAppPlaylistMetas?.()) ?? []
|
||||
@@ -129,7 +108,7 @@ async function selectDownloadCoverSize(
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() => document.documentElement.dataset.coverSize ?? null
|
||||
() => document.documentElement.dataset['coverSize'] ?? null
|
||||
)
|
||||
)
|
||||
.toBe(size);
|
||||
@@ -361,6 +340,17 @@ test.describe('Electron Downloads', () => {
|
||||
await expect(missingRow.getByRole('status')).toContainText(
|
||||
'File missing'
|
||||
);
|
||||
// A name missing from the icon font renders as text that
|
||||
// overflows the icon box instead of one glyph.
|
||||
const missingIcon = missingRow
|
||||
.getByRole('status')
|
||||
.locator('mat-icon');
|
||||
await expect(missingIcon).toHaveText('error_outline');
|
||||
expect(
|
||||
await missingIcon.evaluate(
|
||||
(icon) => icon.scrollWidth <= icon.clientWidth
|
||||
)
|
||||
).toBe(true);
|
||||
await expect(card).toHaveCount(0);
|
||||
await expect(
|
||||
missingRow.getByRole('button', { name: 'Play: E2E Movie' })
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { terminateElectronProcess } from './electron-process-termination';
|
||||
|
||||
type ElectronChildProcess = Pick<
|
||||
// Only the exit subscription is used; picking ChildProcess's own once and
|
||||
// removeListener would demand that every listener host returns a ChildProcess.
|
||||
interface ElectronChildProcess extends Pick<
|
||||
ChildProcess,
|
||||
'exitCode' | 'kill' | 'once' | 'pid' | 'removeListener' | 'signalCode'
|
||||
>;
|
||||
'exitCode' | 'kill' | 'pid' | 'signalCode'
|
||||
> {
|
||||
once(event: 'exit', listener: () => void): unknown;
|
||||
removeListener(event: 'exit', listener: () => void): unknown;
|
||||
}
|
||||
|
||||
export interface ClosableElectronApplication {
|
||||
close(): Promise<void>;
|
||||
|
||||
@@ -33,6 +33,10 @@ import {
|
||||
type ElectronExitConfirmationOptions,
|
||||
prepareElectronApplication,
|
||||
} from './electron-process-lifecycle';
|
||||
import {
|
||||
waitForXtreamImportCompleted,
|
||||
xtreamPlaylistIdFromUrl,
|
||||
} from './xtream-import-wait';
|
||||
|
||||
export const workspaceRoot = resolve(__dirname, '../../..');
|
||||
export const electronMainPath = join(
|
||||
@@ -972,18 +976,33 @@ export async function openSettingsSection(
|
||||
await page.waitForURL(new RegExp(`/workspace/settings/${sectionId}$`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Material slide toggles render `aria-checked` after Angular's next change
|
||||
* detection, later than Playwright's `check()` looks, which then reports
|
||||
* that the click changed nothing. Click when needed and wait for the state.
|
||||
*/
|
||||
export async function setSwitch(
|
||||
toggle: Locator,
|
||||
checked: boolean
|
||||
): Promise<void> {
|
||||
if ((await toggle.isChecked()) !== checked) {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toBeChecked({ checked });
|
||||
}
|
||||
|
||||
export async function enableRemoteControl(
|
||||
page: Page,
|
||||
port: number
|
||||
): Promise<void> {
|
||||
await openSettingsSection(page, 'remote-control');
|
||||
|
||||
const remoteControlCheckbox = page.locator(
|
||||
'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]'
|
||||
const remoteControlSwitch = page.locator(
|
||||
'mat-slide-toggle[formcontrolname="remoteControl"] [role="switch"]'
|
||||
);
|
||||
|
||||
await remoteControlCheckbox.scrollIntoViewIfNeeded();
|
||||
await remoteControlCheckbox.check();
|
||||
await remoteControlSwitch.scrollIntoViewIfNeeded();
|
||||
await setSwitch(remoteControlSwitch, true);
|
||||
await page.locator('#remoteControlPort').fill(String(port));
|
||||
}
|
||||
|
||||
@@ -1488,7 +1507,7 @@ export async function selectSourceSort(
|
||||
await sortTrigger.click();
|
||||
|
||||
const option = page
|
||||
.locator('.cdk-overlay-pane [role="menuitem"]')
|
||||
.locator('.cdk-overlay-pane [role="menuitemradio"]')
|
||||
.filter({
|
||||
hasText: flexibleTextPattern(sortLabel),
|
||||
})
|
||||
@@ -1754,7 +1773,13 @@ export async function waitForXtreamImportToFinish(page: Page): Promise<void> {
|
||||
try {
|
||||
await overlay.waitFor({ state: 'visible', timeout: 5000 });
|
||||
} catch {
|
||||
// The overlay may already be gone by the time the test reaches this point.
|
||||
// The overlay may already be gone, or not shown yet: it appears only
|
||||
// once the portal has answered, so the import status decides below.
|
||||
}
|
||||
|
||||
const playlistId = xtreamPlaylistIdFromUrl(page.url());
|
||||
if (playlistId) {
|
||||
await waitForXtreamImportCompleted(page, playlistId);
|
||||
}
|
||||
|
||||
await expect(overlay).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
channelItemByTitle,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
saveSettings,
|
||||
test,
|
||||
workspaceRoot,
|
||||
type LaunchedElectronApp,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
createLocalMediaServer,
|
||||
installEmbeddedMpvSessionCapture,
|
||||
} from './embedded-mpv-frame-copy-packaged-fixtures';
|
||||
|
||||
/**
|
||||
* mpv's `chapter-list` must reach the shared controls: the chaptered fixture
|
||||
* (Intro 0 s, Episode 5 s, Credits 24 s of 30 s) is drawn as three titled
|
||||
* timeline segments. Frame-copy is the Embedded MPV engine that mounts
|
||||
* `app-player-controls`; the test skips where that runtime is unavailable.
|
||||
*/
|
||||
test('@playback @electron @embedded-mpv frame-copy draws file chapters on the timeline', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const media = await createLocalMediaServer({
|
||||
body: readFileSync(
|
||||
join(
|
||||
workspaceRoot,
|
||||
'apps/web-e2e/src/fixtures/playback/episode-chapters.webm'
|
||||
)
|
||||
),
|
||||
resourcePath: '/episode-chapters.webm',
|
||||
contentType: 'video/webm',
|
||||
});
|
||||
// The launch sits inside the cleanup scope: a failed launch must still
|
||||
// close the media server.
|
||||
let app: LaunchedElectronApp | undefined;
|
||||
try {
|
||||
const launched = await launchElectronApp(dataDir, {
|
||||
env: {
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1',
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY: '1',
|
||||
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW: '1',
|
||||
},
|
||||
});
|
||||
app = launched;
|
||||
const support = await launched.mainWindow.evaluate(() =>
|
||||
window.electron.getEmbeddedMpvSupport()
|
||||
);
|
||||
test.skip(
|
||||
!support.supported || support.engine !== 'frame-copy',
|
||||
`Frame-copy runtime unavailable: ${JSON.stringify(support)}`
|
||||
);
|
||||
await openSettings(launched.mainWindow);
|
||||
await openSettingsSection(launched.mainWindow, 'playback');
|
||||
await launched.mainWindow.getByTestId('select-video-player').click();
|
||||
await launched.mainWindow.getByTestId('embedded-mpv').click();
|
||||
await saveSettings(launched.mainWindow);
|
||||
await goToDashboard(launched.mainWindow);
|
||||
|
||||
const playlist = join(dataDir, 'chapters.m3u');
|
||||
writeFileSync(
|
||||
playlist,
|
||||
`#EXTM3U\n#EXTINF:-1,Chaptered fixture\n${media.url}\n`
|
||||
);
|
||||
await installEmbeddedMpvSessionCapture(launched);
|
||||
await importM3uPlaylistFromNativeDialog(launched, playlist);
|
||||
await channelItemByTitle(launched.mainWindow, 'Chaptered fixture')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// The local frame-copy runtime can fail a frame-view initialization,
|
||||
// and the failure can surface at any point while the session starts;
|
||||
// a user Retry recovers it (see player-theme). Retry whenever the
|
||||
// stalled state shows until mpv's chapters arrive.
|
||||
const stalled = launched.mainWindow.locator(
|
||||
'.embedded-mpv-player__stalled'
|
||||
);
|
||||
await expect(async () => {
|
||||
if (await stalled.isVisible()) {
|
||||
await stalled.getByRole('button', { name: 'Retry' }).click();
|
||||
}
|
||||
expect(
|
||||
await launched.mainWindow.evaluate(
|
||||
() =>
|
||||
window.__packagedEmbeddedMpvSessions?.at(-1)
|
||||
?.chapters ?? []
|
||||
)
|
||||
).toEqual([
|
||||
{ timeSeconds: 0, title: 'Intro' },
|
||||
{ timeSeconds: 5, title: 'Episode' },
|
||||
{ timeSeconds: 24, title: 'Credits' },
|
||||
]);
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
const segments = launched.mainWindow.locator(
|
||||
'app-player-controls .player-controls__timeline-segment'
|
||||
);
|
||||
await expect(segments).toHaveCount(3);
|
||||
await expect
|
||||
.poll(() =>
|
||||
segments.evaluateAll((elements) =>
|
||||
elements.map((element) => ({
|
||||
title: element.getAttribute('data-segment-title'),
|
||||
left: Math.round(parseFloat(element.style.left)),
|
||||
}))
|
||||
)
|
||||
)
|
||||
.toEqual([
|
||||
{ title: 'Intro', left: 0 },
|
||||
{ title: 'Episode', left: 17 },
|
||||
{ title: 'Credits', left: 80 },
|
||||
]);
|
||||
// The seek bar names the chapter at the position for assistive
|
||||
// technology, as it does for catch-up programmes.
|
||||
await expect(
|
||||
launched.mainWindow.locator(
|
||||
'app-player-controls .player-controls__slider--timeline'
|
||||
)
|
||||
).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
/^(Intro|Episode|Credits) · \d+:\d{2}$/
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
if (app) {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
} finally {
|
||||
await media.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -27,6 +27,23 @@ export type LocalMediaServer = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* `current`, now also closing `server`. Register each server this way the
|
||||
* moment it starts: one created later can throw, and the cleanup must
|
||||
* still reach every server already listening.
|
||||
*/
|
||||
export function alsoClosing(
|
||||
current: LocalMediaServer,
|
||||
server: LocalMediaServer
|
||||
): LocalMediaServer {
|
||||
return {
|
||||
url: current.url,
|
||||
close: async () => {
|
||||
await Promise.all([current.close(), server.close()]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Length of the generated Y4M clip. Long enough for the relative-seek burst
|
||||
* in the packaged smoke (three +2 s steps land at 6 s) plus the playing
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
installFrameCanvasAndSessionCapture,
|
||||
isMeaningfulNativePlaybackSnapshot,
|
||||
type LocalMediaServer,
|
||||
alsoClosing,
|
||||
} from './embedded-mpv-frame-copy-packaged-fixtures';
|
||||
import { expectRenderedFrame } from './embedded-mpv-frame-copy-packaged-diagnostics';
|
||||
import {
|
||||
@@ -360,27 +361,24 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
|
||||
resourcePath: '/stream-stats-switch.webm',
|
||||
contentType: 'video/webm',
|
||||
});
|
||||
media = {
|
||||
url: mediaServer.url,
|
||||
close: async () => {
|
||||
await Promise.all([
|
||||
mediaServer.close(),
|
||||
alternateMedia.close(),
|
||||
]);
|
||||
},
|
||||
};
|
||||
media = alsoClosing(mediaServer, alternateMedia);
|
||||
const chapteredMedia = await createLocalMediaServer({
|
||||
body: readFileSync(
|
||||
join(
|
||||
__dirname,
|
||||
'../../web-e2e/src/fixtures/playback/episode-chapters.webm'
|
||||
)
|
||||
),
|
||||
resourcePath: '/chapters.webm',
|
||||
contentType: 'video/webm',
|
||||
});
|
||||
media = alsoClosing(media, chapteredMedia);
|
||||
const audioMedia = await createLocalMediaServer({
|
||||
body: createWavFixture(),
|
||||
resourcePath: '/stream-stats-audio.wav',
|
||||
contentType: 'audio/wav',
|
||||
});
|
||||
const videoMedia = media;
|
||||
media = {
|
||||
url: videoMedia.url,
|
||||
close: async () => {
|
||||
await Promise.all([videoMedia.close(), audioMedia.close()]);
|
||||
},
|
||||
};
|
||||
media = alsoClosing(media, audioMedia);
|
||||
// mpv uses its built-in demuxer name "mkv" for WebM.
|
||||
for (const [streamUrl, videoCodec, container] of [
|
||||
[alternateMedia.url, 'vp8', 'mkv'],
|
||||
@@ -415,6 +413,33 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// mpv's `chapter-list` reaches the renderer session (it draws the
|
||||
// timeline segments and times the Up next card), and the next
|
||||
// file starts without the previous file's chapters.
|
||||
await launchedFrameCopyApp.mainWindow.evaluate(
|
||||
async ({ sessionId, streamUrl }) => {
|
||||
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
|
||||
streamUrl,
|
||||
title: 'Chaptered episode',
|
||||
isLive: false,
|
||||
});
|
||||
},
|
||||
{ sessionId: created.id, streamUrl: chapteredMedia.url }
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
() => getLatestSession(launchedFrameCopyApp, created.id),
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
.toMatchObject({
|
||||
streamUrl: chapteredMedia.url,
|
||||
chapters: [
|
||||
{ timeSeconds: 0, title: 'Intro' },
|
||||
{ timeSeconds: 5, title: 'Episode' },
|
||||
{ timeSeconds: 24, title: 'Credits' },
|
||||
],
|
||||
});
|
||||
|
||||
// Audio-only replacements must not retain the previous video size.
|
||||
await launchedFrameCopyApp.mainWindow.evaluate(
|
||||
async ({ sessionId, streamUrl }) => {
|
||||
@@ -433,6 +458,7 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
|
||||
)
|
||||
.toMatchObject({
|
||||
streamUrl: audioMedia.url,
|
||||
chapters: [],
|
||||
videoWidth: 0,
|
||||
videoHeight: 0,
|
||||
stats: {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickCategoryByNameExact,
|
||||
clickGridListCardByTitle,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
saveSettings,
|
||||
test,
|
||||
waitForXtreamImportToFinish,
|
||||
xtreamMockServer,
|
||||
} from './electron-test-fixtures';
|
||||
import { installEmbeddedMpvSessionCapture } from './embedded-mpv-frame-copy-packaged-fixtures';
|
||||
import {
|
||||
fetchXtreamSeriesFixture,
|
||||
getXtreamTitle,
|
||||
} from './portal-mock-fixtures';
|
||||
|
||||
/**
|
||||
* Issue #1902: an episode whose connection drops reaches mpv as EOF in the
|
||||
* middle of the episode, and series autoplay used to take that for the end
|
||||
* and start the next episode. The `streamdrop` mock cuts every episode a
|
||||
* third of the way into its 60 s clip and refuses it for a few seconds, so
|
||||
* ffmpeg's own reconnect fails and only the app-level reconnect can resume.
|
||||
* Needs the native-view Embedded MPV runtime; skips where it is missing.
|
||||
*/
|
||||
const credentials = { password: 'streamdrop', username: 'streamdrop' };
|
||||
/** Past the ~21 s cut: the resumed episode has played on. */
|
||||
const RESUMED_POSITION_SECONDS = 25;
|
||||
/** Keyframe rounding and the 500 ms poll before the drop. */
|
||||
const RESUME_TOLERANCE_SECONDS = 3;
|
||||
|
||||
type SessionSample = {
|
||||
episodeId: number | null;
|
||||
positionSeconds: number;
|
||||
reconnecting: boolean;
|
||||
status: string;
|
||||
};
|
||||
|
||||
async function fetchSeasonOneEpisodeIds(
|
||||
request: APIRequestContext,
|
||||
seriesId: number
|
||||
): Promise<number[]> {
|
||||
const params = new URLSearchParams({
|
||||
action: 'get_series_info',
|
||||
series_id: String(seriesId),
|
||||
...credentials,
|
||||
});
|
||||
const response = await request.get(
|
||||
`${xtreamMockServer}/player_api.php?${params}`
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const info = (await response.json()) as {
|
||||
episodes?: Record<string, { id: string; episode_num: number }[]>;
|
||||
};
|
||||
return [...(info.episodes?.['1'] ?? [])]
|
||||
.sort((a, b) => a.episode_num - b.episode_num)
|
||||
.map((episode) => Number(episode.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Session updates in arrival order, each tagged with the episode its session
|
||||
* last loaded: the `loading` update carries the requested URL, while later
|
||||
* snapshots report mpv's `path`, which not every engine fills in reliably.
|
||||
*/
|
||||
function readSessions(page: Page): Promise<SessionSample[]> {
|
||||
return page.evaluate(() => {
|
||||
const episodeBySession = new Map<string, number>();
|
||||
return (window.__packagedEmbeddedMpvSessions ?? []).map((session) => {
|
||||
const match = /\/series\/[^/]+\/[^/]+\/(\d+)\.\w+$/.exec(
|
||||
session.streamUrl
|
||||
);
|
||||
if (match) {
|
||||
episodeBySession.set(session.id, Number(match[1]));
|
||||
}
|
||||
return {
|
||||
episodeId: episodeBySession.get(session.id) ?? null,
|
||||
positionSeconds: session.positionSeconds,
|
||||
reconnecting: !!session.reconnect,
|
||||
status: session.status,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What followed the first episode's mid-episode EOF, in update order. Every
|
||||
* position played after the drop must stay near the drop: a reload that
|
||||
* restarted the episode would otherwise reach the resumed position as well.
|
||||
* A zero is the engine's placeholder before the reload's first `time-pos`.
|
||||
*/
|
||||
function outcomeAfterDrop(
|
||||
sessions: SessionSample[],
|
||||
[first, second]: number[]
|
||||
): 'pending' | 'resumed' | 'restarted' | 'skipped' {
|
||||
const dropped = sessions.findIndex(
|
||||
(session) => session.episodeId === first && session.status === 'ended'
|
||||
);
|
||||
if (dropped < 0) {
|
||||
return 'pending';
|
||||
}
|
||||
const dropPosition = sessions[dropped].positionSeconds;
|
||||
for (const session of sessions.slice(dropped)) {
|
||||
if (session.episodeId === second) {
|
||||
return 'skipped';
|
||||
}
|
||||
if (session.episodeId !== first || session.status !== 'playing') {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
session.positionSeconds > 0 &&
|
||||
session.positionSeconds < dropPosition - RESUME_TOLERANCE_SECONDS
|
||||
) {
|
||||
return 'restarted';
|
||||
}
|
||||
if (session.positionSeconds >= RESUMED_POSITION_SECONDS) {
|
||||
return 'resumed';
|
||||
}
|
||||
}
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
test('@playback @xtream @electron @embedded-mpv resumes an episode whose connection drops instead of skipping to the next one', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const fixture = await fetchXtreamSeriesFixture(request, credentials);
|
||||
const series = fixture.items[0];
|
||||
if (!series) {
|
||||
throw new Error('The stream-drop scenario has no series.');
|
||||
}
|
||||
const episodeIds = await fetchSeasonOneEpisodeIds(
|
||||
request,
|
||||
Number(series.series_id)
|
||||
);
|
||||
expect(episodeIds).toHaveLength(3);
|
||||
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: {
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1',
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY: '0',
|
||||
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW: '1',
|
||||
},
|
||||
});
|
||||
try {
|
||||
const support = await app.mainWindow.evaluate(() =>
|
||||
window.electron.getEmbeddedMpvSupport()
|
||||
);
|
||||
test.skip(
|
||||
!support.supported || support.engine !== 'native',
|
||||
`Native-view Embedded MPV unavailable: ${JSON.stringify(support)}`
|
||||
);
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'playback');
|
||||
await app.mainWindow.getByTestId('select-video-player').click();
|
||||
await app.mainWindow.getByTestId('embedded-mpv').click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await goToDashboard(app.mainWindow);
|
||||
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
...credentials,
|
||||
name: 'Stream Drop',
|
||||
});
|
||||
await waitForXtreamImportToFinish(app.mainWindow);
|
||||
await installEmbeddedMpvSessionCapture(app);
|
||||
await openWorkspaceSection(app.mainWindow, 'Series');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
await clickGridListCardByTitle(app.mainWindow, getXtreamTitle(series));
|
||||
const episode = app.mainWindow
|
||||
.locator('.episode-card, .episode-list-item')
|
||||
.first();
|
||||
await expect(episode).toBeVisible({ timeout: 20_000 });
|
||||
await episode.click();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
outcomeAfterDrop(
|
||||
await readSessions(app.mainWindow),
|
||||
episodeIds
|
||||
),
|
||||
{ timeout: 90_000 }
|
||||
)
|
||||
.not.toBe('pending');
|
||||
const sessions = await readSessions(app.mainWindow);
|
||||
expect(outcomeAfterDrop(sessions, episodeIds)).toBe('resumed');
|
||||
// The drop was shown as a reconnect, not as the episode's end.
|
||||
expect(sessions.some((session) => session.reconnecting)).toBe(true);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
fetchXtreamEpgFixture,
|
||||
fetchXtreamLiveFixture,
|
||||
fetchStalkerCategoryFixture,
|
||||
} from './portal-mock-fixtures';
|
||||
|
||||
@@ -463,3 +464,100 @@ test('@epg @stalker @theme @electron applies live themes to the shared Stalker g
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
/** Title of the programme block holding keyboard focus, if any. */
|
||||
async function focusedTimelineBlockTitle(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => {
|
||||
const block = document.activeElement?.closest(
|
||||
'app-epg-timeline .epg-timeline__block'
|
||||
);
|
||||
return (
|
||||
block
|
||||
?.querySelector('.epg-timeline__block-title')
|
||||
?.textContent?.trim() ?? null
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Two frames: the focus scroll's measure, then the re-rendered blocks. */
|
||||
async function nextFrames(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
test('@epg @xtream @electron reaches programmes beyond the rendered range with Tab', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
// The ribbon renders only the programmes near the visible range; keyboard
|
||||
// focus scrolls a block into view, which renders its neighbours before
|
||||
// the next key press. A generated schedule (240 half-hour slots) is far
|
||||
// wider than that range.
|
||||
test.setTimeout(180000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const credentials = { username: 'minimal', password: 'minimal' };
|
||||
const fixture = await fetchXtreamLiveFixture(request, credentials);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await app.mainWindow.route('https://test-streams.mux.dev/**', () => {
|
||||
// Keep the external demo request pending; guide data is local.
|
||||
});
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Xtream Timeline Keyboard',
|
||||
...credentials,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
const channelRow = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
fixture.items[0]?.name ?? ''
|
||||
).first();
|
||||
await expect(channelRow).toBeVisible({ timeout: 20000 });
|
||||
await channelRow.click();
|
||||
|
||||
const timeline = app.mainWindow.locator('app-epg-timeline');
|
||||
const nowBlock = timeline.locator('.epg-timeline__block.is-now');
|
||||
await expect(nowBlock).toBeVisible({ timeout: 20000 });
|
||||
const titles = timeline.locator('.epg-timeline__block-title');
|
||||
const initial = new Set(
|
||||
(await titles.allInnerTexts()).map((title) => title.trim())
|
||||
);
|
||||
expect(initial.size).toBeLessThan(120);
|
||||
|
||||
// Walk well past the rendered range in both directions: ten
|
||||
// programmes that were not in the DOM when the walk started.
|
||||
for (const key of ['Tab', 'Shift+Tab']) {
|
||||
// The previous walk moved the window away from now.
|
||||
await timeline.locator('.epg-timeline__jump').click();
|
||||
await expect(nowBlock).toBeVisible();
|
||||
await nowBlock.focus();
|
||||
const reached = new Set<string>();
|
||||
for (let press = 0; press < 400 && reached.size < 10; press++) {
|
||||
await app.mainWindow.keyboard.press(key);
|
||||
await nextFrames(app.mainWindow);
|
||||
const inRibbon = await app.mainWindow.evaluate(
|
||||
() =>
|
||||
!!document.activeElement?.closest(
|
||||
'app-epg-timeline .epg-timeline__ribbon'
|
||||
)
|
||||
);
|
||||
expect(inRibbon, `${key} press ${press} left the ribbon`).toBe(
|
||||
true
|
||||
);
|
||||
const title = await focusedTimelineBlockTitle(app.mainWindow);
|
||||
if (title && !initial.has(title)) {
|
||||
reached.add(title);
|
||||
}
|
||||
}
|
||||
expect(reached.size, `${key} programmes beyond the range`).toBe(10);
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
saveSettings,
|
||||
setSwitch,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
@@ -161,8 +162,8 @@ test.describe('Electron EPG', () => {
|
||||
});
|
||||
const guard = app.mainWindow
|
||||
.getByTestId('portal-connectivity-toggle')
|
||||
.locator('input');
|
||||
await guard.uncheck();
|
||||
.getByRole('switch');
|
||||
await setSwitch(guard, false);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('settings-unsaved-bar')
|
||||
).toBeVisible();
|
||||
@@ -192,10 +193,12 @@ test.describe('Electron EPG', () => {
|
||||
});
|
||||
});
|
||||
await openSettingsSection(app.mainWindow, 'general');
|
||||
await app.mainWindow
|
||||
.getByTestId('portal-connectivity-toggle')
|
||||
.locator('input')
|
||||
.uncheck();
|
||||
await setSwitch(
|
||||
app.mainWindow
|
||||
.getByTestId('portal-connectivity-toggle')
|
||||
.getByRole('switch'),
|
||||
false
|
||||
);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
expect,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
goToDashboard,
|
||||
openGlobalRecent,
|
||||
openWorkspaceSection,
|
||||
openPlaylistFavorites,
|
||||
openSources,
|
||||
@@ -41,8 +43,158 @@ import {
|
||||
getXtreamTitle,
|
||||
pickDistinctTitles,
|
||||
} from './portal-mock-fixtures';
|
||||
import { readVisibleSidebarCategoryNames } from './sidebar-categories.e2e-support';
|
||||
import {
|
||||
routePlayableStreams,
|
||||
startAndConfirmPlayback,
|
||||
} from './playable-stream-fixture';
|
||||
|
||||
test.describe('Electron Favorites', () => {
|
||||
for (const origin of ['favorites', 'recent', 'dashboard'] as const) {
|
||||
test(`@stalker @electron portal handoff from ${origin} restores the owning categories after opening a different source`, async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
const vod = await fetchStalkerCategoryFixture(request, 'vod');
|
||||
const [title] = pickDistinctTitles(vod.items, getStalkerTitle);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
await routePlayableStreams(page);
|
||||
try {
|
||||
await addStalkerPortal(page, { name: 'Portal A' });
|
||||
await waitForStalkerCatalog(page);
|
||||
await clickCategoryById(page, vod.categoryId);
|
||||
const portalPath = new URL(page.url()).pathname.replace(
|
||||
/\/vod(?:\/[^/]+)?$/,
|
||||
`/vod/${vod.categoryId}`
|
||||
);
|
||||
const categoriesA = await readVisibleSidebarCategoryNames(page);
|
||||
await clickGridListCardByTitle(page, title);
|
||||
await addCurrentDetailToFavorites(page);
|
||||
if (origin !== 'favorites') {
|
||||
await startAndConfirmPlayback(page, () =>
|
||||
page.locator('button.play-btn').first().click()
|
||||
);
|
||||
}
|
||||
await goBackFromDetail(page);
|
||||
if (origin !== 'favorites') {
|
||||
// Represent an already saved viewing position. The short
|
||||
// media fixture ends before the player's 15 s save interval.
|
||||
const playlistId = /\/stalker\/([^/]+)/.exec(
|
||||
portalPath
|
||||
)?.[1];
|
||||
const contentId = Number(
|
||||
vod.items.find(
|
||||
(item) => getStalkerTitle(item) === title
|
||||
)?.id
|
||||
);
|
||||
expect(playlistId).toBeTruthy();
|
||||
await page.evaluate(
|
||||
async ({ playlistId, contentId }) => {
|
||||
await window.electron.dbSavePlaybackPosition(
|
||||
playlistId!,
|
||||
{
|
||||
contentXtreamId: contentId,
|
||||
contentType: 'vod',
|
||||
playlistType: 'stalker',
|
||||
positionSeconds: 2,
|
||||
durationSeconds: 6,
|
||||
}
|
||||
);
|
||||
},
|
||||
{ playlistId, contentId }
|
||||
);
|
||||
}
|
||||
|
||||
await openSources(page);
|
||||
await addStalkerPortal(page, {
|
||||
name: 'Portal B',
|
||||
macAddress: '00:1A:79:00:00:03',
|
||||
});
|
||||
await waitForStalkerCatalog(page);
|
||||
const categoriesB = await readVisibleSidebarCategoryNames(page);
|
||||
expect(categoriesB).not.toEqual(categoriesA);
|
||||
|
||||
if (origin === 'dashboard') {
|
||||
await goToDashboard(page);
|
||||
await page
|
||||
.locator(
|
||||
'[data-test-id="dashboard-continue-watching-rail-card"]'
|
||||
)
|
||||
.filter({ hasText: title })
|
||||
.first()
|
||||
.click();
|
||||
} else {
|
||||
if (origin === 'favorites')
|
||||
await openWorkspaceSection(page, 'Global favorites');
|
||||
else await openGlobalRecent(page);
|
||||
await switchUnifiedCollectionScope(page, 'All playlists');
|
||||
await contentCardByTitle(page, title).first().click();
|
||||
}
|
||||
await expect(page.locator('app-content-hero')).toContainText(
|
||||
title
|
||||
);
|
||||
if (origin !== 'favorites') {
|
||||
await startAndConfirmPlayback(
|
||||
page,
|
||||
() =>
|
||||
page
|
||||
.getByRole('button', { name: /^Continue/ })
|
||||
.first()
|
||||
.click(),
|
||||
0.5
|
||||
);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Close player',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
}
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'View in portal',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).pathname)
|
||||
.toBe(portalPath);
|
||||
await expect(page.locator('app-content-hero')).toContainText(
|
||||
title
|
||||
);
|
||||
await expect
|
||||
.poll(() => readVisibleSidebarCategoryNames(page))
|
||||
.toEqual(categoriesA);
|
||||
|
||||
await goBackFromDetail(page);
|
||||
await expectPathname(
|
||||
page,
|
||||
origin === 'favorites'
|
||||
? /\/workspace\/global-favorites$/
|
||||
: /\/workspace\/global-recent$/
|
||||
);
|
||||
await expect(page.locator('app-content-hero')).toContainText(
|
||||
title
|
||||
);
|
||||
for (const [name, expected] of [
|
||||
['Portal B', categoriesB],
|
||||
['Portal A', categoriesA],
|
||||
] as const) {
|
||||
await openSources(page);
|
||||
await sourceRowByTitle(page, name).first().click();
|
||||
await waitForStalkerCatalog(page);
|
||||
await expect
|
||||
.poll(() => readVisibleSidebarCategoryNames(page))
|
||||
.toEqual(expected);
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('@persistence @m3u @electron shows M3U favorites in playlist and all-playlists scope, and preserves them after restart', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { LaunchedElectronApp } from './electron-test-fixtures';
|
||||
import {
|
||||
electronMainPath,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
interface ChannelHold {
|
||||
/** Invocations parked behind the hold, waiting for `release`. */
|
||||
waiting: number;
|
||||
gate: Promise<void>;
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
type HoldGlobal = typeof globalThis & {
|
||||
__ipcChannelHolds?: Record<string, ChannelHold | undefined>;
|
||||
__ipcChannelHoldInstalled?: string[];
|
||||
};
|
||||
|
||||
// Serialized into the test bootstrap; all runtime dependencies are arguments.
|
||||
function installChannelHolds(
|
||||
{ ipcMain }: Pick<typeof import('electron'), 'ipcMain'>,
|
||||
channels: readonly string[]
|
||||
): void {
|
||||
const register = ipcMain.handle;
|
||||
const scope = globalThis as HoldGlobal;
|
||||
scope.__ipcChannelHolds = {};
|
||||
scope.__ipcChannelHoldInstalled = [];
|
||||
// Wraps the listener at registration through the public API; no
|
||||
// Electron-private handler registry is read or mutated.
|
||||
ipcMain.handle = function (channel, listener) {
|
||||
if (!channels.includes(channel)) {
|
||||
return register.call(this, channel, listener);
|
||||
}
|
||||
scope.__ipcChannelHoldInstalled?.push(channel);
|
||||
return register.call(this, channel, async (...args) => {
|
||||
const hold = scope.__ipcChannelHolds?.[channel];
|
||||
if (hold) {
|
||||
hold.waiting += 1;
|
||||
await hold.gate;
|
||||
}
|
||||
return listener(...args);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches the app with `channels` wrapped so a test can park their
|
||||
* invocations (`holdIpcChannel`) and keep a loading state on screen for as
|
||||
* long as it measures it.
|
||||
*/
|
||||
export function launchElectronWithChannelHolds(
|
||||
dataDir: string,
|
||||
channels: readonly string[]
|
||||
): Promise<LaunchedElectronApp> {
|
||||
const entryPoint = join(dataDir, 'ipc-channel-holds.cjs');
|
||||
writeFileSync(
|
||||
entryPoint,
|
||||
[
|
||||
`(${installChannelHolds.toString()})(require('electron'), ${JSON.stringify(channels)});`,
|
||||
`require(${JSON.stringify(electronMainPath)});`,
|
||||
].join('\n')
|
||||
);
|
||||
return launchElectronApp(dataDir, { entryPoint });
|
||||
}
|
||||
|
||||
/** Parks every later invocation of `channel` until the returned release. */
|
||||
export async function holdIpcChannel(
|
||||
app: LaunchedElectronApp,
|
||||
channel: string
|
||||
) {
|
||||
await app.electronApp.evaluate((_electron, name) => {
|
||||
const scope = globalThis as HoldGlobal;
|
||||
if (!scope.__ipcChannelHoldInstalled?.includes(name)) {
|
||||
throw new Error(`IPC channel ${name} has no hold installed`);
|
||||
}
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const holds = (scope.__ipcChannelHolds ??= {});
|
||||
holds[name] = { waiting: 0, gate, release };
|
||||
}, channel);
|
||||
|
||||
return {
|
||||
waitUntilHeld: () =>
|
||||
expect
|
||||
.poll(() =>
|
||||
app.electronApp.evaluate(
|
||||
(_electron, name) =>
|
||||
(globalThis as HoldGlobal).__ipcChannelHolds?.[name]
|
||||
?.waiting ?? 0,
|
||||
channel
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0),
|
||||
release: () =>
|
||||
app.electronApp.evaluate((_electron, name) => {
|
||||
const holds = (globalThis as HoldGlobal).__ipcChannelHolds;
|
||||
const hold = holds?.[name];
|
||||
if (!hold) throw new Error(`IPC channel ${name} is not held`);
|
||||
delete holds[name];
|
||||
hold.release();
|
||||
}, channel),
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export interface JourneyRendererGateState {
|
||||
readonly gatedEpochMs: number | null;
|
||||
readonly gatedMethod: string | null;
|
||||
readonly passThroughLoads: number;
|
||||
/** `did-finish-load` events kept from the app's listeners on about:blank. */
|
||||
readonly didFinishLoadHeldOnBlank: number;
|
||||
/** `ready-to-show` events dropped while the window was on about:blank. */
|
||||
readonly readyToShowHeldOnBlank: number;
|
||||
readonly releasedEpochMs: number | null;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { ElectronApplication, Page } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { configureLiveFormat } from '../xtream-live-format.fixture';
|
||||
import {
|
||||
JOURNEY_CLICK_QUIET_MS,
|
||||
waitForJourneyClickQuiet,
|
||||
} from '../performance/journey-click-settle';
|
||||
import {
|
||||
blockJourneyExternalArtwork,
|
||||
readJourneyExternalArtworkCancelled,
|
||||
} from '../performance/journey-external-artwork';
|
||||
import {
|
||||
detachJourneyMainIpcCapture,
|
||||
installJourneyMainIpcCapture,
|
||||
@@ -47,14 +51,6 @@ export const PLAYBACK_JOURNEY_MAIN_IPC_STATE_KEY =
|
||||
'__iptvnatorJourneyPlaybackMainIpcCapture';
|
||||
export const PLAYBACK_JOURNEY_PORTAL_NAME = 'Journey live portal';
|
||||
const ERROR_PREFIX = 'playback-journey';
|
||||
const EXTERNAL_ARTWORK_STATE_KEY = '__iptvnatorJourneyExternalArtwork';
|
||||
/**
|
||||
* The generated live catalog's channel and category logos point at
|
||||
* picsum.photos. They are cancelled in the main process, so no request of
|
||||
* the journey leaves the machine and a logo never loads, or fails, at a
|
||||
* different moment on a runner with a different network.
|
||||
*/
|
||||
const EXTERNAL_ARTWORK_URLS = ['*://picsum.photos/*', '*://*.picsum.photos/*'];
|
||||
|
||||
/** Seeds J2's profile with the local-media portal and the HTML5 player. */
|
||||
export const PLAYBACK_JOURNEY_SEED: LaunchJourneySeedOptions = {
|
||||
@@ -68,45 +64,6 @@ export const PLAYBACK_JOURNEY_SEED: LaunchJourneySeedOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
async function blockExternalArtwork(
|
||||
electronApp: ElectronApplication
|
||||
): Promise<void> {
|
||||
await electronApp.evaluate(
|
||||
({ session }, input) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
if (target[input.key] !== undefined) {
|
||||
throw new Error('playback-journey-artwork-block-installed');
|
||||
}
|
||||
const state = { cancelled: 0 };
|
||||
target[input.key] = state;
|
||||
// The app registers no onBeforeRequest listener of its own
|
||||
// (only onBeforeSendHeaders), so this replaces nothing.
|
||||
session.defaultSession.webRequest.onBeforeRequest(
|
||||
{ urls: input.urls },
|
||||
(_details, callback) => {
|
||||
state.cancelled += 1;
|
||||
callback({ cancel: true });
|
||||
}
|
||||
);
|
||||
},
|
||||
{ key: EXTERNAL_ARTWORK_STATE_KEY, urls: EXTERNAL_ARTWORK_URLS }
|
||||
);
|
||||
}
|
||||
|
||||
async function readCancelledExternalArtwork(
|
||||
electronApp: ElectronApplication
|
||||
): Promise<number> {
|
||||
return electronApp.evaluate(
|
||||
(_electron, key) =>
|
||||
(
|
||||
(globalThis as unknown as Record<string, unknown>)[key] as {
|
||||
cancelled: number;
|
||||
}
|
||||
).cancelled,
|
||||
EXTERNAL_ARTWORK_STATE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
/** Dashboard card → live section → first category, as a user would. */
|
||||
async function openLiveCategory(page: Page, timeoutMs: number): Promise<void> {
|
||||
await page
|
||||
@@ -143,7 +100,7 @@ export async function measurePlaybackJourney(
|
||||
if (!startClick) {
|
||||
throw new Error('playback-journey-probe-without-start');
|
||||
}
|
||||
await blockExternalArtwork(electronApp);
|
||||
await blockJourneyExternalArtwork(electronApp);
|
||||
await openLiveCategory(mainWindow, timeoutMs);
|
||||
const channel = mainWindow.locator(startClick.selector).first();
|
||||
await channel.waitFor({ state: 'visible', timeout: timeoutMs });
|
||||
@@ -199,7 +156,7 @@ export async function measurePlaybackJourney(
|
||||
);
|
||||
return {
|
||||
externalArtworkCancelled:
|
||||
await readCancelledExternalArtwork(electronApp),
|
||||
await readJourneyExternalArtworkCancelled(electronApp),
|
||||
http: {
|
||||
afterPlaying: sinceSpawn.filter(
|
||||
(entry) =>
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import type { ElectronApplication, Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
blockJourneyExternalArtwork,
|
||||
readJourneyExternalArtworkCancelled,
|
||||
} from '../performance/journey-external-artwork';
|
||||
import {
|
||||
JOURNEY_MAIN_COUNTER,
|
||||
JOURNEY_PERFORMANCE_COUNTERS_CHANNEL,
|
||||
} from '../performance/journey-main-counters';
|
||||
import {
|
||||
countJourneyMainIpcInFlight,
|
||||
detachJourneyMainIpcCapture,
|
||||
installJourneyMainIpcCapture,
|
||||
JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
JOURNEY_RENDERER_API_TRACE_CHANNEL,
|
||||
peekJourneyMainIpcCaptures,
|
||||
readJourneyMainIpcCapture,
|
||||
} from '../performance/journey-main-ipc-capture';
|
||||
import { waitForJourneyQuiet } from '../performance/journey-quiet-wait';
|
||||
import {
|
||||
armSearchJourneyProbe,
|
||||
createSearchJourneyProbeOptions,
|
||||
readSearchJourneyPreStartMutations,
|
||||
type SearchJourneyProbeOptions,
|
||||
SEARCH_JOURNEY_INPUT_SELECTOR,
|
||||
SEARCH_JOURNEY_ROUTE_PATH,
|
||||
waitForSearchJourneyProbe,
|
||||
} from '../performance/search-journey-probe';
|
||||
import {
|
||||
SEARCH_JOURNEY_QUERY_METHOD,
|
||||
type SearchJourneyActivitySample,
|
||||
type SearchJourneyMeasurement,
|
||||
type SearchJourneyQueryTraceEntry,
|
||||
type SearchJourneySettle,
|
||||
} from '../performance/search-journey-record';
|
||||
import { JOURNEY_RENDERER_GATE_KEY } from './journey-renderer-gate-client';
|
||||
import type {
|
||||
LaunchJourneySeedOptions,
|
||||
LaunchJourneySession,
|
||||
} from './launch-journey-app';
|
||||
|
||||
/**
|
||||
* J4 "Search": runs inside a process that J1 has just launched with the
|
||||
* main-process counters on (SQL statements are counted). The test opens
|
||||
* global search from the rail and focuses the header search box (not
|
||||
* measured), lets the app settle, then types the query one key at a time
|
||||
* at a fixed interval and measures until the results have settled.
|
||||
*
|
||||
* Main-process activity (bridge calls, SQL statements) is sampled before
|
||||
* every keystroke, so the record can show what each key caused: with the
|
||||
* shell's debounce, only the last interval should run a query. Contract:
|
||||
* docs/architecture/performance-journeys.md.
|
||||
*/
|
||||
export const SEARCH_JOURNEY_MAIN_IPC_STATE_KEY =
|
||||
'__iptvnatorJourneySearchMainIpcCapture';
|
||||
export const SEARCH_JOURNEY_PORTAL_NAME = 'Journey search portal';
|
||||
/**
|
||||
* Six characters; on the mock's `large` account (12,000 items) the term
|
||||
* matches 170 series titles, more than the first page of 100.
|
||||
*/
|
||||
export const SEARCH_JOURNEY_QUERY = 'system';
|
||||
/** Well below the shell's 300 ms input debounce, like steady typing. */
|
||||
export const SEARCH_JOURNEY_KEY_DELAY_MS = 100;
|
||||
const QUIET_MS = 1_000;
|
||||
const QUIET_POLL_MS = 100;
|
||||
const QUIET_TIMEOUT_MS = 30_000;
|
||||
const AFTER_SETTLED_WINDOW_MS = 500;
|
||||
const ERROR_PREFIX = 'search-journey';
|
||||
|
||||
/** J1's M3U source plus the mock's existing 12,000-item `large` catalog. */
|
||||
export const SEARCH_JOURNEY_SEED: LaunchJourneySeedOptions = {
|
||||
portal: {
|
||||
name: SEARCH_JOURNEY_PORTAL_NAME,
|
||||
password: 'large',
|
||||
username: 'large',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* One synchronous pass in the main process: the capture's counts and the
|
||||
* registered counters handler (which reads the registry synchronously), so
|
||||
* no bridge call or SQL report can land between the two reads.
|
||||
*/
|
||||
async function sampleActivity(
|
||||
electronApp: ElectronApplication
|
||||
): Promise<SearchJourneyActivitySample> {
|
||||
return electronApp.evaluate(
|
||||
async (_electron, input) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
const capture = target[input.captureKey] as
|
||||
| {
|
||||
callsBeforeSentinel: number;
|
||||
callsByMethod: Record<string, number>;
|
||||
}
|
||||
| undefined;
|
||||
const gate = target[input.gateKey] as
|
||||
| { invokeHandler?: (channel: string) => Promise<unknown> }
|
||||
| undefined;
|
||||
if (!capture || typeof gate?.invokeHandler !== 'function') {
|
||||
throw new Error('search-journey-sample-unavailable');
|
||||
}
|
||||
const ipcCalls = capture.callsBeforeSentinel;
|
||||
const queryCalls = capture.callsByMethod[input.queryMethod] ?? 0;
|
||||
const pending = gate.invokeHandler(input.channel);
|
||||
const snapshot = (await pending) as {
|
||||
counters?: Record<string, number>;
|
||||
} | null;
|
||||
const sqlStatements = snapshot?.counters?.[input.sqlCounter];
|
||||
if (typeof sqlStatements !== 'number') {
|
||||
throw new Error('search-journey-sql-counter-missing');
|
||||
}
|
||||
return { ipcCalls, queryCalls, sqlStatements };
|
||||
},
|
||||
{
|
||||
captureKey: SEARCH_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
channel: JOURNEY_PERFORMANCE_COUNTERS_CHANNEL,
|
||||
gateKey: JOURNEY_RENDERER_GATE_KEY,
|
||||
queryMethod: SEARCH_JOURNEY_QUERY_METHOD,
|
||||
sqlCounter: JOURNEY_MAIN_COUNTER.SQL_STATEMENTS,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until DOM, bridge calls (started and in flight) and SQL statements
|
||||
* have all been unchanged for `QUIET_MS`, so leftovers of the launch and of
|
||||
* the navigation to global search are not attributed to the first key.
|
||||
*/
|
||||
async function waitForSearchQuiet(
|
||||
electronApp: ElectronApplication,
|
||||
page: Page,
|
||||
probeStateKey: string
|
||||
): Promise<SearchJourneySettle> {
|
||||
const { sample, waitedMs } = await waitForJourneyQuiet({
|
||||
inFlight: (activity) => activity.ipcInFlight,
|
||||
pollMs: QUIET_POLL_MS,
|
||||
quietMs: QUIET_MS,
|
||||
sample: async () => {
|
||||
const [launchCapture, journeyCapture] =
|
||||
await peekJourneyMainIpcCaptures(electronApp, [
|
||||
JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
SEARCH_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
]);
|
||||
if (launchCapture.unmatchedCompletions > 0) {
|
||||
throw new Error(`${ERROR_PREFIX}-bridge-completions-unmatched`);
|
||||
}
|
||||
return {
|
||||
domMutations: await readSearchJourneyPreStartMutations(
|
||||
page,
|
||||
probeStateKey
|
||||
),
|
||||
ipcCalls: journeyCapture.callsBeforeStart,
|
||||
ipcInFlight: countJourneyMainIpcInFlight(launchCapture),
|
||||
sqlStatements: (await sampleActivity(electronApp))
|
||||
.sqlStatements,
|
||||
};
|
||||
},
|
||||
timeoutError: (activity) =>
|
||||
new Error(`${ERROR_PREFIX}-not-quiet: ${JSON.stringify(activity)}`),
|
||||
timeoutMs: QUIET_TIMEOUT_MS,
|
||||
});
|
||||
return {
|
||||
preStartDomMutations: sample.domMutations,
|
||||
preStartIpcCalls: sample.ipcCalls,
|
||||
quietMs: QUIET_MS,
|
||||
sqlStatements: sample.sqlStatements,
|
||||
waitedMs,
|
||||
};
|
||||
}
|
||||
|
||||
function sleepUntil(epochMs: number): Promise<void> {
|
||||
const remainingMs = epochMs - Date.now();
|
||||
return remainingMs > 0
|
||||
? new Promise((resolve) => setTimeout(resolve, remainingMs))
|
||||
: Promise.resolve();
|
||||
}
|
||||
|
||||
const QUERY_TRACE_KEY = '__iptvnatorJourneySearchQueryTrace';
|
||||
|
||||
/**
|
||||
* Records every trace event of the query method in the main process, with
|
||||
* the term and result length the preload's summaries carry and the main
|
||||
* process's arrival epoch (the clock the IPC capture stamps its sentinels
|
||||
* with). The record uses it to prove the final term's query completed
|
||||
* before the settle, so results of an earlier term cannot end the journey.
|
||||
* The summaries hold the search term and counts only.
|
||||
*/
|
||||
async function traceQueryCalls(
|
||||
electronApp: ElectronApplication
|
||||
): Promise<void> {
|
||||
await electronApp.evaluate(
|
||||
({ ipcMain }, input) => {
|
||||
const entries: unknown[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>)[input.key] =
|
||||
entries;
|
||||
ipcMain.on(input.channel, (_event, payload: unknown) => {
|
||||
const record = payload as Record<string, unknown> | null;
|
||||
if (record?.['method'] !== input.method) return;
|
||||
const args = record['args'] as { items?: unknown[] } | null;
|
||||
const result = record['result'] as { length?: unknown } | null;
|
||||
const term = args?.items?.[0];
|
||||
entries.push({
|
||||
epochMs: Date.now(),
|
||||
phase: String(record['phase']),
|
||||
resultLength:
|
||||
typeof result?.length === 'number'
|
||||
? result.length
|
||||
: null,
|
||||
term: typeof term === 'string' ? term : null,
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
channel: JOURNEY_RENDERER_API_TRACE_CHANNEL,
|
||||
key: QUERY_TRACE_KEY,
|
||||
method: SEARCH_JOURNEY_QUERY_METHOD,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const SENTINEL_SQL_KEY = '__iptvnatorJourneySearchSentinelSql';
|
||||
|
||||
/**
|
||||
* Reads `main.sqlStatements` in the main process when the start and the end
|
||||
* sentinel arrive, so the SQL counter covers exactly the IPC capture's
|
||||
* window: database work just before the first key or after the settle is
|
||||
* not counted. The gate's `invokeHandler` runs the counters handler
|
||||
* synchronously, so the value is the total at the sentinel's arrival even
|
||||
* though it is stored when the promise settles.
|
||||
*/
|
||||
async function stampSqlAtSentinels(
|
||||
electronApp: ElectronApplication,
|
||||
probeOptions: SearchJourneyProbeOptions
|
||||
): Promise<void> {
|
||||
await electronApp.evaluate(
|
||||
({ ipcMain }, input) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
const gate = target[input.gateKey] as {
|
||||
invokeHandler: (channel: string) => Promise<unknown>;
|
||||
};
|
||||
const state: Record<'end' | 'start', number | null> = {
|
||||
end: null,
|
||||
start: null,
|
||||
};
|
||||
target[input.key] = state;
|
||||
ipcMain.on(input.channel, (_event, payload: unknown) => {
|
||||
const record = payload as Record<string, unknown> | null;
|
||||
if (
|
||||
record?.['method'] !== input.sentinelMethod ||
|
||||
record['phase'] !== 'start'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const args = JSON.stringify(record['args'] ?? null);
|
||||
const which = args.includes(input.startId)
|
||||
? 'start'
|
||||
: args.includes(input.endId)
|
||||
? 'end'
|
||||
: null;
|
||||
if (which === null || state[which] !== null) return;
|
||||
void gate
|
||||
.invokeHandler(input.countersChannel)
|
||||
.then((snapshot) => {
|
||||
const value = (
|
||||
snapshot as { counters?: Record<string, number> }
|
||||
)?.counters?.[input.sqlCounter];
|
||||
state[which] = typeof value === 'number' ? value : null;
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
channel: JOURNEY_RENDERER_API_TRACE_CHANNEL,
|
||||
countersChannel: JOURNEY_PERFORMANCE_COUNTERS_CHANNEL,
|
||||
endId: probeOptions.endSentinelId,
|
||||
gateKey: JOURNEY_RENDERER_GATE_KEY,
|
||||
key: SENTINEL_SQL_KEY,
|
||||
sentinelMethod: probeOptions.sentinelMethod,
|
||||
sqlCounter: JOURNEY_MAIN_COUNTER.SQL_STATEMENTS,
|
||||
startId: probeOptions.startSentinelId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function readQueryTrace(
|
||||
electronApp: ElectronApplication
|
||||
): Promise<SearchJourneyQueryTraceEntry[]> {
|
||||
return electronApp.evaluate(
|
||||
(_electron, key) =>
|
||||
JSON.parse(
|
||||
JSON.stringify(
|
||||
(globalThis as unknown as Record<string, unknown>)[key]
|
||||
)
|
||||
) as SearchJourneyQueryTraceEntry[],
|
||||
QUERY_TRACE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a failed settle saw: the bridge calls of the search, renderer errors
|
||||
* (a search that threw shows the same empty view as one that found
|
||||
* nothing), SQL statements before each key and now, and the traced query
|
||||
* calls with their terms and result lengths.
|
||||
*/
|
||||
async function describeSettleFailure(
|
||||
electronApp: ElectronApplication,
|
||||
samples: readonly SearchJourneyActivitySample[],
|
||||
consoleErrors: readonly string[]
|
||||
): Promise<string> {
|
||||
const [capture] = await peekJourneyMainIpcCaptures(electronApp, [
|
||||
SEARCH_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
]);
|
||||
const now = await sampleActivity(electronApp);
|
||||
const queryTrace = await readQueryTrace(electronApp);
|
||||
return JSON.stringify({
|
||||
bridgeCalls: capture.callsByMethod,
|
||||
consoleErrors,
|
||||
queryTrace,
|
||||
sqlBeforeKeysAndNow: [...samples, now].map(
|
||||
(entry) => entry.sqlStatements
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** Rail link → global search, then focus the header box. Not measured. */
|
||||
async function openGlobalSearch(page: Page, timeoutMs: number): Promise<void> {
|
||||
await page
|
||||
.getByRole('link', { name: 'Global search', exact: true })
|
||||
.click({ timeout: timeoutMs });
|
||||
// A router navigation, not a document load: wait on the path itself.
|
||||
await page
|
||||
.waitForFunction(
|
||||
(path) => location.pathname.endsWith(path),
|
||||
SEARCH_JOURNEY_ROUTE_PATH,
|
||||
{ timeout: timeoutMs }
|
||||
)
|
||||
.catch((failure: unknown) => {
|
||||
throw new Error(
|
||||
`${ERROR_PREFIX}-route-not-reached: ${page.url()} (${String(failure)})`
|
||||
);
|
||||
});
|
||||
const input = page.locator(SEARCH_JOURNEY_INPUT_SELECTOR);
|
||||
await input.waitFor({ state: 'visible', timeout: timeoutMs });
|
||||
await input.focus({ timeout: timeoutMs });
|
||||
}
|
||||
|
||||
export async function measureSearchJourney(
|
||||
session: LaunchJourneySession,
|
||||
timeoutMs: number
|
||||
): Promise<SearchJourneyMeasurement> {
|
||||
const { electronApp, mainWindow } = session;
|
||||
const query = SEARCH_JOURNEY_QUERY;
|
||||
const probeOptions = createSearchJourneyProbeOptions(query);
|
||||
// Kept for the message of a failed settle.
|
||||
const consoleErrors: string[] = [];
|
||||
mainWindow.on('console', (message) => {
|
||||
if (message.type() === 'error' && consoleErrors.length < 10) {
|
||||
consoleErrors.push(message.text().slice(0, 300));
|
||||
}
|
||||
});
|
||||
await blockJourneyExternalArtwork(electronApp);
|
||||
await traceQueryCalls(electronApp);
|
||||
await stampSqlAtSentinels(electronApp, probeOptions);
|
||||
await openGlobalSearch(mainWindow, timeoutMs);
|
||||
await installJourneyMainIpcCapture(electronApp, {
|
||||
channel: JOURNEY_RENDERER_API_TRACE_CHANNEL,
|
||||
sentinelId: probeOptions.endSentinelId,
|
||||
sentinelMethod: probeOptions.sentinelMethod,
|
||||
startSentinelId: probeOptions.startSentinelId,
|
||||
stateKey: SEARCH_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
});
|
||||
await armSearchJourneyProbe(mainWindow, probeOptions);
|
||||
const settle = await waitForSearchQuiet(
|
||||
electronApp,
|
||||
mainWindow,
|
||||
probeOptions.stateKey
|
||||
);
|
||||
await detachJourneyMainIpcCapture(electronApp, JOURNEY_MAIN_IPC_STATE_KEY);
|
||||
const focused = await mainWindow
|
||||
.locator(SEARCH_JOURNEY_INPUT_SELECTOR)
|
||||
.evaluate((input) => input === document.activeElement);
|
||||
if (!focused) {
|
||||
throw new Error(`${ERROR_PREFIX}-input-not-focused`);
|
||||
}
|
||||
// One `keyboard.type` per character on a fixed schedule, so the
|
||||
// main-process sample before each key sits between two keystrokes.
|
||||
const samples: SearchJourneyActivitySample[] = [];
|
||||
const firstKeyAtMs = Date.now();
|
||||
for (let position = 0; position < query.length; position += 1) {
|
||||
await sleepUntil(firstKeyAtMs + position * SEARCH_JOURNEY_KEY_DELAY_MS);
|
||||
samples.push(await sampleActivity(electronApp));
|
||||
await mainWindow.keyboard.type(query[position]);
|
||||
}
|
||||
const renderer = await waitForSearchJourneyProbe(
|
||||
mainWindow,
|
||||
probeOptions.stateKey,
|
||||
timeoutMs
|
||||
).catch(async (failure: unknown) => {
|
||||
throw new Error(
|
||||
`${String(failure)} ${await describeSettleFailure(electronApp, samples, consoleErrors)}`
|
||||
);
|
||||
});
|
||||
const ipc = await readJourneyMainIpcCapture(
|
||||
electronApp,
|
||||
SEARCH_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
10_000
|
||||
);
|
||||
samples.push(await sampleActivity(electronApp));
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, AFTER_SETTLED_WINDOW_MS)
|
||||
);
|
||||
return {
|
||||
afterSettled: await sampleActivity(electronApp),
|
||||
afterSettledWindowMs: AFTER_SETTLED_WINDOW_MS,
|
||||
externalArtworkCancelled:
|
||||
await readJourneyExternalArtworkCancelled(electronApp),
|
||||
ipc,
|
||||
keyDelayMs: SEARCH_JOURNEY_KEY_DELAY_MS,
|
||||
pid: session.launch.pid,
|
||||
query,
|
||||
queryTrace: await readQueryTrace(electronApp),
|
||||
sqlAtSentinels: await electronApp.evaluate(
|
||||
(_electron, key) =>
|
||||
JSON.parse(
|
||||
JSON.stringify(
|
||||
(globalThis as unknown as Record<string, unknown>)[key]
|
||||
)
|
||||
) as SearchJourneyMeasurement['sqlAtSentinels'],
|
||||
SENTINEL_SQL_KEY
|
||||
),
|
||||
renderer,
|
||||
samples,
|
||||
settle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
import type { JourneyIterationRecord } from '../performance/journey-summary';
|
||||
import {
|
||||
SEARCH_JOURNEY_ID,
|
||||
SEARCH_JOURNEY_UNAVAILABLE_COUNTERS,
|
||||
toSearchIterationRecord,
|
||||
} from '../performance/search-journey-record';
|
||||
import {
|
||||
JOURNEY_ITERATION_TIMEOUT_MS,
|
||||
JOURNEY_MEASURED_ITERATIONS,
|
||||
JOURNEY_WARMUP_ITERATIONS,
|
||||
logJourneyIteration,
|
||||
writeJourneyRunEntry,
|
||||
} from './journey-run';
|
||||
import {
|
||||
LAUNCH_JOURNEY_MOCK_ORIGIN,
|
||||
removeLaunchJourneyProfile,
|
||||
runLaunchJourney,
|
||||
seedLaunchJourneyProfile,
|
||||
} from './launch-journey-app';
|
||||
import {
|
||||
measureSearchJourney,
|
||||
SEARCH_JOURNEY_SEED,
|
||||
} from './search-journey-app';
|
||||
|
||||
/**
|
||||
* J4 "Search": type a six-character query into the header search box on
|
||||
* /workspace/search until the global search results have settled. Every
|
||||
* iteration is a fresh J1 launch on a copy of the seeded profile (one M3U
|
||||
* source and the mock's 12,000-item `large` Xtream catalog), with the
|
||||
* main-process counters on so SQL statements are counted.
|
||||
* Contract: docs/architecture/performance-journeys.md.
|
||||
*/
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('J4 search', async () => {
|
||||
const iterations: JourneyIterationRecord[] = [];
|
||||
let electronVersion = 'unknown';
|
||||
const templateDirectory = await seedLaunchJourneyProfile(
|
||||
LAUNCH_JOURNEY_MOCK_ORIGIN,
|
||||
SEARCH_JOURNEY_SEED
|
||||
);
|
||||
try {
|
||||
const total = JOURNEY_WARMUP_ITERATIONS + JOURNEY_MEASURED_ITERATIONS;
|
||||
for (let index = 0; index < total; index += 1) {
|
||||
const warmup = index < JOURNEY_WARMUP_ITERATIONS;
|
||||
const { continuation, launch } = await runLaunchJourney(
|
||||
templateDirectory,
|
||||
JOURNEY_ITERATION_TIMEOUT_MS,
|
||||
// SQL statements are a J4 counter, so unlike J2 and J3 the
|
||||
// launch runs with the main-process counters and SQL hook.
|
||||
{ idleWindowMs: null, mainCounters: true },
|
||||
(session) =>
|
||||
measureSearchJourney(
|
||||
session,
|
||||
JOURNEY_ITERATION_TIMEOUT_MS
|
||||
).catch((failure: unknown) => {
|
||||
throw new Error(
|
||||
`iteration ${index}: ${String(failure)}`
|
||||
);
|
||||
})
|
||||
);
|
||||
electronVersion = launch.electronVersion;
|
||||
const record = toSearchIterationRecord(index, warmup, continuation);
|
||||
iterations.push(record);
|
||||
logJourneyIteration(SEARCH_JOURNEY_ID, record);
|
||||
}
|
||||
} finally {
|
||||
await removeLaunchJourneyProfile(templateDirectory);
|
||||
}
|
||||
|
||||
await writeJourneyRunEntry(
|
||||
SEARCH_JOURNEY_ID,
|
||||
iterations,
|
||||
SEARCH_JOURNEY_UNAVAILABLE_COUNTERS,
|
||||
electronVersion
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,638 @@
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
channelItemByTitle,
|
||||
clickCategoryByNameExact,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
openSources,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
saveSettings,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import { fetchXtreamLiveFixture, getXtreamTitle } from './portal-mock-fixtures';
|
||||
import { applyTheme } from './theme-contrast';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard focus is visible everywhere. The global stylesheet used to strip
|
||||
// the outline from every input, button and `:focus`; it now draws a fallback
|
||||
// ring on `:focus-visible`, and a component with an indicator of its own
|
||||
// keeps it. Every Tab stop below must show exactly one ring, on itself or on
|
||||
// the ancestor or sibling that draws it for it (a content card, a checkbox,
|
||||
// a select's form-field outline), in both themes, at 3:1 against what it
|
||||
// sits on where that is a flat colour, and not cut by an ancestor's
|
||||
// `overflow: hidden`. A mouse click leaves no ring.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Ring = {
|
||||
kind: 'outline' | 'box-shadow' | 'field-outline';
|
||||
on: string;
|
||||
color: string;
|
||||
/** Null where the ring sits on artwork or a gradient. */
|
||||
contrast: number | null;
|
||||
/** The `overflow: hidden` ancestor that cuts the outline, if any. */
|
||||
clippedBy: string | null;
|
||||
/** `--app-focus-ring` where the ring is drawn. */
|
||||
token: string;
|
||||
};
|
||||
|
||||
type Stop = { element: string; label: string; rings: Ring[] };
|
||||
|
||||
type Probe = {
|
||||
capture(): { element: string; label: string };
|
||||
settle(): Ring[];
|
||||
clicked(): { element: string; focusVisible: boolean; outline: string };
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__focusRingProbe?: Probe;
|
||||
}
|
||||
}
|
||||
|
||||
/** Calls one method of the probe installed in the page. */
|
||||
function callProbe<M extends keyof Probe>(
|
||||
page: Page,
|
||||
method: M
|
||||
): Promise<ReturnType<Probe[M]>> {
|
||||
return page.evaluate((name) => {
|
||||
const probe = window.__focusRingProbe;
|
||||
if (!probe) throw new Error('The focus ring probe is not installed.');
|
||||
return probe[name]() as ReturnType<Probe[M]>;
|
||||
}, method);
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the in-page probe. `capture` snapshots the rings around the
|
||||
* focused element; `settle`, run once focus has moved on, reports the rings
|
||||
* that went away with it, i.e. the ones that focus drew.
|
||||
*/
|
||||
async function installProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
if (window.__focusRingProbe) return;
|
||||
const canvas = document.createElement('canvas').getContext('2d', {
|
||||
willReadFrequently: true,
|
||||
}) as CanvasRenderingContext2D;
|
||||
const transparent = (color: string) =>
|
||||
color === 'transparent' || /,\s*0\)$/.test(color);
|
||||
const ringOf = (el: Element) => {
|
||||
const style = getComputedStyle(el);
|
||||
const outline =
|
||||
style.outlineStyle !== 'none' &&
|
||||
parseFloat(style.outlineWidth) > 0 &&
|
||||
!transparent(style.outlineColor)
|
||||
? style.outlineColor
|
||||
: '';
|
||||
// A focus ring or line drawn as an unblurred shadow: a spread
|
||||
// ring (`<color> 0 0 0 2px`) or an offset line, inset or not.
|
||||
const line =
|
||||
/(rgba?\([^)]*\)) (-?[\d.]+)px (-?[\d.]+)px 0px(?: (-?[\d.]+)px)?( inset)?/.exec(
|
||||
style.boxShadow
|
||||
);
|
||||
const shadow =
|
||||
line &&
|
||||
[line[2], line[3], line[4] ?? '0'].some(
|
||||
(length) => parseFloat(length) !== 0
|
||||
)
|
||||
? line[0]
|
||||
: '';
|
||||
return {
|
||||
outline,
|
||||
offset: parseFloat(style.outlineOffset) || 0,
|
||||
shadow,
|
||||
// A Material form field shows focus on its outline's border.
|
||||
border: el.classList.contains('mdc-notched-outline__leading')
|
||||
? `${style.borderTopWidth} ${style.borderTopColor}`
|
||||
: '',
|
||||
};
|
||||
};
|
||||
const name = (el: Element) =>
|
||||
el.tagName.toLowerCase() +
|
||||
[...el.classList]
|
||||
.filter((c) => !c.startsWith('ng-') && !c.startsWith('cdk-'))
|
||||
.slice(0, 2)
|
||||
.map((c) => `.${c}`)
|
||||
.join('');
|
||||
/** Luminance of `colors` painted in order over white. */
|
||||
const paint = (colors: string[]) => {
|
||||
canvas.fillStyle = '#fff';
|
||||
canvas.fillRect(0, 0, 1, 1);
|
||||
for (const color of colors) {
|
||||
canvas.fillStyle = color;
|
||||
canvas.fillRect(0, 0, 1, 1);
|
||||
}
|
||||
const [r, g, b] = Array.from(
|
||||
canvas.getImageData(0, 0, 1, 1).data.slice(0, 3)
|
||||
).map((channel) => {
|
||||
const c = channel / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
/** Background layers from the root down to `el`, or null on art. */
|
||||
const layersUnder = (el: Element | null) => {
|
||||
const layers: string[] = [];
|
||||
for (let node = el; node; node = node.parentElement) {
|
||||
const style = getComputedStyle(node);
|
||||
if (style.backgroundImage !== 'none') return null;
|
||||
layers.unshift(style.backgroundColor);
|
||||
}
|
||||
return layers;
|
||||
};
|
||||
/**
|
||||
* The ancestor whose `overflow: hidden` cuts `el`'s outline, looking
|
||||
* up to the nearest scroll container: past it, whether a ring at the
|
||||
* edge shows depends on the scroll position, not on the styles.
|
||||
*/
|
||||
const clipperOf = (el: Element) => {
|
||||
const style = getComputedStyle(el);
|
||||
if (style.outlineStyle === 'none') return null;
|
||||
const reach = Math.max(
|
||||
0,
|
||||
(parseFloat(style.outlineOffset) || 0) +
|
||||
parseFloat(style.outlineWidth)
|
||||
);
|
||||
const box = el.getBoundingClientRect();
|
||||
for (let node = el.parentElement; node; node = node.parentElement) {
|
||||
const clip = getComputedStyle(node);
|
||||
const axes = [clip.overflowX, clip.overflowY];
|
||||
if (axes.some((axis) => axis === 'auto' || axis === 'scroll')) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!axes.some((axis) => axis === 'hidden' || axis === 'clip')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const edge = node.getBoundingClientRect();
|
||||
const inset = (side: string) =>
|
||||
parseFloat(clip.getPropertyValue(`border-${side}-width`)) -
|
||||
0.5;
|
||||
if (
|
||||
box.left - reach < edge.left + inset('left') ||
|
||||
box.top - reach < edge.top + inset('top') ||
|
||||
box.right + reach > edge.right - inset('right') ||
|
||||
box.bottom + reach > edge.bottom - inset('bottom')
|
||||
) {
|
||||
return name(node);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
/** The app ring colour in `el`'s context, as `rgb(…)`. */
|
||||
const tokenAt = (el: Element) => {
|
||||
const hex = getComputedStyle(el)
|
||||
.getPropertyValue('--app-focus-ring')
|
||||
.trim();
|
||||
canvas.fillStyle = '#000';
|
||||
canvas.fillStyle = hex;
|
||||
const [r, g, b] = Array.from(
|
||||
(() => {
|
||||
canvas.fillRect(0, 0, 1, 1);
|
||||
return canvas.getImageData(0, 0, 1, 1).data;
|
||||
})()
|
||||
);
|
||||
return hex ? `rgb(${r}, ${g}, ${b})` : '';
|
||||
};
|
||||
const contrastOf = (on: Element, color: string, inside: boolean) => {
|
||||
const layers = layersUnder(inside ? on : on.parentElement);
|
||||
if (!layers) return null;
|
||||
const back = paint(layers);
|
||||
const ring = paint([...layers, color]);
|
||||
return (
|
||||
(Math.max(back, ring) + 0.05) / (Math.min(back, ring) + 0.05)
|
||||
);
|
||||
};
|
||||
|
||||
let pending: {
|
||||
candidates: Element[];
|
||||
before: (ReturnType<typeof ringOf> & {
|
||||
clippedBy: string | null;
|
||||
})[];
|
||||
} | null = null;
|
||||
|
||||
window.__focusRingProbe = {
|
||||
capture() {
|
||||
const el = document.activeElement ?? document.body;
|
||||
const candidates = [el];
|
||||
for (
|
||||
let node = el.parentElement, depth = 0;
|
||||
node && depth < 3;
|
||||
node = node.parentElement, depth++
|
||||
) {
|
||||
candidates.push(node);
|
||||
}
|
||||
for (const sibling of el.parentElement?.children ?? []) {
|
||||
if (sibling !== el) candidates.push(sibling);
|
||||
}
|
||||
const fieldOutline = el
|
||||
.closest('.mat-mdc-form-field')
|
||||
?.querySelector('.mdc-notched-outline__leading');
|
||||
if (fieldOutline) candidates.push(fieldOutline);
|
||||
// Geometry is read while focused: moving on may scroll.
|
||||
pending = {
|
||||
candidates,
|
||||
before: candidates.map((candidate) => ({
|
||||
...ringOf(candidate),
|
||||
clippedBy: clipperOf(candidate),
|
||||
})),
|
||||
};
|
||||
return {
|
||||
element: name(el),
|
||||
label: (
|
||||
el.getAttribute('aria-label') ??
|
||||
el.textContent ??
|
||||
''
|
||||
)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 40),
|
||||
};
|
||||
},
|
||||
settle() {
|
||||
if (!pending) return [];
|
||||
const { candidates, before } = pending;
|
||||
pending = null;
|
||||
return candidates.flatMap((on, index) => {
|
||||
const was = before[index];
|
||||
const now = ringOf(on);
|
||||
const rings: Ring[] = [];
|
||||
if (was.outline && was.outline !== now.outline) {
|
||||
rings.push({
|
||||
kind: 'outline',
|
||||
on: name(on),
|
||||
color: was.outline,
|
||||
contrast: contrastOf(
|
||||
on,
|
||||
was.outline,
|
||||
was.offset < 0
|
||||
),
|
||||
clippedBy: was.clippedBy,
|
||||
token: tokenAt(on),
|
||||
});
|
||||
}
|
||||
if (was.shadow && was.shadow !== now.shadow) {
|
||||
const color = was.shadow.replace(/\) .*$/, ')');
|
||||
rings.push({
|
||||
kind: 'box-shadow',
|
||||
on: name(on),
|
||||
color,
|
||||
contrast: contrastOf(
|
||||
on,
|
||||
color,
|
||||
was.shadow.endsWith(' inset')
|
||||
),
|
||||
clippedBy: null,
|
||||
token: tokenAt(on),
|
||||
});
|
||||
}
|
||||
if (was.border && was.border !== now.border) {
|
||||
const color = getComputedStyle(on).borderTopColor;
|
||||
const focused = was.border.replace(/^\S+ /, '');
|
||||
rings.push({
|
||||
kind: 'field-outline',
|
||||
on: name(on),
|
||||
color: focused,
|
||||
// Against the field's own fill, inside the line.
|
||||
contrast:
|
||||
focused === color
|
||||
? null
|
||||
: contrastOf(on, focused, true),
|
||||
clippedBy: null,
|
||||
token: tokenAt(on),
|
||||
});
|
||||
}
|
||||
return rings;
|
||||
});
|
||||
},
|
||||
clicked() {
|
||||
const el = document.activeElement ?? document.body;
|
||||
return {
|
||||
element: name(el),
|
||||
focusVisible: el.matches(':focus-visible'),
|
||||
outline: ringOf(el).outline,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabs from `from` through `area`, up to `limit` stops, and reports the
|
||||
* rings each stop drew. `from` is entered from the keyboard (Tab, then
|
||||
* Shift+Tab) so it matches `:focus-visible` like the stops after it.
|
||||
*/
|
||||
async function tabThrough(
|
||||
page: Page,
|
||||
area: Locator,
|
||||
from: Locator,
|
||||
limit: number
|
||||
): Promise<Stop[]> {
|
||||
await installProbe(page);
|
||||
await from.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(from).toBeFocused();
|
||||
|
||||
const stops: Stop[] = [];
|
||||
while (stops.length < limit) {
|
||||
const inside = await area.evaluate((root) =>
|
||||
root.contains(document.activeElement)
|
||||
);
|
||||
if (!inside) break;
|
||||
const focused = await callProbe(page, 'capture');
|
||||
await page.keyboard.press('Tab');
|
||||
const rings = await callProbe(page, 'settle');
|
||||
stops.push({ ...focused, rings });
|
||||
}
|
||||
return stops;
|
||||
}
|
||||
|
||||
/** Every stop shows one uncut ring with 3:1 against a flat background. */
|
||||
function expectOneVisibleRing(stops: Stop[], context: string): void {
|
||||
expect(stops.length, `${context}: Tab stops`).toBeGreaterThan(1);
|
||||
for (const stop of stops) {
|
||||
const where = `${context}: ${stop.element} "${stop.label}"`;
|
||||
expect(stop.rings, where).toHaveLength(1);
|
||||
const [ring] = stop.rings;
|
||||
expect(ring.clippedBy, `${where} ring clipped`).toBeNull();
|
||||
// One ring colour everywhere. A select shows focus on its Material
|
||||
// form-field outline, which keeps the theme's field colours.
|
||||
if (ring.kind !== 'field-outline') {
|
||||
expect(ring.color, `${where} ring colour`).toBe(ring.token);
|
||||
}
|
||||
if (ring.contrast !== null) {
|
||||
expect(
|
||||
ring.contrast,
|
||||
`${where} ring contrast`
|
||||
).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The focused element's outline, and whether focus counts as visible. */
|
||||
function ringOfFocused(
|
||||
page: Page
|
||||
): Promise<{ focusVisible: boolean; outline: string }> {
|
||||
return page.evaluate(() => {
|
||||
const el = document.activeElement ?? document.body;
|
||||
const style = getComputedStyle(el);
|
||||
return {
|
||||
focusVisible: el.matches(':focus-visible'),
|
||||
outline: `${style.outlineStyle} ${style.outlineWidth} ${style.outlineColor}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** `--app-focus-ring` at the focused element, as `rgb(…)`. */
|
||||
function focusRingRgb(page: Page): Promise<string> {
|
||||
return page.evaluate(() => {
|
||||
const probe = document.createElement('span');
|
||||
probe.style.color = 'var(--app-focus-ring)';
|
||||
(document.activeElement ?? document.body).appendChild(probe);
|
||||
const color = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return color;
|
||||
});
|
||||
}
|
||||
|
||||
/** A mouse click focuses `target` without a ring. */
|
||||
async function expectNoRingAfterClick(
|
||||
page: Page,
|
||||
target: Locator,
|
||||
context: string
|
||||
): Promise<void> {
|
||||
await installProbe(page);
|
||||
await target.click();
|
||||
const clicked = await callProbe(page, 'clicked');
|
||||
expect(clicked, `${context}: focus after a click`).toEqual({
|
||||
element: clicked.element,
|
||||
focusVisible: false,
|
||||
outline: '',
|
||||
});
|
||||
}
|
||||
|
||||
async function openFirstSeries(page: Page): Promise<void> {
|
||||
await openWorkspaceSection(page, 'Series');
|
||||
await clickFirstGridListCard(page);
|
||||
await expect(page.locator('[data-test-id="episodes-heading"]')).toBeVisible(
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function openMoviesWithRatingFilter(page: Page): Promise<void> {
|
||||
await openWorkspaceSection(page, 'Movies');
|
||||
const view = page.locator('app-category-content-view');
|
||||
await expect(view.locator('mat-card').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await view.locator('.refine-action').click();
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: /^\s*5\.0 and higher/ })
|
||||
.click();
|
||||
await expect(view.locator('.rating-refinement-chip')).toBeVisible();
|
||||
}
|
||||
|
||||
/** Switches the guide to its list view and opens a live channel's EPG. */
|
||||
async function openLiveEpgList(page: Page, request: APIRequestContext) {
|
||||
const fixture = await fetchXtreamLiveFixture(request, {
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
});
|
||||
const channel = fixture.items[0];
|
||||
if (!channel) throw new Error('Expected a live channel in the mock.');
|
||||
// The mock's live streams redirect to an external demo stream; the
|
||||
// guide needs none of it, so keep that request pending.
|
||||
await page.route('https://test-streams.mux.dev/**', () => undefined);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, 'epg');
|
||||
await page.getByTestId('epg-view-mode-list').click();
|
||||
await saveSettings(page);
|
||||
await openWorkspaceSection(page, 'Live TV');
|
||||
await clickCategoryByNameExact(page, fixture.categoryName);
|
||||
await channelItemByTitle(page, getXtreamTitle(channel)).first().click();
|
||||
const guide = page.locator('app-epg-list-view');
|
||||
await expect(guide.locator('app-epg-list-view-row').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
return guide;
|
||||
}
|
||||
|
||||
/** Opens the command palette from the keyboard. */
|
||||
async function openCommandPalette(page: Page): Promise<Locator> {
|
||||
await page.locator('body').focus();
|
||||
await page.keyboard.press(
|
||||
`${process.platform === 'darwin' ? 'Meta' : 'Control'}+K`
|
||||
);
|
||||
const palette = page.locator(
|
||||
'mat-dialog-container app-workspace-command-palette'
|
||||
);
|
||||
await expect(palette).toBeVisible();
|
||||
return palette;
|
||||
}
|
||||
|
||||
test.describe('Keyboard focus ring', () => {
|
||||
test('@electron @theme every Tab stop shows one visible ring in both themes, a click none', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
const report: Record<string, Stop[]> = {};
|
||||
try {
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
|
||||
const walk = async (
|
||||
context: string,
|
||||
area: Locator,
|
||||
from: Locator,
|
||||
limit: number
|
||||
) => {
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
const stops = await tabThrough(page, area, from, limit);
|
||||
report[`${context} (${theme})`] = stops;
|
||||
expectOneVisibleRing(stops, `${context} in ${theme}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Detail page: the hero's action row, then the season tabs.
|
||||
await openFirstSeries(page);
|
||||
const shell = page.locator('app-portal-detail-shell');
|
||||
await walk(
|
||||
'detail actions and season tabs',
|
||||
shell,
|
||||
shell.locator('app-detail-action-button button').first(),
|
||||
14
|
||||
);
|
||||
expect(
|
||||
report['detail actions and season tabs (dark)'].map(
|
||||
(stop) => stop.element
|
||||
)
|
||||
).toContain('button.season-tabs__pill');
|
||||
|
||||
const pill = shell.locator('.season-tabs__pill').first();
|
||||
await expectNoRingAfterClick(page, pill, 'season tab');
|
||||
await expectNoRingAfterClick(
|
||||
page,
|
||||
shell.locator('.mat-button-toggle-button').last(),
|
||||
'episode view toggle'
|
||||
);
|
||||
|
||||
// A catalog grid and its refinement chips. Closing the refine
|
||||
// menu with a click hands focus back to its trigger by script:
|
||||
// still pointer focus, so no ring.
|
||||
await openMoviesWithRatingFilter(page);
|
||||
await installProbe(page);
|
||||
expect(await callProbe(page, 'clicked')).toEqual({
|
||||
element: expect.stringContaining('mat-mdc-button-base'),
|
||||
focusVisible: false,
|
||||
outline: '',
|
||||
});
|
||||
const view = page.locator('app-category-content-view');
|
||||
await walk(
|
||||
'catalog refinements and grid',
|
||||
view,
|
||||
view.locator('.rating-refinement-chip'),
|
||||
6
|
||||
);
|
||||
|
||||
// Sources: the type filters, then the list's row actions.
|
||||
await openSources(page);
|
||||
await expect(
|
||||
page.locator('app-playlist-item').first()
|
||||
).toBeVisible();
|
||||
await walk(
|
||||
'sources list',
|
||||
page.locator('app-workspace-shell'),
|
||||
page
|
||||
.locator('app-workspace-sources-filters-panel button')
|
||||
.first(),
|
||||
12
|
||||
);
|
||||
expect(
|
||||
report['sources list (dark)'].map((stop) => stop.label)
|
||||
).toContain('Check again');
|
||||
|
||||
// The live EPG list: its rows are Tab stops (`role="button"`)
|
||||
// with their own inset ring, then the row's info button.
|
||||
const guide = await openLiveEpgList(page, request);
|
||||
await walk(
|
||||
'epg list',
|
||||
guide,
|
||||
guide.locator('app-epg-list-view-row').first(),
|
||||
4
|
||||
);
|
||||
expect(
|
||||
report['epg list (dark)'].map((stop) => stop.element)
|
||||
).toContainEqual(expect.stringMatching(/^app-epg-list-view-row\b/));
|
||||
|
||||
// Settings: the section navigation, then the first page's
|
||||
// controls (selects, switches, the theme switcher).
|
||||
await openSettings(page);
|
||||
const firstSection = page
|
||||
.locator('[data-test-id^="settings-section-"]')
|
||||
.first();
|
||||
await walk(
|
||||
'settings',
|
||||
page.locator('app-workspace-shell'),
|
||||
firstSection,
|
||||
24
|
||||
);
|
||||
|
||||
// A search result chosen with Enter focuses its row by script;
|
||||
// the keypress keeps `:focus-visible`, so the row shows the ring.
|
||||
await applyTheme(page, 'light');
|
||||
const search = page.locator(
|
||||
'app-workspace-shell-header input[type="search"]'
|
||||
);
|
||||
await search.fill('subtitles');
|
||||
await page
|
||||
.getByTestId('settings-search-result-show-captions')
|
||||
.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const revealed = page.locator('[data-setting-id="show-captions"]');
|
||||
await expect(revealed).toBeFocused();
|
||||
expect(await ringOfFocused(page)).toEqual({
|
||||
focusVisible: true,
|
||||
outline: `solid 2px ${await focusRingRgb(page)}`,
|
||||
});
|
||||
|
||||
const toggle = page
|
||||
.getByTestId('settings-container')
|
||||
.locator('mat-slide-toggle [role="switch"]')
|
||||
.first();
|
||||
await expectNoRingAfterClick(page, toggle, 'settings switch');
|
||||
await toggle.click();
|
||||
|
||||
// The command palette: its search field draws no outline, so the
|
||||
// row underlines it; then the commands.
|
||||
const palette = await openCommandPalette(page);
|
||||
await walk(
|
||||
'command palette',
|
||||
palette,
|
||||
palette.locator('.palette-search input'),
|
||||
3
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(palette).toBeHidden();
|
||||
} finally {
|
||||
// Every stop and the rings it drew, for a failure on CI.
|
||||
await test.info().attach('focus-stops.json', {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
|
||||
type ScrollEndProbe = Element & { e2eScrollEnded?: boolean };
|
||||
|
||||
/**
|
||||
* Press a key that scrolls `scroller`, then wait for that scroll's `scrollend`.
|
||||
*
|
||||
* Chromium drops a keyboard scroll pressed in the frame or two between the
|
||||
* compositor finishing the previous smooth scroll and Blink handling that
|
||||
* finish: the key retargets the finished animation, and the late finish
|
||||
* notification then resets it. The final `scrollTop` arrives before that
|
||||
* notification, so the next discrete scroll input waits for `scrollend`
|
||||
* instead. The key must scroll; a key that does not fails here.
|
||||
*/
|
||||
export async function pressScrollKey(
|
||||
page: Page,
|
||||
scroller: Locator,
|
||||
key: string
|
||||
): Promise<void> {
|
||||
await scroller.evaluate((element) => {
|
||||
const probe = element as ScrollEndProbe;
|
||||
probe.e2eScrollEnded = false;
|
||||
element.addEventListener(
|
||||
'scrollend',
|
||||
() => {
|
||||
probe.e2eScrollEnded = true;
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
await page.keyboard.press(key);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
scroller.evaluate(
|
||||
(element) => (element as ScrollEndProbe).e2eScrollEnded
|
||||
),
|
||||
{ message: `${key} scroll ended` }
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
routePlayableStreams,
|
||||
startAndConfirmPlayback,
|
||||
} from './playable-stream-fixture';
|
||||
import { clearRecentItems } from './recent-clear.e2e-support';
|
||||
|
||||
/**
|
||||
* Issue #1458, second report: "all channels disappear after clearing the
|
||||
@@ -160,13 +161,7 @@ test.describe('Live channel rail collapse (#1458)', () => {
|
||||
channelItemByTitle(app.mainWindow, 'Channel Alpha').first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Clear recently viewed Live TV' })
|
||||
.click();
|
||||
await app.mainWindow
|
||||
.locator('mat-dialog-container')
|
||||
.getByRole('button', { name: 'Clear', exact: true })
|
||||
.click();
|
||||
await clearRecentItems(app.mainWindow, 'Live TV');
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'Channel Alpha')
|
||||
).toHaveCount(0);
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
closeElectronApp,
|
||||
electronMainPath,
|
||||
expect,
|
||||
type LaunchedElectronApp,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
openSources,
|
||||
openWorkspaceSection,
|
||||
restartElectronApp,
|
||||
sourceRowByTitle,
|
||||
test,
|
||||
waitForM3uCatalog,
|
||||
writeTemporaryM3uFile,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
expectRendererReloadedOnRoute,
|
||||
reloadFromMainProcess,
|
||||
} from './renderer-reload.support';
|
||||
|
||||
const SOURCE_TITLE = 'group-management-source';
|
||||
const HIDDEN_GROUPS = ['Movies', 'Sports'];
|
||||
const VISIBLE_HIDDEN_GROUPS_KEY = '__e2eVisibleHiddenGroups';
|
||||
const RECONCILE_CHANNEL = 'EPG_RECONCILE_SOURCES';
|
||||
|
||||
interface ReconcileScript {
|
||||
calls: number;
|
||||
gate: Promise<void>;
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
type ReconcileGlobal = typeof globalThis & {
|
||||
__e2eReconcileScript?: ReconcileScript;
|
||||
};
|
||||
|
||||
// Serialized into the test bootstrap; all runtime dependencies are arguments.
|
||||
function installReconcileScript(
|
||||
{ ipcMain }: Pick<typeof import('electron'), 'ipcMain'>,
|
||||
channel: string
|
||||
): void {
|
||||
const register = ipcMain.handle;
|
||||
const scope = globalThis as ReconcileGlobal;
|
||||
ipcMain.handle = function (name, listener) {
|
||||
if (name !== channel) {
|
||||
return register.call(this, name, listener);
|
||||
}
|
||||
return register.call(this, name, async (...args) => {
|
||||
const script = scope.__e2eReconcileScript;
|
||||
if (script) {
|
||||
script.calls += 1;
|
||||
if (script.calls === 1) return { success: false };
|
||||
await script.gate;
|
||||
}
|
||||
return listener(...args);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function launchWithReconcileScript(
|
||||
dataDir: string
|
||||
): Promise<LaunchedElectronApp> {
|
||||
const entryPoint = join(dataDir, 'reconcile-script.cjs');
|
||||
writeFileSync(
|
||||
entryPoint,
|
||||
[
|
||||
`(${installReconcileScript.toString()})(require('electron'), ${JSON.stringify(RECONCILE_CHANNEL)});`,
|
||||
`require(${JSON.stringify(electronMainPath)});`,
|
||||
].join('\n')
|
||||
);
|
||||
return launchElectronApp(dataDir, { entryPoint });
|
||||
}
|
||||
|
||||
/**
|
||||
* The next renderer's startup reconciliation fails, and the retry the
|
||||
* playlist inventory awaits is parked until `release`: the M3U route then
|
||||
* activates (its settings resolver has finished) while the store still has no
|
||||
* playlists.
|
||||
*/
|
||||
async function delayPlaylistInventory(app: LaunchedElectronApp) {
|
||||
await app.electronApp.evaluate(() => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
(globalThis as ReconcileGlobal).__e2eReconcileScript = {
|
||||
calls: 0,
|
||||
gate,
|
||||
release,
|
||||
};
|
||||
});
|
||||
return {
|
||||
waitUntilParked: () =>
|
||||
expect
|
||||
.poll(() =>
|
||||
app.electronApp.evaluate(
|
||||
() =>
|
||||
(globalThis as ReconcileGlobal).__e2eReconcileScript
|
||||
?.calls ?? 0
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(1),
|
||||
release: () =>
|
||||
app.electronApp.evaluate(() => {
|
||||
const scope = globalThis as ReconcileGlobal;
|
||||
const script = scope.__e2eReconcileScript;
|
||||
delete scope.__e2eReconcileScript;
|
||||
script?.release();
|
||||
}),
|
||||
};
|
||||
}
|
||||
const CHANNELS = ['News', 'Sports', 'Movies'].flatMap((group) =>
|
||||
[1, 2].map((index) => ({
|
||||
groupTitle: group,
|
||||
name: `${group} Channel ${index}`,
|
||||
url: `https://streams.example.test/${group.toLowerCase()}-${index}.m3u8`,
|
||||
}))
|
||||
);
|
||||
|
||||
function groupNavLabels(page: Page): Locator {
|
||||
return page.locator('app-groups-view .group-nav-item .nav-item-label');
|
||||
}
|
||||
|
||||
async function openGroupManagement(page: Page): Promise<Locator> {
|
||||
await page
|
||||
.getByRole('button', { name: 'Manage groups', exact: true })
|
||||
.click();
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
await expect(dialog.locator('.group-item')).toHaveCount(3);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
function groupCheckbox(dialog: Locator, group: string): Locator {
|
||||
return dialog
|
||||
.locator('.group-item')
|
||||
.filter({ hasText: group })
|
||||
.getByRole('checkbox');
|
||||
}
|
||||
|
||||
async function storedHiddenGroupTitles(page: Page): Promise<unknown> {
|
||||
return page.evaluate(async () => {
|
||||
const playlistId = location.pathname.match(
|
||||
/\/workspace\/playlists\/([^/]+)/
|
||||
)?.[1];
|
||||
if (!playlistId) throw new Error('M3U playlist route is missing');
|
||||
const api = (window as unknown as { electron: ElectronBridgeApi })
|
||||
.electron;
|
||||
const playlist = (await api.dbGetAppPlaylist(playlistId)) as {
|
||||
hiddenGroupTitles?: unknown;
|
||||
} | null;
|
||||
const hidden = playlist?.hiddenGroupTitles;
|
||||
return Array.isArray(hidden) ? [...hidden].sort() : hidden;
|
||||
});
|
||||
}
|
||||
|
||||
/** Imports the playlist and hides {@link HIDDEN_GROUPS} in Manage groups. */
|
||||
async function importAndHideGroups(
|
||||
app: LaunchedElectronApp,
|
||||
dataDir: string
|
||||
): Promise<void> {
|
||||
const filePath = writeTemporaryM3uFile(
|
||||
dataDir,
|
||||
`${SOURCE_TITLE}.m3u`,
|
||||
CHANNELS
|
||||
);
|
||||
await importM3uPlaylistFromNativeDialog(app, filePath);
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Groups');
|
||||
await expect(groupNavLabels(app.mainWindow)).toHaveText([
|
||||
'Movies',
|
||||
'News',
|
||||
'Sports',
|
||||
]);
|
||||
|
||||
const dialog = await openGroupManagement(app.mainWindow);
|
||||
for (const group of HIDDEN_GROUPS) {
|
||||
await groupCheckbox(dialog, group).uncheck();
|
||||
}
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']);
|
||||
await expect
|
||||
.poll(() => storedHiddenGroupTitles(app.mainWindow))
|
||||
.toEqual(HIDDEN_GROUPS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records, from the start of every new document, each hidden group the
|
||||
* groups rail ever renders visibly — including before the startup inventory
|
||||
* publishes the playlist.
|
||||
*/
|
||||
async function recordVisibleHiddenGroups(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
await app.electronApp.context().addInitScript(
|
||||
({ hiddenGroups, key }) => {
|
||||
const seen: string[] = [];
|
||||
Reflect.set(window, key, seen);
|
||||
const check = () => {
|
||||
for (const item of document.querySelectorAll(
|
||||
'app-groups-view .group-nav-item'
|
||||
)) {
|
||||
const group = item.getAttribute('data-group-key') ?? '';
|
||||
if (
|
||||
hiddenGroups.includes(group) &&
|
||||
!seen.includes(group) &&
|
||||
item.checkVisibility()
|
||||
) {
|
||||
seen.push(group);
|
||||
}
|
||||
}
|
||||
};
|
||||
new MutationObserver(check).observe(document, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
},
|
||||
{ hiddenGroups: HIDDEN_GROUPS, key: VISIBLE_HIDDEN_GROUPS_KEY }
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('Electron M3U group management', () => {
|
||||
test('keeps groups hidden in Manage groups after a restart (#1894)', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importAndHideGroups(app, dataDir);
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
app.mainWindow = restarted.mainWindow;
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, SOURCE_TITLE)
|
||||
.first()
|
||||
.click();
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Groups');
|
||||
await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']);
|
||||
|
||||
// The dialog opens from the restored state, so saving it again
|
||||
// cannot silently unhide the groups.
|
||||
const reopened = await openGroupManagement(app.mainWindow);
|
||||
await expect(groupCheckbox(reopened, 'News')).toBeChecked();
|
||||
await expect(groupCheckbox(reopened, 'Movies')).not.toBeChecked();
|
||||
await expect(groupCheckbox(reopened, 'Sports')).not.toBeChecked();
|
||||
await reopened
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.click();
|
||||
await expect(reopened).toBeHidden();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('never shows hidden groups when the Groups route opens before the playlist inventory', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchWithReconcileScript(dataDir);
|
||||
|
||||
try {
|
||||
await importAndHideGroups(app, dataDir);
|
||||
await recordVisibleHiddenGroups(app);
|
||||
const inventory = await delayPlaylistInventory(app);
|
||||
|
||||
// A reload boots a fresh store straight on this route.
|
||||
await reloadFromMainProcess(app);
|
||||
await inventory.waitUntilParked();
|
||||
// The route session has read the playlist and published its
|
||||
// channels, still behind the startup screen, before the store
|
||||
// has the playlist the hidden groups belong to.
|
||||
const rail = app.mainWindow.locator(
|
||||
'app-groups-view .group-nav-item'
|
||||
);
|
||||
await expect(rail).toHaveCount(3);
|
||||
await expect(rail.first()).toBeHidden();
|
||||
|
||||
await inventory.release();
|
||||
await expectRendererReloadedOnRoute(
|
||||
app.mainWindow,
|
||||
/\/workspace\/playlists\/[^/]+\/groups$/
|
||||
);
|
||||
await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']);
|
||||
expect(
|
||||
await app.mainWindow.evaluate(
|
||||
(key) => Reflect.get(window, key),
|
||||
VISIBLE_HIDDEN_GROUPS_KEY
|
||||
)
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import type { Locator, Page, TestInfo } from '@playwright/test';
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openSources,
|
||||
saveSettings,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
import { measureBackdropTextContrast } from './theme-contrast';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
const THEMES: readonly Theme[] = ['dark', 'light'];
|
||||
const COLOR_PROPERTIES = [
|
||||
'color',
|
||||
'background-color',
|
||||
'background-image',
|
||||
'border-top-color',
|
||||
'fill',
|
||||
'stroke',
|
||||
'box-shadow',
|
||||
];
|
||||
|
||||
/** Every colour the subtree paints, keyed by element and pseudo-element. */
|
||||
async function paintedColors(scope: Locator): Promise<Record<string, string>> {
|
||||
return scope.evaluate((root, properties) => {
|
||||
const colors: Record<string, string> = {};
|
||||
[root, ...Array.from(root.querySelectorAll('*'))].forEach(
|
||||
(element, index) => {
|
||||
const name = `${index}:${element.tagName.toLowerCase()}.${(
|
||||
element.getAttribute('class') ?? ''
|
||||
)
|
||||
.trim()
|
||||
.replace(/\s+/g, '.')}`;
|
||||
for (const pseudo of ['', '::before']) {
|
||||
const style = getComputedStyle(element, pseudo || null);
|
||||
for (const property of properties) {
|
||||
colors[`${name}${pseudo} ${property}`] =
|
||||
style.getPropertyValue(property);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
return colors;
|
||||
}, COLOR_PROPERTIES);
|
||||
}
|
||||
|
||||
/** Lets entrance fades finish, so contrast reads the settled page. */
|
||||
async function settleAnimations(page: Page): Promise<void> {
|
||||
await page.mouse.move(1, 1);
|
||||
await finishTransitions(page);
|
||||
}
|
||||
|
||||
/** Waits for the finite animations and transitions now running. */
|
||||
async function finishTransitions(page: Page): Promise<void> {
|
||||
await page.evaluate(() =>
|
||||
Promise.all(
|
||||
document
|
||||
.getAnimations()
|
||||
.filter(
|
||||
(animation) =>
|
||||
animation.effect?.getComputedTiming().iterations !==
|
||||
Infinity
|
||||
)
|
||||
.map((animation) => animation.finished.catch(() => undefined))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
interface Surface {
|
||||
name: string;
|
||||
scope: Locator;
|
||||
/**
|
||||
* Text measured against the rendered pixels under it, so gradient cards,
|
||||
* tinted chips and filled buttons all count.
|
||||
*/
|
||||
texts: Locator[];
|
||||
/** Cards whose hover changes their fill, with the text to re-measure. */
|
||||
hovered?: { card: Locator; texts: Locator[] }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks an explicit app theme in Settings and saves it. A fresh profile
|
||||
* follows the System theme, whose OS listener would rewrite the theme class
|
||||
* as soon as the OS scheme changes.
|
||||
*/
|
||||
async function chooseAppTheme(page: Page, theme: Theme): Promise<void> {
|
||||
await openSettings(page);
|
||||
await page
|
||||
.getByTestId(theme === 'dark' ? 'DARK_THEME' : 'LIGHT_THEME')
|
||||
.click();
|
||||
await saveSettings(page);
|
||||
await expect(page.locator('body')).toHaveClass(
|
||||
theme === 'dark' ? /dark-theme/ : /^(?!.*dark-theme)/
|
||||
);
|
||||
// The "saved" snackbar would cover the screenshots.
|
||||
await expect(page.locator('mat-snack-bar-container')).toBeHidden({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The surface takes its colours from the app theme alone: flipping the OS
|
||||
* colour scheme changes nothing, and text stays legible where the app and
|
||||
* the OS disagree. Returns the colours for the cross-theme comparison.
|
||||
*/
|
||||
async function expectFollowsAppTheme(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
theme: Theme,
|
||||
surface: Surface
|
||||
): Promise<Record<string, string>> {
|
||||
const osScheme: Theme = theme === 'dark' ? 'light' : 'dark';
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
const underMatchingOs = await paintedColors(surface.scope);
|
||||
await page.emulateMedia({ colorScheme: osScheme });
|
||||
expect(
|
||||
await paintedColors(surface.scope),
|
||||
`${surface.name}: app ${theme} theme on a ${osScheme} OS`
|
||||
).toEqual(underMatchingOs);
|
||||
|
||||
await settleAnimations(page);
|
||||
const shot = testInfo.outputPath(
|
||||
`${surface.name}-app-${theme}-os-${osScheme}.png`
|
||||
);
|
||||
await page.screenshot({ path: shot });
|
||||
await testInfo.attach(`${surface.name} app ${theme} on ${osScheme} OS`, {
|
||||
path: shot,
|
||||
contentType: 'image/png',
|
||||
});
|
||||
for (const [index, text] of surface.texts.entries()) {
|
||||
expect(
|
||||
await measureBackdropTextContrast(page, text),
|
||||
`${surface.name}: text ${index} in the ${theme} theme`
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
for (const { card, texts } of surface.hovered ?? []) {
|
||||
await card.hover();
|
||||
await finishTransitions(page);
|
||||
for (const [index, text] of texts.entries()) {
|
||||
expect(
|
||||
await measureBackdropTextContrast(page, text),
|
||||
`${surface.name}: hovered card text ${index} in the ${theme} theme`
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
await page.mouse.move(1, 1);
|
||||
}
|
||||
return underMatchingOs;
|
||||
}
|
||||
|
||||
/** Starts (or, with `drop`, finishes) a file drag over the workspace. */
|
||||
async function dragFileOverWorkspace(
|
||||
page: Page,
|
||||
fileName: string,
|
||||
drop = false
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ name, dropFile }) => {
|
||||
const target = document.querySelector('.workspace-shell');
|
||||
if (!target) throw new Error('Workspace drop target not found.');
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(new File(['#EXTM3U'], name));
|
||||
const types = dropFile
|
||||
? ['dragenter', 'dragover', 'drop']
|
||||
: ['dragenter', 'dragover'];
|
||||
for (const type of types) {
|
||||
target.dispatchEvent(
|
||||
new DragEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
{ name: fileName, dropFile: drop }
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('App theme over the OS colour scheme', () => {
|
||||
test('@theme @electron welcome screens and the drop overlay follow the app theme, not the OS', async ({
|
||||
dataDir,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(150_000);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
const colors: Record<string, Partial<Record<Theme, unknown>>> = {};
|
||||
const record = (name: string, theme: Theme, value: unknown) => {
|
||||
colors[name] = { ...colors[name], [theme]: value };
|
||||
};
|
||||
try {
|
||||
for (const theme of THEMES) {
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await chooseAppTheme(page, theme);
|
||||
|
||||
await goToDashboard(page);
|
||||
const dashboard = page.locator(
|
||||
'app-empty-state .welcome-dashboard'
|
||||
);
|
||||
await expect(dashboard).toBeVisible();
|
||||
record(
|
||||
'welcome-dashboard',
|
||||
theme,
|
||||
await expectFollowsAppTheme(page, testInfo, theme, {
|
||||
name: 'welcome-dashboard',
|
||||
scope: dashboard,
|
||||
texts: [
|
||||
dashboard.locator('.welcome-dashboard__tagline'),
|
||||
dashboard.locator(
|
||||
'.welcome-dashboard__description'
|
||||
),
|
||||
dashboard.locator('.feature-card__title').first(),
|
||||
dashboard.locator('.feature-card__desc').first(),
|
||||
dashboard.locator(
|
||||
'.welcome-dashboard__cta .mdc-button__label'
|
||||
),
|
||||
],
|
||||
hovered: [
|
||||
{
|
||||
card: dashboard
|
||||
.locator('.feature-card')
|
||||
.first(),
|
||||
texts: [
|
||||
dashboard
|
||||
.locator('.feature-card__title')
|
||||
.first(),
|
||||
dashboard
|
||||
.locator('.feature-card__desc')
|
||||
.first(),
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await openSources(page);
|
||||
const sources = page.locator(
|
||||
'app-empty-state .welcome-sources'
|
||||
);
|
||||
await expect(sources).toBeVisible();
|
||||
record(
|
||||
'welcome-sources',
|
||||
theme,
|
||||
await expectFollowsAppTheme(page, testInfo, theme, {
|
||||
name: 'welcome-sources',
|
||||
scope: sources,
|
||||
texts: [
|
||||
sources.locator('.welcome-sources__body'),
|
||||
sources.locator('.source-card__name').first(),
|
||||
sources.locator('.source-card__needs').first(),
|
||||
sources.locator('.source-card__chip').first(),
|
||||
sources
|
||||
.locator('.source-card__cta .mdc-button__label')
|
||||
.first(),
|
||||
],
|
||||
hovered: [
|
||||
{
|
||||
card: sources.locator('.source-card').first(),
|
||||
texts: [
|
||||
sources
|
||||
.locator('.source-card__name')
|
||||
.first(),
|
||||
sources
|
||||
.locator('.source-card__needs')
|
||||
.first(),
|
||||
sources
|
||||
.locator('.source-card__chip')
|
||||
.first(),
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const overlay = page.locator('app-playlist-drop-overlay');
|
||||
const card = overlay.locator('.drop-overlay__card');
|
||||
await dragFileOverWorkspace(page, 'channels.m3u');
|
||||
await expect(card).toBeVisible();
|
||||
record(
|
||||
'drop-overlay',
|
||||
theme,
|
||||
await expectFollowsAppTheme(page, testInfo, theme, {
|
||||
name: 'drop-overlay',
|
||||
scope: overlay,
|
||||
texts: [
|
||||
card.locator('.drop-overlay__title'),
|
||||
card.locator('.drop-overlay__body'),
|
||||
],
|
||||
})
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(card).toBeHidden();
|
||||
|
||||
// The rejection dismisses itself after 1.8s. Both reads must
|
||||
// see it, so a slow runner that loses it between them drops
|
||||
// again; a colour that follows the OS fails every attempt.
|
||||
await expect(async () => {
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await dragFileOverWorkspace(page, 'notes.txt', true);
|
||||
await expect(card).toHaveClass(/is-rejected/, {
|
||||
timeout: 1_000,
|
||||
});
|
||||
const rejected = await paintedColors(overlay);
|
||||
await page.emulateMedia({
|
||||
colorScheme: theme === 'dark' ? 'light' : 'dark',
|
||||
});
|
||||
const underOtherOs = await paintedColors(overlay);
|
||||
expect(
|
||||
await card.getAttribute('class'),
|
||||
'rejection still shown after both reads'
|
||||
).toMatch(/is-rejected/);
|
||||
expect(
|
||||
underOtherOs,
|
||||
`rejected drop: app ${theme} theme`
|
||||
).toEqual(rejected);
|
||||
record('rejected drop', theme, rejected);
|
||||
}).toPass({ timeout: 20_000 });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(card).toBeHidden();
|
||||
}
|
||||
for (const [name, byTheme] of Object.entries(colors)) {
|
||||
expect(byTheme.light, `${name}: light vs dark`).not.toEqual(
|
||||
byTheme.dark
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await page.emulateMedia({ colorScheme: null });
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
describeLayerSize,
|
||||
findOversizedClipMasks,
|
||||
isSynthesizedClipMask,
|
||||
layerDevicePixels,
|
||||
layerScale,
|
||||
readTileMemoryFromTrace,
|
||||
type CompositedLayerWithReasons,
|
||||
type TraceEvent,
|
||||
} from './compositing-probe';
|
||||
|
||||
function layer(
|
||||
overrides: Partial<CompositedLayerWithReasons> = {}
|
||||
): CompositedLayerWithReasons {
|
||||
return {
|
||||
drawsContent: true,
|
||||
height: 1874,
|
||||
layerId: '317',
|
||||
reasons: [],
|
||||
width: 3340,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A column-major 4x4 matrix scaling x and y. */
|
||||
function scaleMatrix(x: number, y: number): number[] {
|
||||
return [x, 0, 0, 0, 0, y, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
}
|
||||
|
||||
const contentArea = { dpr: 2, height: 1874, width: 3340 };
|
||||
|
||||
test('layerScale reads the x and y scale of a column-major transform', () => {
|
||||
assert.deepEqual(layerScale(layer()), { x: 1, y: 1 });
|
||||
assert.deepEqual(
|
||||
layerScale(layer({ transform: scaleMatrix(0.96, 0.96) })),
|
||||
{
|
||||
x: 0.96,
|
||||
y: 0.96,
|
||||
}
|
||||
);
|
||||
// A rotation keeps the axis length.
|
||||
const rotated = layer({
|
||||
transform: [0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||
});
|
||||
assert.deepEqual(layerScale(rotated), { x: 1, y: 1 });
|
||||
});
|
||||
|
||||
test('layerDevicePixels applies the transform scale to the bounds', () => {
|
||||
assert.equal(layerDevicePixels(layer({ height: 10, width: 10 })), 100);
|
||||
assert.equal(
|
||||
Math.round(
|
||||
layerDevicePixels(
|
||||
layer({ height: 10, transform: scaleMatrix(0.5, 2), width: 10 })
|
||||
)
|
||||
),
|
||||
100
|
||||
);
|
||||
});
|
||||
|
||||
test('a synthesized clip mask draws content without an owner node or a reason', () => {
|
||||
assert.equal(isSynthesizedClipMask(layer()), true);
|
||||
assert.equal(isSynthesizedClipMask(layer({ backendNodeId: 42 })), false);
|
||||
assert.equal(isSynthesizedClipMask(layer({ reasons: ['Overlap'] })), false);
|
||||
assert.equal(isSynthesizedClipMask(layer({ drawsContent: false })), false);
|
||||
// A content layer whose first paint chunk belongs to an anonymous box
|
||||
// has no owner node but keeps its reason.
|
||||
assert.equal(
|
||||
isSynthesizedClipMask(
|
||||
layer({ backendNodeId: undefined, reasons: ['Overlap'] })
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('findOversizedClipMasks keeps masks of at least half the content area, at their scale', () => {
|
||||
const chevronMask = layer({
|
||||
height: 1955,
|
||||
layerId: '330',
|
||||
transform: scaleMatrix(0.96, 0.96),
|
||||
width: 3482,
|
||||
});
|
||||
const smallMask = layer({ height: 6, layerId: '400', width: 18 });
|
||||
const track = layer({
|
||||
backendNodeId: 7,
|
||||
height: 635,
|
||||
layerId: '324',
|
||||
reasons: ['Overlap'],
|
||||
width: 7520,
|
||||
});
|
||||
const nodelessTrack = layer({
|
||||
height: 1874,
|
||||
layerId: '209',
|
||||
reasons: ['Overlap'],
|
||||
width: 3340,
|
||||
});
|
||||
const halfMinusOne = layer({
|
||||
height: 1874,
|
||||
layerId: '500',
|
||||
width: 1669,
|
||||
});
|
||||
const masks = findOversizedClipMasks(
|
||||
[layer(), chevronMask, smallMask, track, nodelessTrack, halfMinusOne],
|
||||
contentArea
|
||||
);
|
||||
assert.deepEqual(
|
||||
masks.map((mask) => mask.layerId),
|
||||
['317', '330']
|
||||
);
|
||||
assert.deepEqual(masks.map(describeLayerSize), [
|
||||
'317: 3340x1874',
|
||||
'330: 3482x1955',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
findOversizedClipMasks([halfMinusOne], contentArea, 0.4).map(
|
||||
(mask) => mask.layerId
|
||||
),
|
||||
['500']
|
||||
);
|
||||
});
|
||||
|
||||
function dump(
|
||||
pid: number,
|
||||
allocators: Record<string, number | null>
|
||||
): TraceEvent {
|
||||
const entries = Object.entries(allocators).map(([name, bytes]) => [
|
||||
name,
|
||||
bytes === null
|
||||
? {}
|
||||
: {
|
||||
attrs: {
|
||||
size: { units: 'bytes', value: bytes.toString(16) },
|
||||
},
|
||||
},
|
||||
]);
|
||||
return {
|
||||
args: { dumps: { allocators: Object.fromEntries(entries) } },
|
||||
pid,
|
||||
ph: 'v',
|
||||
};
|
||||
}
|
||||
|
||||
const MB = 1_048_576;
|
||||
|
||||
test('readTileMemoryFromTrace reports the renderer dump with the most tile memory', () => {
|
||||
const reading = readTileMemoryFromTrace([
|
||||
{ ph: 'X', pid: 1 },
|
||||
dump(1, { 'skia/gpu_resources': 200 * MB }),
|
||||
dump(2, {
|
||||
'cc/image_memory': 118 * MB,
|
||||
'cc/tile_memory': 537 * MB,
|
||||
'cc/tile_memory/provider_1': null,
|
||||
'cc/tile_memory/provider_1/resource_1': 24 * MB,
|
||||
'cc/tile_memory/provider_1/resource_2': 24 * MB,
|
||||
'cc/tile_memory/provider_1/resource_3': 6.5 * MB,
|
||||
}),
|
||||
dump(3, {
|
||||
'cc/tile_memory': 12 * MB,
|
||||
'cc/tile_memory/provider_2/resource_1': 12 * MB,
|
||||
}),
|
||||
]);
|
||||
assert.deepEqual(reading, {
|
||||
imageMB: 118,
|
||||
largestResourcesMB: [24, 24, 6.5],
|
||||
pid: 2,
|
||||
resourceCount: 3,
|
||||
tileMB: 537,
|
||||
});
|
||||
});
|
||||
|
||||
test('readTileMemoryFromTrace yields nulls without a tile memory dump', () => {
|
||||
assert.deepEqual(
|
||||
readTileMemoryFromTrace([dump(1, { 'skia/gpu_resources': 10 * MB })]),
|
||||
{
|
||||
imageMB: null,
|
||||
largestResourcesMB: [],
|
||||
pid: null,
|
||||
resourceCount: 0,
|
||||
tileMB: null,
|
||||
}
|
||||
);
|
||||
assert.equal(readTileMemoryFromTrace([]).tileMB, null);
|
||||
});
|
||||
|
||||
test('readTileMemoryFromTrace ignores sizes that are not byte counts', () => {
|
||||
const reading = readTileMemoryFromTrace([
|
||||
{
|
||||
args: {
|
||||
dumps: {
|
||||
allocators: {
|
||||
'cc/tile_memory': {
|
||||
attrs: { size: { units: 'objects', value: '10' } },
|
||||
},
|
||||
'cc/tile_memory/provider_1/resource_1': {
|
||||
attrs: { size: { units: 'bytes', value: 'zz' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ph: 'v',
|
||||
pid: 9,
|
||||
},
|
||||
]);
|
||||
assert.equal(reading.tileMB, null);
|
||||
assert.equal(reading.resourceCount, 0);
|
||||
});
|
||||
Loaded 100 of 1055 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user