Merge remote-tracking branch 'origin/master' into claude/dazzling-easley-9f438c

# Conflicts:
#	apps/electron-backend/project.json
#	apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts
This commit is contained in:
4gray committed 2026-07-27 20:43:20 +02:00
commit bbcbf6c1ac
1068 files changed
+160062 -13094

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
# Release notes (`.changes/`)
Every PR with a user-visible change drops one file here describing that change
in plain language. At release time
`tools/release/build-release-notes.mjs` turns the accumulated files into the
GitHub release body, the `CHANGELOG.md` section, and a blog-post scaffold for
the website — then deletes them.
The point is to write the note **while the context is still fresh**, instead of
reconstructing three months of work from commit titles at release time.
## File
Name it `<area>-<short-slug>.md`, e.g. `.changes/playback-up-next-rail.md`.
```markdown
---
type: feature
area: playback
issues: [1187]
screenshot: up-next-rail
---
Series now show an "Up Next" rail beside the player on wide windows: the rest
of the current season, watch progress, and click-to-play inline.
```
| Field | Required | Value |
| ------------ | -------- | ----------------------------------------------------------- |
| `type` | yes | `breaking`, `feature`, `fix`, `perf`, or `internal` |
| `area` | yes | lowercase slug, same as the conventional-commit scope |
| `issues` | no | issue numbers this closes — `[1187]` or `1187` |
| `screenshot` | no | slug from `tools/release/screenshots.manifest.json` |
There is **no version field**. The release version is chosen deliberately at
release time, not derived from these files.
You never write a PR number: the generator resolves it from the commit that
added the file.
## Writing the body
One to three sentences, present tense, **written for a user, not a reviewer**.
The body is capped at 400 characters — depth belongs in the blog post.
- ❌ "Refactor `WebVideoControlsAdapter` to hoist volume state into the session"
- ✅ "The player now remembers volume between episodes"
- ❌ "Fix off-by-one in `resolveEnrichmentSeasonNumber`"
- ✅ "Series whose title carries a season marker no longer show the wrong season"
`type: internal` is for changes with no user-visible effect that are still worth
recording (dependency bumps with behaviour risk, packaging moves). They stay out
of the release body and blog post, and land collapsed in `CHANGELOG.md`.
## When a note is not needed
Skip the note — and apply the `no-release-note` label — for test-only changes,
docs, CI/workflow plumbing, and pure refactors with no behaviour change.
## Commands
```bash
pnpm run release:notes:validate
pnpm run release:notes:github
pnpm run release:notes:changelog
pnpm run release:notes:blog
node tools/release/build-release-notes.mjs --consume
```
The release version comes from the root `package.json` — bump it first, then
generate. `--version 0.24.0` overrides it to preview a release before the bump:
```bash
pnpm run release:notes:github --version 0.24.0
```
A bare `--` separator is accepted and ignored, so the npm habit of
`pnpm run release:notes:github -- --version 0.24.0` works too: pnpm forwards
that separator to the script rather than consuming it the way npm does.
`--validate` and `--format github` only read and print. `--format changelog`
and `--format blog` write their target file (rerunning `changelog` for the same
version replaces that section rather than duplicating it). Only `--consume`
deletes anything.
The release sequence is: bump the version → `release:notes:changelog` →
`release:notes:blog` → `--consume` → commit → tag → push. The tag build then
extracts the new `CHANGELOG.md` section into the GitHub release body
(`tools/release/extract-changelog-section.mjs`) and **fails the release** if
the section is missing — a tag cut without the changelog step cannot silently
ship PR-title-only notes.
The website publishes **one post per minor version** (`v0-18` … `v0-22`), and
release screenshots live under the matching `blog/v0-24/` directory. A patch
release therefore edits the existing post rather than generating a new one, so
`--format blog` refuses to overwrite unless you pass `--force`.
## Screenshots
`pnpm run release:screenshots` captures every manifest shot in dark and light
against the built app plus the Xtream mock server — never a real account.
The run is fail-closed: it proves the real `~/.iptvnator/databases` directory
(including the SQLite WAL sidecars, checked after Electron exits) was not
touched, launches the app with an allowlisted environment, records and blocks
all non-localhost traffic, scans every frame for external resources and
credential-shaped text, and asserts TMDB enrichment stays disabled. Frames are
staged outside the repository and published only once every shot and every
guard has passed.
Adding a shot for a new feature = one entry in
`tools/release/screenshots.manifest.json` (plus, if navigation is new, one
named action in `tools/release/capture-navigation.ts`).
```bash
pnpm nx run electron-backend:build-e2e # once, before capturing
pnpm run release:screenshots # all shots, both themes
pnpm run release:screenshots -- --only dashboard --theme dark
pnpm run release:screenshots -- --release v0-24
```
+9
View File
@@ -0,0 +1,9 @@
---
type: fix
area: backup
issues: [1017]
---
Backups carry hidden Xtream categories correctly. An export used to lose which
categories you had hidden, and restoring such a backup then hid every category
of that kind.
+9
View File
@@ -0,0 +1,9 @@
---
type: internal
area: deps
---
Updated 43 dependencies, including the HTTP client behind every playlist and
portal request — that one closes seven advisories that affect the shipped app,
among them a proxy-credential leak on redirects. Also refreshes the ArtPlayer,
hls.js, Video.js and Shaka player engines. No behaviour change intended.
@@ -0,0 +1,8 @@
---
type: internal
area: deps
---
Patched five vulnerable transitive dependencies that ship with the app —
including the YAML parser `electron-updater` uses to read update manifests, and
the HTTP form encoder behind portal requests. No behaviour change.
+9
View File
@@ -0,0 +1,9 @@
---
type: feature
area: downloads
---
Downloads can be paused and picked up later. A paused transfer keeps what it
already fetched and continues from that point instead of starting over, and
downloads cut short by a crash or a closed app come back as paused rather than
lost.
+7
View File
@@ -0,0 +1,7 @@
---
type: fix
area: electron
---
The desktop remote-control server now blocks crafted static paths from escaping
bundled web files on Windows.
+9
View File
@@ -0,0 +1,9 @@
---
type: feature
area: embedded-mpv
---
Experimental Embedded MPV can draw video inside the app window instead of into a
separate layer pinned on top of it, so menus, dialogs and the player controls
stop being swallowed by the picture. Available on macOS (Apple Silicon), Windows
and Linux x64; switching it on needs a restart.
+9
View File
@@ -0,0 +1,9 @@
---
type: feature
area: epg
---
Channels whose guide never matched can be mapped by hand: right-click a channel
in any list and pick "Map EPG channel" to attach it to a channel from your
uploaded XMLTV guide. The mapping is remembered and used everywhere the guide is
read — M3U playlists, Xtream and Stalker portals alike.
+9
View File
@@ -0,0 +1,9 @@
---
type: fix
area: epg
---
A database error while reading or saving a manual EPG channel mapping no longer
surfaces as a failed request. Looking up, saving, deleting, and searching
mappings now fall back quietly, so a transient storage hiccup can no longer take
down the EPG panel or the "Map EPG channel" dialog.
+9
View File
@@ -0,0 +1,9 @@
---
type: fix
area: favorites
issues: [1137]
---
Favorites stop losing changes. The custom drag-and-drop order of an Xtream
playlist's own favorites is saved again, and two favorites or history entries
added at almost the same moment no longer quietly overwrite each other.
+7
View File
@@ -0,0 +1,7 @@
---
type: feature
area: i18n
issues: [1192]
---
IPTVnator speaks Hungarian, its 19th language — contributed by @htibcsike.
+10
View File
@@ -0,0 +1,10 @@
---
type: feature
area: m3u
issues: [86, 614, 656, 733, 752]
---
MPEG-DASH channels play in the built-in player, ClearKey-encrypted ones
included — the keys are read from the playlist's #KODIPROP lines, whether they
sit above or below the channel entry. Streams locked with Widevine or PlayReady
still cannot be played, but they now say so instead of failing silently.
+7
View File
@@ -0,0 +1,7 @@
---
type: perf
area: m3u
---
Importing large M3U playlists is faster because IPTVnator no longer rewrites
the entire playlist when loading saved favorites.
+9
View File
@@ -0,0 +1,9 @@
---
type: fix
area: m3u
issues: [1189]
---
Playlists with very long stream URLs — Pluto TV style lists that carry a session
token in every link — import in full again instead of collapsing into a single
channel.
+8
View File
@@ -0,0 +1,8 @@
---
type: perf
area: m3u
---
Cancelling a large M3U refresh now stops its background worker before parsed
channels can be copied or saved, keeping the interface responsive and leaving
the existing playlist unchanged.
@@ -0,0 +1,9 @@
---
type: feature
area: playback
---
An optional new set of player controls that looks and behaves the same in the
HTML5, Video.js and ArtPlayer players, with picture-in-picture and, in
fullscreen, the name of what you are watching. Enable it in Settings → Playback;
left off, each of the three keeps its own controls exactly as before.
+11
View File
@@ -0,0 +1,11 @@
---
type: fix
area: playback
issues: [1155]
---
The "Show subtitles" setting now works in the built-in players: turning it off
hides subtitles a stream switched on by itself, and the preference finally
applies on Xtream and Stalker pages too. Previously it only reached the M3U
player, and even there Video.js and ArtPlayer ignored it. The player's own
subtitle menu still overrides the setting for the current stream.
+9
View File
@@ -0,0 +1,9 @@
---
type: feature
area: playback
---
The inline player on movie and series pages fills the whole content area like a
theater: the video sits centered in black instead of leaving a strip of app
background beside it. In the built-in web players, an optional ambient mode
fills that space with a blurred, dimmed copy of the poster.
+9
View File
@@ -0,0 +1,9 @@
---
type: feature
area: playback
---
A series playing inline on a wide window shows an "Up Next" rail beside the
video: the rest of the season and the start of the next one, the current episode
highlighted, watch progress on every card. Click one to jump straight to it.
Built-in web players only; switch the rail off in Settings → Playback.
+10
View File
@@ -0,0 +1,10 @@
---
type: fix
area: playlists
issues: [931]
---
One unreachable playlist no longer holds up the rest. Refreshes give up after 30
seconds and run a few at a time, so your other playlists still update, and the
message on startup names how many actually failed instead of always claiming
success.
+9
View File
@@ -0,0 +1,9 @@
---
type: fix
area: search
issues: [1161]
---
Searching for names that carry punctuation inside them — "A&E", "X-Men",
"L'Equipe" — finds them anywhere in a title, including channels the provider
prefixes, like "US: A&E".
@@ -0,0 +1,9 @@
---
type: feature
area: settings
---
A new setting drops the country prefix from live channel names, turning
"UK - BBC One" into "BBC One" in lists, the guide and the player. Movie and
series titles are left alone, and names that only look like a prefix — "Sky -
Sports F1" — stay intact.
+9
View File
@@ -0,0 +1,9 @@
---
type: perf
area: stalker
---
Detail pages for anything that is not a series — a movie, a live channel, a VOD
item that only looks like a series — no longer fire an episode-list request
before they can show anything, so they open faster on every route in, from
browsing and search to Favorites, Recent and the dashboard.
@@ -0,0 +1,9 @@
---
type: fix
area: stalker
---
Series opened from Favorites, Recent or the dashboard show their current
episodes. One saved back when a single episode existed used to keep showing that
one episode forever; the list is refreshed from the portal in the background
instead.
+9
View File
@@ -0,0 +1,9 @@
---
type: feature
area: stalker
---
The Live TV section loads its full channel list up front: search covers every
channel instead of only the page you are on, genres show how many channels they
hold, and Live TV opens on a grid of all channels. Guide data for the visible
rows loads in bulk, so it shows up without playing a channel first.
+10
View File
@@ -0,0 +1,10 @@
---
type: fix
area: tmdb
---
Movies whose provider ships a dead or wrong TMDB id are enriched again. The
id is weighed against the title and release year: a dead one falls back to
the title search, a stale one that clearly points at another film loses to
it, and a working id is no longer thrown away just because the provider
spells the title differently.
+8
View File
@@ -0,0 +1,8 @@
---
type: feature
area: tmdb
---
Settings → Metadata (TMDB) now shows how many entries the metadata cache
holds and how much space they take, with a button to empty it. Clearing
costs nothing but the next few lookups — enrichment refetches on demand.
+9
View File
@@ -0,0 +1,9 @@
---
type: feature
area: tmdb
---
Series pages show whether a show has ended or is still returning, so you know
before committing to it. Directors and creators became clickable avatar chips
like the cast — they open the person's page, where directing credits now sit
alongside acting ones.
+9
View File
@@ -0,0 +1,9 @@
---
type: fix
area: tmdb
---
Series detail pages now list the cast of the whole show instead of only its
newest season, so actors who left partway through stop disappearing from
long-running shows — while people who joined for the current season still
show up.
+9
View File
@@ -0,0 +1,9 @@
---
type: fix
area: tmdb
---
Titles that providers dress up with language or quality tags — "|ALB| Fallout",
"4K-DE - The Pitt (2025)", "Breaking Bad-eng" — now match against TMDB, so they
get artwork, plot and cast like the rest of the catalog. Shows split into one
entry per season also pull the season that entry really contains.
@@ -0,0 +1,9 @@
---
type: fix
area: window-controls
---
On Windows, the minimize, maximize and close buttons disappeared for good after
leaving fullscreen video playback — the only way to get them back was to restart
the app. They now reappear as soon as you exit fullscreen, and the maximize
button no longer gets stuck on the wrong icon afterwards.
+9
View File
@@ -0,0 +1,9 @@
---
type: feature
area: xtream
issues: [1138]
---
Catch-up is available from Favorites and Recent, not just Live TV, so an
archived programme is reachable wherever the channel is. The programme currently
on air can also be restarted from the beginning.
+9
View File
@@ -0,0 +1,9 @@
---
type: fix
area: xtream
---
Portals sitting behind Cloudflare or a similar firewall connect again. Those
setups answered the app with a challenge page instead of data, so "Test
connection" failed on portals that worked fine in every other player; requests
now identify themselves the way an ordinary IPTV player does.
+9
View File
@@ -0,0 +1,9 @@
---
type: feature
area: xtream
---
Continue Watching resumes a series where you left it: opening one from the
dashboard starts the exact episode at its saved position, and the series page
offers "Play episode N" instead of always starting at the first one. Episodes
launched in MPV or VLC count towards this too.
+88
View File
@@ -0,0 +1,88 @@
---
name: release-cut
description: Cut an IPTVnator release — bump the version, generate release notes from .changes/, scaffold the website post, tag, and verify the draft. Use when asked to release, cut a version, prepare release notes, or publish a new version.
---
# Release Cut
The pipeline turns accumulated `.changes/*.md` notes into all three release
surfaces. Order matters: **the tag build extracts the CHANGELOG section into
the GitHub release body and fails if it is missing**, so the changelog step
is not optional.
## Sequence
1. **Pick the version** — deliberate choice, edit `version` in the root
`package.json`. Bare semver only: any suffix flips electron-updater into
prerelease mode and leaks into installer version fields.
2. **Review the notes** — read every file in `.changes/`. Fix wording (user
language, not reviewer language), then:
```bash
pnpm run release:notes:validate
```
3. **Generate the changelog section** (idempotent per version — rerunning
replaces the section, so regenerate freely until it reads well):
```bash
pnpm run release:notes:changelog
```
4. **Scaffold the website post**:
```bash
pnpm run release:notes:blog
```
Output is `apps/website/src/content/blog/v0-XX-release-notes.mdx` with
`draft: true`. The narrative intro, headlines, and `description` are
editorial — fill every `TODO` by hand. One post per **minor** version:
for a patch release, edit the existing post (the scaffold refuses to
overwrite without `--force`).
5. **Screenshots** — only from the fail-closed capture script against the
mock servers, never from a real playlist or account: real streams, logos,
and TMDB artwork are copyrighted, and credentials must never reach a
published image.
```bash
pnpm nx run electron-backend:build-e2e # once
pnpm run release:screenshots # all manifest shots, dark+light
```
Output goes to `apps/website/public/blog/v0-XX/screenshots/`. New feature
to showcase = new entry in `tools/release/screenshots.manifest.json`
(slug must match the note's `screenshot:` field). The run aborts and
deletes its frames on any guard violation (real-DB touch, external
request, credential-shaped text in frame, TMDB active).
6. **Consume the notes** (the only destructive step):
```bash
node tools/release/build-release-notes.mjs --consume
```
7. **Commit, tag, push**:
```bash
git add CHANGELOG.md .changes apps/website package.json
git commit -m "chore(release): v0.XX.0"
git tag v0.XX.0 && git push && git push --tags
```
8. **Verify the draft release** once `build-and-make.yaml` finishes: authored
notes on top, GitHub's generated commit list below, all platform assets
present (`.dmg`/`.zip` + `latest-mac.yml`, `.exe`/`.msi` + `latest.yml`,
`.deb`/`.rpm`/`.AppImage`/`.snap`/`.flatpak` + `latest-linux*.yml`,
blockmaps). Publish manually; flip the blog post to `draft: false`.
## Failure modes
- **create-release fails with "CHANGELOG.md has no section for X"** — step 3
was skipped. Run it, commit, delete and re-push the tag.
- **Snap store publication** is a separate manual flow after the public
release exists (`publish-snap.yaml`).
- Post-release checklist candidates: i18n drift (`pnpm run i18n:check`),
update the website `v0-XX` blog assets, announce in Telegram.
+70
View File
@@ -0,0 +1,70 @@
---
name: release-notes
description: Write the .changes/ release note that every PR with a user-visible change must include. Use when creating or finishing a PR that changes behavior in apps/ or libs/, when the "Release note gate" CI check fails, or when deciding whether the no-release-note label applies.
---
# Release Notes (`.changes/`)
Every PR with a user-visible change adds **one** note file under `.changes/`.
At release time the notes become the GitHub release body, the `CHANGELOG.md`
section, and the website blog scaffold. CI enforces this: the **Release note
gate** check fails any PR that touches runtime code under `apps/` or `libs/`
without an added `.changes/*.md` file or the `no-release-note` label.
## File format
Name: `.changes/<area>-<short-slug>.md` — `area` matches the
conventional-commit scope of the PR.
```markdown
---
type: feature
area: playback
issues: [1187]
screenshot: up-next-rail
---
Series now show an "Up Next" rail beside the player on wide windows: the rest
of the current season, watch progress, and click-to-play inline.
```
| Field | Required | Value |
| ------------ | -------- | ---------------------------------------------------- |
| `type` | yes | `breaking` / `feature` / `fix` / `perf` / `internal` |
| `area` | yes | lowercase slug = conventional-commit scope |
| `issues` | no | `[1187]` or bare `1187` — issues this PR closes |
| `screenshot` | no | slug from the release screenshot manifest |
- **No version field.** The release version is chosen at release time.
- **Never write a PR number.** The generator resolves it from git.
- Unknown keys fail validation — this is what catches typos like `scopr:`.
## Writing the body
One to three sentences, present tense, max 400 characters, **written for a
user, not a reviewer**:
- ❌ "Refactor `WebVideoControlsAdapter` to hoist volume state"
- ✅ "The player now remembers volume between episodes"
- ❌ "Fix off-by-one in `resolveEnrichmentSeasonNumber`"
- ✅ "Series with a season marker in the title no longer show the wrong season"
`type: internal` is for changes worth recording but invisible to users
(dependency bumps with behavior risk, packaging moves). They are excluded
from the release body and blog, and collapsed in `CHANGELOG.md`.
## When to skip (`no-release-note` label)
Test-only changes, docs, CI/workflow plumbing, pure refactors with no
behavior change. The gate auto-exempts `*.spec.ts`, `*.e2e.ts`,
`__snapshots__/`, `apps/website/`, `apps/*-e2e/`, `apps/*-mock-server/`,
`libs/shared/testing/` and `*.md` — if only those changed, no label needed.
## Verify before finishing
```bash
pnpm run release:notes:validate
```
Full format reference: `.changes/README.md`. Gate policy:
`tools/release/check-release-note-gate.mjs`.
+88
View File
@@ -0,0 +1,88 @@
---
name: release-cut
description: Cut an IPTVnator release — bump the version, generate release notes from .changes/, scaffold the website post, tag, and verify the draft. Use when asked to release, cut a version, prepare release notes, or publish a new version.
---
# Release Cut
The pipeline turns accumulated `.changes/*.md` notes into all three release
surfaces. Order matters: **the tag build extracts the CHANGELOG section into
the GitHub release body and fails if it is missing**, so the changelog step
is not optional.
## Sequence
1. **Pick the version** — deliberate choice, edit `version` in the root
`package.json`. Bare semver only: any suffix flips electron-updater into
prerelease mode and leaks into installer version fields.
2. **Review the notes** — read every file in `.changes/`. Fix wording (user
language, not reviewer language), then:
```bash
pnpm run release:notes:validate
```
3. **Generate the changelog section** (idempotent per version — rerunning
replaces the section, so regenerate freely until it reads well):
```bash
pnpm run release:notes:changelog
```
4. **Scaffold the website post**:
```bash
pnpm run release:notes:blog
```
Output is `apps/website/src/content/blog/v0-XX-release-notes.mdx` with
`draft: true`. The narrative intro, headlines, and `description` are
editorial — fill every `TODO` by hand. One post per **minor** version:
for a patch release, edit the existing post (the scaffold refuses to
overwrite without `--force`).
5. **Screenshots** — only from the fail-closed capture script against the
mock servers, never from a real playlist or account: real streams, logos,
and TMDB artwork are copyrighted, and credentials must never reach a
published image.
```bash
pnpm nx run electron-backend:build-e2e # once
pnpm run release:screenshots # all manifest shots, dark+light
```
Output goes to `apps/website/public/blog/v0-XX/screenshots/`. New feature
to showcase = new entry in `tools/release/screenshots.manifest.json`
(slug must match the note's `screenshot:` field). The run aborts and
deletes its frames on any guard violation (real-DB touch, external
request, credential-shaped text in frame, TMDB active).
6. **Consume the notes** (the only destructive step):
```bash
node tools/release/build-release-notes.mjs --consume
```
7. **Commit, tag, push**:
```bash
git add CHANGELOG.md .changes apps/website package.json
git commit -m "chore(release): v0.XX.0"
git tag v0.XX.0 && git push && git push --tags
```
8. **Verify the draft release** once `build-and-make.yaml` finishes: authored
notes on top, GitHub's generated commit list below, all platform assets
present (`.dmg`/`.zip` + `latest-mac.yml`, `.exe`/`.msi` + `latest.yml`,
`.deb`/`.rpm`/`.AppImage`/`.snap`/`.flatpak` + `latest-linux*.yml`,
blockmaps). Publish manually; flip the blog post to `draft: false`.
## Failure modes
- **create-release fails with "CHANGELOG.md has no section for X"** — step 3
was skipped. Run it, commit, delete and re-push the tag.
- **Snap store publication** is a separate manual flow after the public
release exists (`publish-snap.yaml`).
- Post-release checklist candidates: i18n drift (`pnpm run i18n:check`),
update the website `v0-XX` blog assets, announce in Telegram.
+70
View File
@@ -0,0 +1,70 @@
---
name: release-notes
description: Write the .changes/ release note that every PR with a user-visible change must include. Use when creating or finishing a PR that changes behavior in apps/ or libs/, when the "Release note gate" CI check fails, or when deciding whether the no-release-note label applies.
---
# Release Notes (`.changes/`)
Every PR with a user-visible change adds **one** note file under `.changes/`.
At release time the notes become the GitHub release body, the `CHANGELOG.md`
section, and the website blog scaffold. CI enforces this: the **Release note
gate** check fails any PR that touches runtime code under `apps/` or `libs/`
without an added `.changes/*.md` file or the `no-release-note` label.
## File format
Name: `.changes/<area>-<short-slug>.md` — `area` matches the
conventional-commit scope of the PR.
```markdown
---
type: feature
area: playback
issues: [1187]
screenshot: up-next-rail
---
Series now show an "Up Next" rail beside the player on wide windows: the rest
of the current season, watch progress, and click-to-play inline.
```
| Field | Required | Value |
| ------------ | -------- | ---------------------------------------------------- |
| `type` | yes | `breaking` / `feature` / `fix` / `perf` / `internal` |
| `area` | yes | lowercase slug = conventional-commit scope |
| `issues` | no | `[1187]` or bare `1187` — issues this PR closes |
| `screenshot` | no | slug from the release screenshot manifest |
- **No version field.** The release version is chosen at release time.
- **Never write a PR number.** The generator resolves it from git.
- Unknown keys fail validation — this is what catches typos like `scopr:`.
## Writing the body
One to three sentences, present tense, max 400 characters, **written for a
user, not a reviewer**:
- ❌ "Refactor `WebVideoControlsAdapter` to hoist volume state"
- ✅ "The player now remembers volume between episodes"
- ❌ "Fix off-by-one in `resolveEnrichmentSeasonNumber`"
- ✅ "Series with a season marker in the title no longer show the wrong season"
`type: internal` is for changes worth recording but invisible to users
(dependency bumps with behavior risk, packaging moves). They are excluded
from the release body and blog, and collapsed in `CHANGELOG.md`.
## When to skip (`no-release-note` label)
Test-only changes, docs, CI/workflow plumbing, pure refactors with no
behavior change. The gate auto-exempts `*.spec.ts`, `*.e2e.ts`,
`__snapshots__/`, `apps/website/`, `apps/*-e2e/`, `apps/*-mock-server/`,
`libs/shared/testing/` and `*.md` — if only those changed, no label needed.
## Verify before finishing
```bash
pnpm run release:notes:validate
```
Full format reference: `.changes/README.md`. Gate policy:
`tools/release/check-release-note-gate.mjs`.
+66
View File
@@ -0,0 +1,66 @@
---
name: stalker-portal
description: Repository guidance for Stalker/Ministra portal catalogs, VOD/series shapes, playback metadata, collections, EPG, and remote control.
---
# Stalker Portal
Use this skill when changing Stalker/Ministra routes, stores, catalog/detail
views, playback, favorites/recent activity, EPG, or remote control.
## Read First
- `docs/architecture/stalker-portal.md`
- `docs/architecture/stalker-epg.md` for ITV EPG work
- `docs/architecture/remote-control.md` for live remote-control work
## Ownership
- Feature UI: `libs/portal/stalker/feature/src/lib/`
- Store/API data access: `libs/portal/stalker/data-access/src/lib/`
- Electron requests: `apps/electron-backend/src/app/events/stalker.events.ts`
- Shared Stalker item normalization:
`libs/shared/interfaces/src/lib/stalker-item.normalizer.ts`
- Dashboard aggregation: `libs/workspace/dashboard/data-access/src/lib/`
Keep provider-specific API and normalization behavior in Stalker data access.
Keep shared portal layouts/utilities provider-neutral. Preserve full-portal
session auth and simple IPC request paths.
## `is_series` Cross-Surface Checklist
Treat VOD items with `is_series` as series across every downstream surface.
Do not stop after making the detail view render.
1. Accept portal flags `true`, `1`, and `'1'` through the existing normalizers.
Preserve all three modes: regular `/series`, embedded VOD `series[]`, and
lazy Ministra VOD `is_series`.
2. Build quick-start state through the shared series utility. Preserve
`labelKey`, `labelParams`, and `episodeLabel` when adapting it for Stalker;
translation parameters must reach the template.
3. Preserve `is_series` and the VOD origin in favorites/recent activity.
`extractStalkerItemType()` must normalize that activity to dashboard type
`series`.
4. Before either inline or external episode playback, persist the parent
`seriesXtreamId` plus resolved `seasonNumber` and `episodeNumber`. Keep
generated episode tracking IDs stable for lazy `is_series` episodes. When
`season_number` is absent, derive the coordinate from the same naturally
ordered season list used by quick start; do not default every season to 1.
5. The dashboard reads saved playback positions; it must not infer episode
numbers from provider payloads. Legacy rows without season/episode metadata
remain badge-less until that episode is played again.
## Regression Coverage
- Series view/UI and playback handoff:
`pnpm nx test portal-stalker-feature`
- Stalker shape/store behavior:
`pnpm nx test portal-stalker-data-access`
- Dashboard classification and position lookup:
`pnpm nx test workspace-dashboard-data-access`
- Dashboard badge rendering when changed:
`pnpm nx test workspace-dashboard-feature`
For user-visible workflow changes, run the closest available E2E target. If no
fixture covers the affected portal shape, record that gap and perform the
strongest targeted unit/build validation available.
+10
View File
@@ -0,0 +1,10 @@
paths:
# build-cross-platform and build-linux share their steps via a YAML anchor
# (steps: *electron-build-steps). actionlint type-checks the anchored steps
# against each job's own matrix, so matrix.linux_profile — defined only in
# build-linux — is reported as unknown when the same steps are checked
# against the build-cross-platform matrix. The steps guard every use with
# `matrix.os == 'linux'` or a `|| ''` fallback, so this is a false positive.
.github/workflows/build-and-make.yaml:
ignore:
- 'property "linux_profile" is not defined in object type'
+39
View File
@@ -0,0 +1,39 @@
# Every Dependabot PR triggers the full pipeline (~15 jobs), so version
# updates are batched: weekly cadence, minor+patch bumps grouped into one PR
# per ecosystem, majors as individual PRs so CI gates them one by one.
# Security updates are separate and are not limited by this schedule.
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
time: '06:00'
open-pull-requests-limit: 5
groups:
npm-minor-patch:
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
time: '06:00'
groups:
actions-minor-patch:
patterns:
- '*'
update-types:
- minor
- patch
- package-ecosystem: docker
directory: /docker
schedule:
interval: weekly
day: monday
time: '06:00'
+23
View File
@@ -0,0 +1,23 @@
<!--
Thanks for contributing to IPTVnator!
Keep the description short — what changed and why is enough.
-->
## What changed
## Why
## Release note
Changes a user could notice need one file in `.changes/` describing the change
in plain language — see
[`.changes/README.md`](https://github.com/4gray/iptvnator/blob/master/.changes/README.md).
It becomes the release notes and the website post, so it is worth a minute.
- [ ] Added a note under `.changes/`
- [ ] Not needed (test-only, docs, CI, or a refactor with no behavior change)
## Checks
- [ ] Tests added or updated for the changed behavior
- [ ] `pnpm run lint` and the affected `pnpm nx test <project>` pass
File diff suppressed because it is too large. Load diff
+94 -5
View File
@@ -9,7 +9,82 @@ on:
- master
workflow_dispatch:
# Superseded PR pushes cancel their still-running checks. Non-PR runs get a
# unique group (run_id) because GitHub keeps at most one pending run per
# group even with cancel-in-progress: false — a shared ref group would let a
# rapid master push silently replace a queued sibling and leave a merged
# commit without a lint/test record.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
actionlint:
name: Workflow lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v7
# Image pinned by digest (tag 1.7.12). False positives are
# suppressed in .github/actionlint.yaml; shellcheck runs at
# warning+ severity so style/info notes in long release scripts
# don't fail CI while real quoting/logic bugs still do.
- name: Run actionlint
uses: docker://rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667
with:
args: -color
env:
SHELLCHECK_OPTS: --severity=warning
release-note-gate:
name: Release note gate
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout code
uses: actions/checkout@v7
# The gate scripts are dependency-free Node, so this job skips
# pnpm install entirely and stays cheap.
- name: Validate release note format
run: node tools/release/build-release-notes.mjs --validate
# Labels are fetched live rather than read from the (stale) event
# payload, so applying `no-release-note` and re-running the check
# works without a new push. Policy lives in a unit-tested script,
# not in workflow bash.
- name: Require a release note for user-visible changes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \
--paginate --jq '[.[] | {filename, status}]' |
jq -s 'add // []' > /tmp/pr-files.json
gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/labels?per_page=100" \
--paginate --jq '[.[].name]' |
jq -s 'add // []' > /tmp/pr-labels.json
jq -n \
--slurpfile files /tmp/pr-files.json \
--slurpfile labels /tmp/pr-labels.json \
'{files: $files[0], labels: $labels[0]}' |
node tools/release/check-release-note-gate.mjs
lint:
name: Lint
runs-on: ubuntu-latest
@@ -17,7 +92,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
# nx affected needs the merge-base with the PR target branch.
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -31,7 +109,18 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint all projects
# PRs lint only affected projects for faster feedback; root config
# or lockfile changes make every project affected, so the
# module-boundary and max-lines rules cannot be dodged this way.
- name: Lint affected projects (PR)
if: github.event_name == 'pull_request'
run: pnpm nx affected --target=lint --base=origin/${{ github.base_ref }} --head=HEAD --parallel=3 --output-style=static
env:
CI: true
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
- name: Lint all projects (master)
if: github.event_name != 'pull_request'
run: pnpm nx run-many --target=lint --all --parallel=3 --output-style=static
env:
CI: true
@@ -44,7 +133,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -78,7 +167,7 @@ jobs:
- name: Upload unit coverage artifact
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: unit-coverage
path: |
@@ -87,7 +176,7 @@ jobs:
- name: Upload unit coverage to Codecov
if: always()
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
files: ./coverage/merged/lcov.info,./coverage/merged/cobertura-coverage.xml
flags: unit
+92
View File
@@ -0,0 +1,92 @@
name: Cleanup PR Draft Release
on:
pull_request:
types: [closed]
# contents: write — delete the draft release; actions: write — cancel the
# closed PR's still-running build workflow before deleting.
permissions:
actions: write
contents: write
jobs:
delete-draft:
name: Delete PR draft release
# Fork PRs never get a draft (the release job skips them) and their
# GITHUB_TOKEN is read-only regardless of the permissions block, so
# there is nothing to cancel or delete.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
# A closed PR can be reopened while this job is still queued or
# waiting; a reopened PR's fresh build must not be cancelled and
# its draft must not be deleted. Check the live state up front
# (and again right before deleting below).
- name: Check PR is still closed
id: pr-state
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
echo "state=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.state')" >> "${GITHUB_OUTPUT}"
# A build for this PR may still be running and would recreate the
# rolling draft after we delete it. Cancel those runs (dead work
# for a closed PR anyway) and wait for them to wind down. The
# release job additionally re-checks the live PR state, so this
# wait is defense in depth, not the only guard.
- name: Cancel in-progress builds for the closed PR
if: steps.pr-state.outputs.state == 'closed'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
set -euo pipefail
# --event pull_request: a manually dispatched build on the
# same branch is not this PR's work and must not be cancelled.
list_active_runs() {
gh run list --repo "${GITHUB_REPOSITORY}" \
--workflow 'Build and Make Electron App' \
--branch "${HEAD_BRANCH}" \
--event pull_request \
--json databaseId,status \
--jq '.[] | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "requested" or .status == "pending") | .databaseId'
}
for run_id in $(list_active_runs); do
echo "Cancelling run ${run_id}"
gh run cancel "${run_id}" --repo "${GITHUB_REPOSITORY}" || true
done
# Cancellation is asynchronous; poll until the runs settle.
for _ in $(seq 1 18); do
if [ -z "$(list_active_runs)" ]; then
break
fi
sleep 10
done
# Draft releases have no real git tag, so a lookup via
# releases/tags/<tag> returns 404. List releases and match the
# draft by its stored tag_name (test-pr-<n>) instead. The PR state
# is re-checked one last time right before deleting, in case the
# PR was reopened during the cancellation wait above.
- name: Delete draft release for closed PR
if: steps.pr-state.outputs.state == 'closed'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if [ "$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.state')" != "closed" ]; then
echo "PR #${PR_NUMBER} was reopened; keeping its draft."
exit 0
fi
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate \
--jq ".[] | select(.draft and .tag_name == \"test-pr-${PR_NUMBER}\") | .id" |
xargs -r -n1 -I{} gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/{}"
+13 -28
View File
@@ -14,6 +14,14 @@ on:
schedule:
- cron: '0 20 * * 3'
# Required so `codeql-action/analyze` can upload its SARIF results. Without an
# explicit grant the default token is read-only and the upload fails with
# "Resource not accessible by integration".
permissions:
actions: read
contents: read
security-events: write
jobs:
analyze:
name: Analyze
@@ -30,42 +38,19 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
fetch-depth: 2
uses: actions/checkout@v7
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
- run: git checkout HEAD^2
if: ${{ github.event_name == 'pull_request' }}
# Initializes the CodeQL tools for scanning.
# Initializes the CodeQL tools for scanning. PR runs analyze the merge
# 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@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -42,7 +42,7 @@ jobs:
run: pnpm nx build website
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@v5
with:
path: dist/apps/website
+11 -1
View File
@@ -26,6 +26,14 @@ on:
permissions:
contents: read
# Superseded PR pushes cancel their still-running image build. Master/tag/
# manual runs get a unique group (run_id): GitHub keeps at most one pending
# run per group even with cancel-in-progress: false, so a shared ref group
# could silently drop a queued publish between two rapid master pushes.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
name: Build Docker image
@@ -34,7 +42,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Prepare Docker metadata
id: docker-meta
@@ -109,5 +117,7 @@ jobs:
push: ${{ steps.docker-meta.outputs.publish == 'true' }}
tags: ${{ steps.docker-meta.outputs.tags }}
platforms: ${{ steps.docker-meta.outputs.platforms }}
build-args: |
BUILD_COMMIT=${{ github.event.pull_request.head.sha || github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max,ignore-error=true
+31 -4
View File
@@ -4,9 +4,36 @@ on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
- 'docs/**'
- '.plans/**'
- '.codex/**'
- '.claude/**'
- 'apps/website/**'
pull_request:
branches:
- master
paths-ignore:
- '**/*.md'
- 'docs/**'
- '.plans/**'
- '.codex/**'
- '.claude/**'
- 'apps/website/**'
workflow_dispatch:
# Superseded PR pushes cancel their still-running E2E matrix (the most
# expensive per-PR runner time). Non-PR runs get a unique group (run_id):
# GitHub keeps at most one pending run per group even with
# cancel-in-progress: false, so a shared ref group could silently drop a
# queued master run between two rapid pushes.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
electron-e2e-tests:
@@ -22,7 +49,7 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -60,7 +87,7 @@ jobs:
- name: Upload Electron Test Results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: playwright-report-electron-${{ matrix.os }}
path: |
@@ -76,7 +103,7 @@ jobs:
NX_SKIP_NX_CACHE: true
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -104,7 +131,7 @@ jobs:
- name: Upload Web Test Results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: playwright-report-web-ubuntu
path: |
+269
View File
@@ -0,0 +1,269 @@
name: Publish Snap after public release
on:
release:
types:
- published
permissions:
contents: read
jobs:
verify-snap:
name: Verify public-release Snap assets
if: ${{ startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}
runs-on: ubuntu-latest
timeout-minutes: 45
env:
SOURCE_ARCHIVE_NAME: linux-frame-copy-runtime-sources.tar.xz
outputs:
receipt-sha256: ${{ steps.bind-transfer.outputs.receipt-sha256 }}
steps:
- name: Checkout released tooling
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event.release.tag_name }}
persist-credentials: false
- name: Install release source verifier
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install --no-install-recommends -y \
binutils \
squashfs-tools \
xz-utils
- name: Select exact public release assets
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh api \
--paginate \
--slurp \
"repos/${GITHUB_REPOSITORY}/releases/${{ github.event.release.id }}/assets?per_page=100" \
> "${RUNNER_TEMP}/snap-release-assets.json"
node tools/packaging/release-snap-assets.cjs select \
--assets-json "${RUNNER_TEMP}/snap-release-assets.json" \
--output-json "${RUNNER_TEMP}/selected-snap-release-assets.json"
- name: Download exact public release assets
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
ASSET_DIRECTORY="${RUNNER_TEMP}/snap-release-downloads"
rm -rf "${ASSET_DIRECTORY}"
mkdir -p "${ASSET_DIRECTORY}"
node -e \
"const fs=require('node:fs'); const selected=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); for (const asset of [...selected.snapAssets, selected.sourceAsset]) console.log([asset.id, asset.name].join('\\t'));" \
"${RUNNER_TEMP}/selected-snap-release-assets.json" |
while IFS=$'\t' read -r ASSET_ID ASSET_NAME; do
gh api \
--header "Accept: application/octet-stream" \
"repos/${GITHUB_REPOSITORY}/releases/assets/${ASSET_ID}" \
> "${ASSET_DIRECTORY}/${ASSET_NAME}"
done
- name: Verify downloaded public release assets
shell: bash
run: |
set -euo pipefail
VERIFIED_ASSET_STAGING="${RUNNER_TEMP}/verified-snap-release-assets"
SEALED_ASSET_PARENT="/var/lib/iptvnator-snap-release"
SEALED_ASSET_DIRECTORY="${SEALED_ASSET_PARENT}/assets"
test -s "${RUNNER_TEMP}/snap-release-downloads/${SOURCE_ARCHIVE_NAME}"
test ! -e "${VERIFIED_ASSET_STAGING}"
sudo test ! -e "${SEALED_ASSET_PARENT}"
node tools/packaging/release-snap-assets.cjs verify \
--manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \
--directory "${RUNNER_TEMP}/snap-release-downloads" \
--repository-revision "$(git rev-parse HEAD)" \
--verified-directory "${VERIFIED_ASSET_STAGING}"
sudo install -d -m 0700 -o root -g root "${SEALED_ASSET_PARENT}"
sudo mv "${VERIFIED_ASSET_STAGING}" "${SEALED_ASSET_DIRECTORY}"
sudo chown -R root:root "${SEALED_ASSET_DIRECTORY}"
sudo find "${SEALED_ASSET_DIRECTORY}" -type d -exec chmod 0555 {} +
sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} +
sudo chmod 0555 "${SEALED_ASSET_PARENT}"
- name: Reverify sealed public release assets
shell: bash
run: |
set -euo pipefail
VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"
node tools/packaging/release-snap-assets.cjs verify-sealed \
--manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \
--directory "${VERIFIED_ASSET_DIRECTORY}" \
--receipt "${VERIFIED_ASSET_DIRECTORY}/verified-release-assets.json" \
--repository-revision "$(git rev-parse HEAD)"
- name: Bind verified release transfer
id: bind-transfer
shell: bash
run: |
set -euo pipefail
RECEIPT_PATH="/var/lib/iptvnator-snap-release/assets/verified-release-assets.json"
RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")"
RECEIPT_SHA256="${RECEIPT_RECORD%% *}"
[[ "${RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]]
printf 'receipt-sha256=%s\n' "${RECEIPT_SHA256}" >> "${GITHUB_OUTPUT}"
- name: Transfer verified release assets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: verified-snap-release-assets
path: /var/lib/iptvnator-snap-release/assets
if-no-files-found: error
retention-days: 1
compression-level: 0
include-hidden-files: true
publish-snap:
name: Publish verified public-release Snap to edge
needs: verify-snap
if: ${{ needs.verify-snap.result == 'success' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Download verified release assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
name: verified-snap-release-assets
path: ${{ runner.temp }}/verified-snap-release-assets
- name: Seal transferred public release assets
shell: bash
env:
EXPECTED_RECEIPT_SHA256: ${{ needs.verify-snap.outputs.receipt-sha256 }}
run: |
set -euo pipefail
TRANSFERRED_ASSET_DIRECTORY="${RUNNER_TEMP}/verified-snap-release-assets"
SEALED_ASSET_PARENT="/var/lib/iptvnator-snap-release"
SEALED_ASSET_DIRECTORY="${SEALED_ASSET_PARENT}/assets"
test -d "${TRANSFERRED_ASSET_DIRECTORY}"
test ! -L "${TRANSFERRED_ASSET_DIRECTORY}"
shopt -s nullglob dotglob
TRANSFERRED_FILES=("${TRANSFERRED_ASSET_DIRECTORY}"/*)
TRANSFERRED_SNAPS=("${TRANSFERRED_ASSET_DIRECTORY}"/*.snap)
test "${#TRANSFERRED_SNAPS[@]}" -gt 0
test "${#TRANSFERRED_FILES[@]}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 2 ))"
test -f "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz"
test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz"
test -f "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"
test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"
for ASSET_FILE in "${TRANSFERRED_FILES[@]}"; do
test -f "${ASSET_FILE}"
test ! -L "${ASSET_FILE}"
done
RECEIPT_PATH="${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"
RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")"
ACTUAL_RECEIPT_SHA256="${RECEIPT_RECORD%% *}"
[[ "${EXPECTED_RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]]
test "${ACTUAL_RECEIPT_SHA256}" = "${EXPECTED_RECEIPT_SHA256}"
/usr/bin/jq --exit-status '
type == "object" and
(keys == ["assets", "repositoryRevision", "schemaVersion"]) and
(.schemaVersion == 1) and
(.repositoryRevision |
type == "string" and test("^[a-f0-9]{40,64}$")) and
(.assets | type == "array" and length >= 2) and
(.assets | all(.[];
type == "object" and
(keys == ["id", "name", "sha256", "size"]) and
(.id |
type == "number" and . > 0 and
. <= 9007199254740991 and . == floor) and
(.name |
type == "string" and length > 0 and
. != "." and . != ".." and
(contains("/") | not) and
(contains("\\") | not) and
(explode | all(.[]; . > 31 and . != 127))) and
(.sha256 |
type == "string" and test("^[a-f0-9]{64}$")) and
(.size |
type == "number" and . > 0 and
. <= 9007199254740991 and . == floor))) and
([.assets[].name] | length == (unique | length)) and
([.assets[] |
select(.name == "linux-frame-copy-runtime-sources.tar.xz")] |
length == 1) and
([.assets[] | select(.name | endswith(".snap"))] |
length >= 1) and
(.assets | all(.[];
.name == "linux-frame-copy-runtime-sources.tar.xz" or
(.name | endswith(".snap"))))
' "${RECEIPT_PATH}" > /dev/null
RECEIPT_ASSET_COUNT="$(/usr/bin/jq --raw-output '.assets | length' "${RECEIPT_PATH}")"
test "${RECEIPT_ASSET_COUNT}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 1 ))"
SIZE_MANIFEST="${RUNNER_TEMP}/verified-release-asset-sizes.tsv"
CHECKSUM_MANIFEST="${RUNNER_TEMP}/verified-release-asset-checksums.txt"
umask 077
/usr/bin/jq --raw-output \
'.assets[] | [.name, (.size | tostring)] | @tsv' \
"${RECEIPT_PATH}" > "${SIZE_MANIFEST}"
while IFS=$'\t' read -r ASSET_NAME EXPECTED_SIZE; do
ASSET_PATH="${TRANSFERRED_ASSET_DIRECTORY}/${ASSET_NAME}"
ACTUAL_SIZE="$(/usr/bin/stat --format=%s -- "${ASSET_PATH}")"
test "${ACTUAL_SIZE}" = "${EXPECTED_SIZE}"
done < "${SIZE_MANIFEST}"
/usr/bin/jq --raw-output \
'.assets[] | "\(.sha256) \(.name)"' \
"${RECEIPT_PATH}" > "${CHECKSUM_MANIFEST}"
(
cd "${TRANSFERRED_ASSET_DIRECTORY}"
/usr/bin/sha256sum --strict --check "${CHECKSUM_MANIFEST}"
)
rm -f "${SIZE_MANIFEST}" "${CHECKSUM_MANIFEST}"
shopt -u nullglob dotglob
sudo test ! -e "${SEALED_ASSET_PARENT}"
sudo install -d -m 0700 -o root -g root "${SEALED_ASSET_PARENT}"
sudo mv "${TRANSFERRED_ASSET_DIRECTORY}" "${SEALED_ASSET_DIRECTORY}"
sudo chown -R root:root "${SEALED_ASSET_DIRECTORY}"
sudo find "${SEALED_ASSET_DIRECTORY}" -type d -exec chmod 0555 {} +
sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} +
sudo chmod 0555 "${SEALED_ASSET_PARENT}"
- name: Install Snapcraft
shell: bash
run: |
set -euo pipefail
sudo snap install snapcraft --classic --channel=stable
- name: Publish all public-release snaps to edge
shell: bash
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }}
run: |
set -euo pipefail
VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"
STORE_CREDENTIALS="${SNAPCRAFT_STORE_CREDENTIALS}"
unset SNAPCRAFT_STORE_CREDENTIALS
shopt -s nullglob dotglob
SNAP_FILES=("${VERIFIED_ASSET_DIRECTORY}"/*.snap)
test "${#SNAP_FILES[@]}" -gt 0
for SNAP_FILE in "${SNAP_FILES[@]}"; do
SNAP_NAME="${SNAP_FILE##*/}"
echo "Publishing public release asset: ${SNAP_NAME}"
# Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke.
# GitHub Actions never promotes automatically.
SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}"
done
unset STORE_CREDENTIALS
shopt -u nullglob dotglob
+16 -1
View File
@@ -72,7 +72,17 @@ Thumbs.db
.gemini
.cursor
.agent
.claude
# Agent config stays local, except the skills the repo owns. Claude Code only
# discovers skills under .claude/skills/, so the release skills are committed
# there as well as under .codex/skills/. Personal skills in .claude/skills/
# remain ignored — each shared skill is opted in by name.
.claude/*
!.claude/skills/
.claude/skills/*
!.claude/skills/release-notes/
!.claude/skills/release-notes/**
!.claude/skills/release-cut/
!.claude/skills/release-cut/**
.codex/*
!.codex/skills/
!.codex/skills/**
@@ -84,4 +94,9 @@ apps/electron-backend/src/app/options/electron-builder.metadata.generated.json
vendor/embedded-mpv/*/bin/
vendor/embedded-mpv/*/include/
vendor/embedded-mpv/*/lib/
vendor/embedded-mpv/*/notices/
vendor/embedded-mpv/*/runtime-manifest.json
# MemPalace per-project files (issue #185)
mempalace.yaml
entities.json
@@ -0,0 +1,90 @@
# Linux Embedded MPV Frame-Copy Packaging Plan
## Audited baseline
- Linux frame-copy already has an isolated `iptvnator_mpv_helper`, a frame
reader addon, shared controls, native-view fallback, and runtime capability
probes.
- Existing packaged Linux builds intentionally remove the helper/runtime and
retain only system `mpv --wid` native-view.
- Electron, Electron libraries, `embedded_mpv.node`, and
`embedded_mpv_frame_reader.node` must never load or link libmpv. Only the
helper may link it.
- Electron Builder produces AppImage, DEB, RPM, Pacman, Snap, and Flatpak
Linux targets. The available reproducible native/runtime toolchain is x64.
## Decisions
1. Support official frame-copy artifacts on Linux x64 only. Keep every non-x64
artifact marker-only and fail closed to native-view; never accept an
architecture override that injects x64 native files.
2. Use three isolated packaging profiles:
- `system`: DEB/RPM/Pacman use declared distribution libmpv/GL dependencies
and contain no private `native/lib`.
- `portable`: AppImage/Snap contain a pinned LGPL-compatible shared-library
closure with `$ORIGIN`-relative helper loading.
- `flatpak`: Flatpak contains the same pinned closure, validated in the
exact `/app` runtime context.
3. Treat the package manifest as necessary but insufficient. Frame-copy is
available only after exact manifest/schema/profile checks, executable-mode
checks, artifact hashes, dependency-closure/process-isolation checks, and a
bounded helper runtime probe. No environment flag bypasses this gate.
4. Publish exact source archives, recursive source identities, build flags,
licenses, notices, patches/tooling, and pinned display data for bundled
runtimes. Bind every bundled x64 package manifest to the final compliance
archive bytes and released repository revision.
5. Keep Snap Store credentials isolated from release-tag code on a fresh
runner. Store publication is edge-only; candidate/stable promotion remains
manual after installed-package smoke.
## TDD implementation phases
1. Add failing tests for target/profile partitioning, x64 and marker-only
layouts, exact dependency declarations, RPATH/SONAME rules, executable
modes, and Electron/libmpv isolation.
2. Implement profile-aware build and packaging hooks that stage the helper,
frame reader, runtime manifest, private closure where applicable, and legal
payload without weakening native-view.
3. Add failing runtime-policy tests for missing/tampered files, wrong
architecture/profile, malformed manifests, loader failures, hostile
environments, probe timeout/output bounds, and stable fallback reasons.
4. Implement one sanitized helper environment shared by probe and playback,
including Snap graphics-provider handling and Flatpak runtime paths.
5. Add failing compliance/release tests for exact recursive submodule records,
VCS-free source inventory, archive member/type layout, source checksums,
license/notices completeness, package-to-source byte binding, sealed asset
receipts, and credential boundaries.
6. Implement deterministic source generation, package bindings, static Snap
inspection, fresh-runner artifact transfer, and minimal direct Store
upload.
7. Add packaged x64 smoke for actual frame-copy playback plus missing-runtime
native-view fallback. Run fixture-contract tests before the smoke and allow
CI llvmpipe through Chromium's GPU blocklist without bypassing the runtime
gate.
8. Update canonical architecture/maintenance documentation and mirrored
`AGENTS.md`/`CLAUDE.md` contracts.
## Acceptance and verification matrix
- Local/macOS:
- Nx discovery
- packaging and Electron backend unit/integration tests
- packaged-smoke fixture tests
- affected lint targets
- production backend build
- formatting, syntax, and `git diff --check`
- Linux x64 CI:
- build the pinned runtime/helper/frame reader
- verify helper links/resolves libmpv and Electron/addons do not
- extract and statically validate all six package families
- run system, portable, Snap-installed, and Flatpak application probes
- run packaged frame-copy playback and missing-runtime native-view fallback
- regenerate and bind the exact compliance source archive
- Non-x64 CI:
- build selected ARM package targets independently
- require marker-only layout and absence of every x64 native/runtime artifact
- Merge gate:
- exact-head CI green
- no unresolved review findings
- fresh code review clean
- no automatic Snap promotion beyond edge
+287 -7
View File
@@ -16,7 +16,8 @@ This file provides guidance to coding agents working in this repository.
- Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`. Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`.
- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents.
- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.
- Repository-specific skills are committed under `.codex/skills/`. If an external agent does not support skills, treat those files as concise ownership docs.
- ESLint enforces `max-lines` on TypeScript files (target under 300, hard maximum 400). Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs`. Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- <why> */`; the generator skips those files, so a justified exemption never lands in the baseline.
- Repository-specific skills are committed under `.codex/skills/`. Claude Code only discovers skills under `.claude/skills/`, so `release-notes` and `release-cut` are mirrored there and the two copies must be kept in sync; every other entry in `.claude/skills/` is personal and stays gitignored. If an external agent does not support skills, treat those files as concise ownership docs.
## Documentation After Changes
@@ -33,6 +34,18 @@ This file provides guidance to coding agents working in this repository.
- Repo docs are canonical even when they were originally drafted by an LLM.
- Final task summaries should state whether docs were updated and which doc changed.
## Release Notes For User-Visible Changes
- Any change a user could notice — new behavior, changed behavior, bug fix, performance win, breaking change — must add one note file under `.changes/` in the same PR. Format, field table, and writing rules: `.changes/README.md`.
- Name it `<area>-<short-slug>.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.
- Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post.
- Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label.
- CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt).
- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence.
- Validate before finishing: `pnpm run release:notes:validate`.
- Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image.
- Final task summaries should state whether a release note was added or why it was skipped.
## Regression Prevention And Test Updates
- Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required.
@@ -70,14 +83,14 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend
- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events
- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player launch/reuse/polling debug output
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC request/completion markers plus count-only M3U acquire/parse/normalize and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization phase events, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
- GPU/compositor debugging:
```bash
IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 nx serve electron-backend
```
- Settings, portal request/response, and trace payloads must use
`@iptvnator/shared/logging` or the redacting portal logger before reaching
`console.*`; never log raw credentials while debugging.
- If local Nx state gets weird before a rerun:
@@ -128,6 +141,268 @@ Key files:
- `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html` — template conditionals for radio vs video
- `libs/shared/interfaces/src/lib/channel.interface.ts` — `radio: string` field on Channel interface
## Shared Player Controls
- `libs/ui/playback/src/lib/player-controls/` contains the additive,
engine-neutral `PlayerController` contract, standalone
`app-player-controls`, generic web-video adapter/helper, and component-scoped
`WEB_PLAYER_SHARED_CONTROLS` rollout token.
- In fullscreen, `app-player-controls` shows a pointer-transparent media-title
overlay at the top while controls are revealed (`mediaTitle` input:
movie/channel/series name, plus an `S01E03` second line for episodes). Series
names flow from the Xtream/Stalker detail views through
`PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`;
movie and live hosts fall back to `playback.title`, skipping raw stream-URL
fallbacks. Outside fullscreen the overlay stays hidden.
- Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox
appears only when HTML5, Video.js, or ArtPlayer is selected.
`WebPlayerViewComponent` snapshots the preference into
`WEB_PLAYER_SHARED_CONTROLS` for each new player host. The parent `/workspace`
route awaits the initial `SettingsStore` load, including cold-start direct
links, before this snapshot can occur. Saving applies to the next host without
an application restart; an existing session never changes controls mode in
place.
- `Settings.showCaptions` is deliberately outside this rollout gate: it is
engine state, not controls UI. HTML5, Video.js, and ArtPlayer apply it in both
modes — shared controls through their controls bridge, the preference-off
paths through the same helpers without an adapter (`WebVideoSourceTracks` for
HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js). Both re-apply the preference
as the engine adds or switches text tracks. `WebPlayerViewComponent` reads it
from `SettingsStore` rather than a host input, so the M3U player, the
Xtream/Stalker live layouts, and the portal detail inline player all inherit
it (#1155).
- The modes differ in how long the preference is enforced. Shared controls are
authoritative for the session; user intent arrives through `setSubtitleTrack`
and wins until the source changes. Vendor chrome is source-default: the
preference seeds each new source and is released once the media element
reports `playing`, so the engine's own caption menu keeps working. The mode is
selected by the optional `playbackStarted` probe the legacy owners pass to all
three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper
deselects the track (`subtitleTrack = -1`) instead of hiding it, because
`subtitleDisplay` would silently override whatever the vendor menu picks. For
DASH the seed happens in `ShakaVideoSession.start()` after the manifest loads,
so the helper only stops re-suppressing afterwards.
- Embedded MPV ignores the web-player preference. Frame-copy always uses shared
DOM controls through its component-scoped `EmbeddedMpvControlsAdapter`, while
native-view retains the legacy compositor-safe dock and external MPV/VLC
retain their own UI. The host must render exactly one controls system for the
reported Embedded MPV engine.
- Frame-copy shared controls own DOM surface interactions, shortcuts,
fullscreen, and recording feedback. `showControls=false` detaches the shared
surface, modal overlays gate playback shortcuts, fullscreen still triggers
bounds sync, and a playback/session transition key prevents engine or session
handoff from presenting stale recording feedback while timers and pending
commands are cancelled. Same-session IPC replies also yield to a broadcast
snapshot received while the command was pending, preventing a successful
recording acknowledgement from being rolled back by a stale reply.
- DASH (`.mpd`) sources play through a lazily imported Shaka Player source
engine (`libs/ui/playback/src/lib/shaka-engine/`) inside the HTML5 and
ArtPlayer components; ClearKey keys come from KODIPROP-derived
`Channel.drm`, and the shared bridge exposes Shaka audio/text tracks via
source kind `shaka`. See the CLAUDE.md "Video Players" feature entry and
`docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback").
- The built-in HTML5/hls.js player is the second guarded consumer.
`HtmlVideoPlayerComponent` provides a component-scoped
`WebVideoControlsAdapter`; its neutral `web-video-support` bridge is shared
with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction,
caption preference, and source cleanup.
`HtmlVideoElementSession` owns native video-event lifecycle, persisted
volume, start-time/time/ended propagation, and legacy post-play caption
suppression.
`WebPlayerViewComponent.resolvedIsLive` supplies authoritative live/VOD
metadata, while a visible playback diagnostic disables both shared surface
interaction and shortcuts and exits the HTML5 shell's own fullscreen so the
diagnostic actions remain visible. The preference-off path keeps native
controls and legacy series navigation unchanged.
- Video.js is the third guarded consumer. `VjsPlayerComponent` provides a
component-scoped `WebVideoControlsAdapter`; its bridge binds the current Tech
video, rebinds after `playerreset`, exposes source-stable audio/subtitle IDs,
preserves caption preference and explicit subtitle-off state, and reads
duration from Video.js. Reset-driven raw MPEG-TS changes pause first,
coalesce to the latest desired source, preserve actual volume across
Video.js's reset, and restart when authoritative live/VOD metadata changes.
The shared-controls path disables native controls, Video.js
click/double-click/hotkey actions, and spatial navigation;
diagnostic gating and owned-fullscreen exit match HTML5. The preference-off
path keeps the existing Video.js skin and legacy series navigation unchanged.
- ArtPlayer is the fourth guarded consumer. `ArtPlayerComponent` provides a
component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns
HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and
a destroyed-session guard for delayed `customType` callbacks, while
`ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared mode uses
authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference,
MPEG-TS VOD duration correction, and reapplies app volume directly after
ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled,
and a transparent capture layer gives shared controls exclusive click and
double-click ownership. Diagnostic interaction gating and owned-fullscreen
exit match the other web players. The preference-off path keeps the legacy
ArtPlayer skin, source behavior, and series navigation unchanged.
- Shared web picture-in-picture stays inside that default-off rollout.
`PlayerController` exposes capability `pictureInPicture`, state
`pictureInPictureActive`/`canPictureInPicture`, and command
`togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard
element PiP from the adapter's attached video; shared ArtPlayer keeps vendor
`pip: false`, while preference-off native/vendor paths remain unchanged. The
capability-gated button sits before fullscreen and uses active enter/exit
semantics; entry is disabled until metadata, and the action is disabled while
an operation is pending. Embedded MPV reports capability/state false with a
no-op command and has no popup/mini-window.
- `WebVideoControlsAdapter` supplies its current video and binding generation to
`WebVideoPictureInPictureController`; the controller reads the video's
`ownerDocument`, while browser enter/leave events remain authoritative.
Exact-owner exit stays available if request support changes. Request/exit
invocation remains synchronous for user activation, one operation is
serialized, and binding generation plus exact video identity protects
replacement and teardown from stale completion. Video.js Tech reset and
ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a
retained target preserve PiP.
Standard PiP shows the browser/OS video surface without Angular control
chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP
keyboard shortcut, and Embedded MPV popup/native support are out of scope.
- Canonical docs: `docs/architecture/player-controls-contract.md` and
`docs/architecture/embedded-mpv-native.md`
## Linux Embedded MPV Packaging
- Official Linux frame-copy artifacts are x64-only. AppImage, DEB, RPM,
Pacman, Snap, and Flatpak are supported; non-x64 Linux packages must remain
marker-only and must never inherit x64 native artifacts from environment
overrides.
- Packaging runs three isolated profiles:
- `system`: DEB/RPM/Pacman, no private `native/lib`, with package
dependencies DEB=`libmpv2,libegl1,libgl1,libgbm1`,
RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and
Pacman=`mpv,libglvnd,mesa`
- `portable`: AppImage/Snap with the pinned LGPL-compatible closure
- `flatpak`: Flatpak with the same pinned closure
- Flatpak is an isolated packaging pass and keeps `iptvnator` as the real
Electron ELF so Electron Builder's `electron-wrapper` passes it directly to
Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and
`iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation.
- The DEB system-runtime contract is Ubuntu 24.04+ (`libmpv2`). Ubuntu 22.04
provides `libmpv1`, so use the x64 AppImage on Jammy instead of weakening the
package dependency or advertising frame-copy without a compatible runtime.
- Only `iptvnator_mpv_helper` may link libmpv. The Electron executable,
Electron libraries, `embedded_mpv.node`, and
`embedded_mpv_frame_reader.node` must not load or link it. Preserve this
process-isolation contract in build, package, and smoke checks.
- `electron-backend/native{,/**/*}` is excluded from `app.asar`; `afterPack`
exclusively writes the profile-normalized unpacked native tree. Layout and
final-artifact checks must reject every archived
`/electron-backend/native/**` entry so system and marker-only packages cannot
hide stale x64 artifacts.
- Packaged addon, frame-reader, and helper discovery is package-owned
`app.asar.unpacked` only. Writable cwd/dist candidates are development-only
and must never satisfy packaged native-view support or the frame-copy gate.
- Pristine afterPack/unpacked layouts scan Electron libraries recursively.
Extracted Snap payloads exclude only the package-manager `lib/**` and
`usr/lib/**` trees that Snap overlays into the same root; every other
directory remains recursive, and Electron-library symlinks still fail
closed.
- Linux frame-copy availability is fail-closed. The packaged manifest,
artifact modes, declared bundled hashes/closure, and bounded
`--runtime-probe` must all succeed before frame-copy can relax the renderer
sandbox. Any failure reports a stable reason and falls back to native-view
without crashing; an environment flag never bypasses this gate.
- Snap is `core22`/strict and uses an exact private `shared-memory` plug plus
the `graphics-core22` content plug at an empty mode-0755 `$SNAP/graphics`,
with `mesa-core22` as default provider. It declares only the canonical
provider layouts: `/usr/share/libdrm` binds from
`$SNAP/graphics/libdrm`, and `/usr/share/drirc.d` symlinks to
`$SNAP/graphics/drirc.d`. The provider is external shared content, not part
of IPTVnator's package size, source archive, or notices. Installed-Snap CI
must prove controlled unavailable exit after disconnect, then reconnect and
prove success. The helper links `libGL.so.1` rather than `libOpenGL.so.0`.
- The probe and playback helper share one sanitized loader environment:
ambient audit, preload, library, graphics-driver, and shell-startup overrides
are removed; the validated private closure wins; trusted Snap GL,
`graphics-core22`, the core22 base x64 root, and exact GNOME-platform roots
precede generic in-snap roots. The core22 base must precede GNOME so its
`libedit.so.2` cannot be replaced by the older copy requiring
`libtinfo.so.5`. The extracted-artifact verifier removes the identical
unsafe loader/graphics/shell set before direct helper smoke while preserving
feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the
wrapper `PATH`, removes exported `BASH_FUNC_*` functions, and launches
probe/playback through the regular executable
`$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or
disconnected provider returns `snap-graphics-provider-unavailable` before
helper spawn. The packaging-only `--embedded-mpv-runtime-probe` app switch
runs the complete cached manifest/hash/helper gate before BrowserWindow
startup and exits with one availability JSON line. A nonzero helper exit
keeps top-level reason `helper-probe-failed`; `helperReason` is present only
for an exact protocol-v1 line carrying a fixed allowlisted reason, and its
optional `helperDetail` must be 1–1024 printable ASCII characters. Invalid
detail suppresses both helper fields. Every probe uses an explicit 16 MiB
aggregate captured-output ceiling independent of tracing. With
`IPTVNATOR_TRACE_PLAYER=1`, a non-empty helper stderr capture is emitted
separately as one JSON-escaped stderr line whose `stderr` field is limited
to 16,384 characters and whose `truncated` field is always explicit;
trace-write failure cannot change the capability result. Installed-Snap CI
enables Mesa EGL/GL diagnostics through this bounded channel. Any loader
failure remains a stable native-view fallback, never a flag-enabled success.
- In the exact packaged Flatpak `/app` context, reconstruct only Freedesktop
Platform 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its GL
extension loader path comes from the sandbox cache. Flatpak CI must invoke
the application-level `--embedded-mpv-runtime-probe`, not a direct helper
probe that bypasses capability detection.
- The packaged x64 Playwright smoke runs its fixture-contract target first and
passes Chromium `--ignore-gpu-blocklist` so CI llvmpipe can expose WebGL2.
This launch-only flag does not bypass the manifest, hash, loader, or helper
capability gate; `--no-sandbox` remains root-only.
- Bundled Linux releases must publish the exact source archives/git records,
checksums, licenses, flags, patches, build scripts, and the pinned hwdata
`pnp.ids` input. Each bundled package carries
`embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and the exact
`licenses/**` files. CI may cache immutable source inputs, but regenerates
notices and a VCS-metadata-free
`linux-frame-copy-runtime-sources.tar.xz` for the current checkout on every
run while retaining the exact pinned six recursive libplacebo submodule
records. Each record is canonical `full-commit safe/path`; clone-depth
dependent `git describe` annotations are discarded and never form part of
the provenance identity. Its source index carries the globally sorted libplacebo
directory/file/symlink inventory; file hashes, sizes, executable bits, link
targets, aggregates, and canonical tree digest must match the trusted pinned
checkout. The archive has an exact member/type layout and its
`metadata/archive-sha256.txt` records must match the actual source archives.
Concatenated tar/xz streams are inspected past every end marker. The final
archive's SHA-256 and repository revision are copied into every bundled x64
package manifest; system and marker-only packages carry no source-archive
binding.
Automated Snap Store publication is allowed only after a public `v*` GitHub
release contains both the Snap assets and exactly one matching source
archive. Before any upload, the workflow hashes and inspects that archive,
verifies its exact member/type set and size bounds, clean tag revision,
pinned sources including the six recursive submodule records and exact
libplacebo tree digest, legal files, and exact released tooling, then
performs bounded extraction and static package validation for every Snap.
That public-release boundary independently revalidates the exact strict
`meta/snap.yaml` graphics/shared-memory contract and enumerates
`resources/app.asar`, rejecting any archived
`electron-backend/native/**` payload before publication. Its bounded ASAR
header reader uses only Node built-ins and released local tooling, so the
clean tag checkout does not require `node_modules`.
Exactly one x64 Snap must have matching
`sourceArchive` and `sourceRuntime`; any non-x64 Snap must remain
marker-only. Checkout and the artifact-transfer actions are pinned to full
commits; checkout does not persist credentials, and repository credentials
are limited to download steps. A secretless verification job copies assets
through no-follow descriptors, checks pre/post hashes, writes an exact
receipt, repeats the complete source/package verification on a root-owned
read-only snapshot, and transfers only that data through the pinned artifact
service while its receipt digest travels separately through a job output.
The dependent publish job runs on a bounded `ubuntu-latest` runner with no
checkout or release-tag code, verifies that digest plus the exact receipt,
asset hashes, and file-only layout, root-seals the data again, and installs
Snapcraft directly. Store credentials exist only in its final fixed shell
step, which resolves no PATH command, executes no released code, and exposes
the credential only to each exact
`/snap/bin/snapcraft upload --release=edge` process.
Candidate/stable promotion is manual after installed-Snap frame-copy and
missing-runtime fallback smoke; GitHub Actions never promotes automatically.
Canonical maintenance docs:
`docs/architecture/embedded-mpv-native.md` and
`tools/embedded-mpv/README.md`.
## Repo Skills
- `iptvnator-ui-design`
@@ -150,6 +425,11 @@ Key files:
Use when moving heavy database work off the main thread, adding worker-backed SQLite operations, or wiring loading/progress UI for Xtream and playlist DB flows.
File: `.codex/skills/iptvnator-sqlite-db-worker/SKILL.md`
- `stalker-portal`
Repository-specific guidance for Stalker/Ministra catalogs, all three VOD/series modes, cross-surface `is_series` behavior, playback metadata, collections, EPG, and remote control.
Use when changing Stalker routes, stores, detail views, playback, favorites/recent activity, EPG, or remote control.
File: `.codex/skills/stalker-portal/SKILL.md`
- `xtream-electron`
Repository-specific guidance for IPTVnator's Electron-first Xtream implementation, including feature/data-access boundaries, worker-backed DB flows, and Xtream loading/progress UX expectations.
Use when working on Xtream routes, store/data-source logic, or Electron-backed Xtream import/search/delete behavior.
+13
View File
@@ -1,3 +1,16 @@
# Changelog
Releases **0.13.0 – 0.23.0** were published as
[GitHub releases](https://github.com/4gray/iptvnator/releases) and
[website posts](https://4gray.github.io/iptvnator/blog/) rather than collected
here. This file resumes from the next release onwards; the entries below are
kept as they were written.
New sections are generated from `.changes/*.md` and inserted directly below
this marker — see `.changes/README.md`.
<!-- next-release -->
# [0.12.0](https://github.com/4gray/iptvnator/compare/v0.11.1...v0.12.0) (2023-03-11)
+220 -24
View File
@@ -26,6 +26,18 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Repo docs are canonical even when they were originally drafted by an LLM.
- Final task summaries should state whether docs were updated and which doc changed.
## Release Notes For User-Visible Changes
- Any change a user could notice — new behavior, changed behavior, bug fix, performance win, breaking change — must add one note file under `.changes/` in the same PR. Format, field table, and writing rules: `.changes/README.md`.
- Name it `<area>-<short-slug>.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.
- Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post.
- Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label.
- CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt).
- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence.
- Validate before finishing: `pnpm run release:notes:validate`.
- Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image.
- Final task summaries should state whether a release note was added or why it was skipped.
## Regression Prevention And Test Updates
- Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, Claude Code must complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required.
@@ -59,7 +71,7 @@ pnpm nx show projects
- Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`.
- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json`.
- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.
- Repository-specific skills are committed under `.codex/skills/`. If Claude Code does not load skills directly, treat those files as concise ownership docs.
- Repository-specific skills are committed under `.codex/skills/`. Claude Code only discovers skills under `.claude/skills/`, so `release-notes` and `release-cut` are mirrored there and the two copies must be kept in sync; every other entry in `.claude/skills/` is personal and stays gitignored. If an agent does not load skills directly, treat those files as concise ownership docs.
### Building and Serving
@@ -127,14 +139,14 @@ Useful narrower flags:
- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and DB progress events
- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in both main and worker connections
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player launch/reuse/polling debug output
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC request/completion markers plus count-only M3U acquire/parse/normalize and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization phase events, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
For GPU/compositor debugging:
```bash
IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 nx serve electron-backend
```
Settings, portal request/response, and trace payloads must use
`@iptvnator/shared/logging` or the redacting portal logger before reaching
`console.*`; never log raw credentials while debugging.
If the Nx daemon gets into a bad state before rerunning Electron:
@@ -195,7 +207,7 @@ Before finishing behavior changes or bug fixes, follow `Regression Prevention An
### Linting
```bash
# Lint all projects (what CI enforces on every PR)
# Lint all projects (CI runs this on master; PRs lint affected projects)
pnpm run lint
# Lint a single project
@@ -203,12 +215,17 @@ nx lint web
nx lint electron-backend
```
CI runs lint for every project (`.github/workflows/ci.yml`). This enforces the
CI lints affected projects on PRs (`nx affected`) and every project on master
pushes (`.github/workflows/ci.yml`). This enforces the
Nx module-boundary tags, the legacy bare-alias ban, and a `max-lines` ESLint
rule (hard maximum 400 lines per TypeScript file). Pre-existing oversized files
are baselined in `tools/eslint/max-lines-baseline.mjs`; regenerate the baseline
with `node tools/eslint/generate-max-lines-baseline.mjs` after splitting a file.
Never add new files to the baseline.
Never add new files to the baseline — the list must only shrink. A new file
that genuinely cannot be split (for example a function serialized into another
process) instead carries its own file-wide
`/* eslint-disable max-lines -- <why> */`; the generator skips those files, so
a justified exemption never lands in the baseline.
Project `lint` targets that shell out to eslint must quote the glob, e.g.
`eslint "apps/<project>/**/*.ts"`. An unquoted `**` is expanded by the POSIX
@@ -244,8 +261,10 @@ This is an Nx monorepo with the following structure:
- **portal/shared/{data-access,ui,util}** - Cross-portal shared code
- **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`)
- **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`)
- **shared/logging** - Dependency-free structured redaction for diagnostic logs
- **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend)
- **shared/m3u-utils** - M3U playlist utilities
- **shared/marketing-fixtures** - Provider-neutral fictional movie metadata shared by the Xtream and Stalker marketing mocks
- **shared/testing** - Shared test helpers
- **ui/components** - Reusable UI components (incl. channel list)
- **ui/epg** - EPG UI (timeline ribbon, multi-EPG, progress panel, program dialogs)
@@ -345,10 +364,11 @@ Key patterns:
- **Factory injection**: `provideXtreamDataSource()` selects Electron or PWA implementation at runtime
Data strategies by environment:
| Environment | Strategy |
|-------------|----------|
| Environment | Strategy |
| ------------ | ------------------------------------------------------- |
| **Electron** | DB-first: Check DB → fetch API if missing → cache to DB |
| **PWA** | API-only: Always fetch from API, store in memory |
| **PWA** | API-only: Always fetch from API, store in memory |
**M3U Playlist Module Architecture**:
@@ -424,7 +444,7 @@ State management via NgRx (`libs/m3u-state/`):
- `PlaylistActions`: loadPlaylists, addPlaylist, removePlaylist, parsePlaylist
- `ChannelActions`: setChannels, setActiveChannel, setAdjacentChannelAsActive
- `EpgActions`: setActiveEpgProgram, setCurrentEpgProgram, setEpgAvailableFlag
- `FavoritesActions`: updateFavorites, setFavorites
- `FavoritesActions`: updateFavorites, setFavorites, hydrateFavorites
See `docs/architecture/m3u-playlist-module.md` for complete documentation.
@@ -579,6 +599,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- `favorites` - User favorites
- `recentlyViewed` - Watch history
- `epgChannels`, `epgPrograms` - Persisted EPG data
- `epgChannelMappings` (`epg_channel_mappings`) - Manual EPG channel mappings (defined in `epg-mapping.schema.ts`, re-exported by `schema.ts`)
- `playbackPositions` - Resume positions
- `downloads` - Download manager state
- `appState` - Key-value app state (also tracks one-off data migrations)
@@ -597,7 +618,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- **Event handlers**: `apps/electron-backend/src/app/events/`
- `database.events.ts` - Database CRUD operations
- `playlist.events.ts` - Playlist import/update
- `epg.events.ts` - EPG IPC registration and freshness/fetch orchestration; worker lifecycle lives in `epg-worker.service.ts`, DB lookups in `epg-query.service.ts`
- `epg.events.ts` - EPG IPC registration; freshness/fetch orchestration lives in `epg-fetch.service.ts`, manual channel-mapping resolution and CRUD in `epg-mapping.service.ts`, worker lifecycle in `epg-worker.service.ts`, DB lookups in `epg-query.service.ts`
- `xtream.events.ts` - Xtream Codes API
- `stalker.events.ts` - Stalker portal API
- `player.events.ts` - External player IPC registration; MPV/VLC lifecycle logic lives in `mpv-session.service.ts`, `vlc-session.service.ts`, and shared `external-player-*` helpers
@@ -608,7 +629,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- EPG parsing: `epg-parser.worker.ts`; main-process worker lifecycle is coordinated from `apps/electron-backend/src/app/events/epg-worker.service.ts`
- Non-EPG SQLite work: `database.worker.ts` (see `docs/architecture/sqlite-db-worker.md`)
- Playlist refresh: `playlist-refresh.worker.ts`
- Playlist refresh: `playlist-refresh.worker.ts`; explicit cancellation is main-process-owned and terminates the one-shot worker before acknowledging `PLAYLIST_CANCEL_REFRESH` (see `docs/architecture/m3u-playlist-module.md`)
### Key Features
@@ -620,16 +641,186 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
**Video Players**:
- Built-in HTML5 player with HLS.js or Video.js
- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer
- DASH + ClearKey (M3U module): `.mpd` channels play through a lazily loaded
Shaka Player source engine inside the HTML5 and ArtPlayer components (no new
player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*`
lines, post-processed into `Channel.drm` by `extractDrmFromRaw()` in
`libs/shared/m3u-utils` (hooked in `createPlaylistObject()`, covering all
import paths). DASH channels always play inline: `isDashChannel()` bypasses
the external-player setting (radio precedent) and routes Video.js/MPV/VLC/
embedded-MPV users to the HTML5 player via `playerOverride` (ArtPlayer keeps
ArtPlayer). Unsupported license types (Widevine/PlayReady — out of scope,
need the castLabs Electron fork) surface a DRM playback diagnostic instead
of crashing. ClearKey EME works in stock Electron. Engine:
`libs/ui/playback/src/lib/shaka-engine/`; details in
`docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback").
- External players: MPV, VLC (via IPC to Electron backend)
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux
x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy
engine` (restart required) or
`IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV
experiment flag): a per-session helper renders mpv offscreen (CGL on macOS,
EGL on Linux, WGL on Windows), publishes BGRA frames into a shm ring, and the
preload frame pump uploads them to
`<canvas data-embedded-mpv-frame>`. Shared `app-player-controls` owns the DOM
UI; native-view retains the legacy dock. On Linux, only
`iptvnator_mpv_helper` may link libmpv; Electron, its shipped libraries, the
addon, and frame reader must not. Pristine afterPack/unpacked layouts scan
Electron libraries recursively; extracted Snap payloads exclude only the
package-manager `lib/**` and `usr/lib/**` trees overlaid into the same root.
Every other directory remains recursive, and Electron-library symlinks still
fail closed. `electron-backend/native{,/**/*}` is excluded from `app.asar`;
`afterPack` alone owns the profile-normalized unpacked native tree, and
package checks reject every archived `/electron-backend/native/**` entry.
Packaged addon, frame-reader, and helper discovery uses only package-owned
`app.asar.unpacked` paths; cwd/dist candidates remain development-only.
Official x64 packages use three separate profiles:
DEB/RPM/Pacman depend on system libmpv plus the helper's direct
EGL/GL/GBM interfaces, AppImage/Snap bundle the pinned LGPL closure, and
Flatpak bundles the same closure. Flatpak is an isolated packaging pass and
keeps `iptvnator` as the real Electron ELF so Electron Builder's
`electron-wrapper` passes it directly to Zypak. Other Linux targets retain the
conditional `iptvnator` wrapper and `iptvnator.bin`. Mixed
Flatpak/non-Flatpak target sets fail before mutation. Exact system
dependencies are DEB=`libmpv2,libegl1,libgl1,libgbm1`,
RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and
Pacman=`mpv,libglvnd,mesa`. The DEB contract is verified on Ubuntu 24.04+;
Ubuntu 22.04 users need the x64 AppImage because Jammy provides `libmpv1`.
ARM packages are marker-only. Stored or explicit opt-ins cannot bypass the
fail-closed packaged manifest/file/hash gate and bounded `--runtime-probe`;
any failure keeps the sandbox enabled, records a stable reason, and falls
back to native-view without crashing. Snap is `core22`/strict and uses an
exact private `shared-memory` plug plus the `graphics-core22` content plug at
a real empty mode-0755 `$SNAP/graphics`, with external `mesa-core22` as the
default provider. Its only provider-data layouts bind `/usr/share/libdrm`
from `$SNAP/graphics/libdrm` and symlink `/usr/share/drirc.d` to
`$SNAP/graphics/drirc.d`. Installed-Snap CI requires controlled unavailable
status after disconnect, then reconnects and requires success. The helper
links `libGL.so.1`, and probe/playback share a sanitized loader environment
in which ambient audit, preload, library, graphics-driver, and shell-startup
overrides are removed; the validated private closure plus trusted host GL,
graphics-content, core22 base x64, and exact GNOME-platform roots have
explicit precedence. The core22 base stays ahead of GNOME so the older
`libedit.so.2` requiring `libtinfo.so.5` cannot shadow the base ABI. The
extracted-artifact verifier removes the identical unsafe loader/graphics/
shell set before direct helper smoke while preserving selectors such as
`LIBGL_ALWAYS_SOFTWARE`. Snap fixes the wrapper `PATH`,
removes exported `BASH_FUNC_*` functions, and
launches probe/playback through the regular executable
`$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or
disconnected provider returns `snap-graphics-provider-unavailable` before
helper spawn. The packaging-only
`--embedded-mpv-runtime-probe` app switch runs the complete packaged gate
before BrowserWindow startup and emits one availability JSON line. A nonzero
helper exit keeps top-level reason `helper-probe-failed`; `helperReason` is
present only for an exact protocol-v1 line carrying a fixed allowlisted
reason, and its optional `helperDetail` must be 1–1024 printable ASCII
characters. Invalid detail suppresses both helper fields. Every probe uses
an explicit 16 MiB aggregate captured-output ceiling independent of tracing.
With `IPTVNATOR_TRACE_PLAYER=1`, non-empty helper stderr is emitted separately
as one JSON-escaped stderr line with a 16,384-character `stderr` limit and an
explicit `truncated` field; trace-write failure cannot change availability.
Installed-Snap CI enables Mesa EGL/GL diagnostics through this bounded
channel. The exact packaged Flatpak `/app` context reconstructs only
Freedesktop Platform 24.08's immutable
`__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its CI smoke invokes that
application-level probe instead of the helper directly. The packaged x64
Playwright smoke runs its fixture-contract target first and passes Chromium
`--ignore-gpu-blocklist` so CI llvmpipe exposes WebGL2; this does not bypass
the runtime gate, and `--no-sandbox` remains root-only. Bundled Linux
packages carry hash-validated
`embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and `licenses/**`.
CI caches the staged runtime plus immutable source inputs, never finished
notices or the compliance tarball; it regenerates those notices and the
VCS-metadata-free `linux-frame-copy-runtime-sources.tar.xz` for the current
checkout while preserving the exact pinned six recursive libplacebo
submodule records. Each record is canonical `full-commit safe/path`;
clone-depth dependent `git describe` annotations are discarded and never
form part of the provenance identity. Its source index carries the globally sorted libplacebo
directory/file/symlink inventory; file hashes, sizes, executable bits, link
targets, aggregates, and canonical tree digest must match the trusted pinned
checkout. The archive has an exact member/type layout and its
`metadata/archive-sha256.txt` records must match the actual source archives.
Concatenated tar/xz streams are inspected past every end marker. Every
bundled x64 package manifest binds the final archive's SHA-256 and repository
revision; system and marker-only packages do not carry that binding. Snap
Store
publication runs only from a public `v*` GitHub release that already
contains the Snap assets and exactly one source archive. Before any upload,
the workflow hashes and checks the archive's exact member/type set and size
bounds, verifies its clean tag revision, pinned sources including the six
recursive submodule records and exact libplacebo tree digest, legal payload,
and exact released tooling, then performs bounded extraction and static
validation for every Snap. That public-release boundary independently
revalidates the exact strict `meta/snap.yaml` graphics/shared-memory
contract and enumerates `resources/app.asar`, rejecting any archived
`electron-backend/native/**` payload before publication. Its bounded ASAR
header reader uses only Node built-ins and released local tooling, so the
clean tag checkout does not require `node_modules`. Exactly one x64 Snap
must have matching
`sourceArchive` and `sourceRuntime`; any non-x64 Snap remains marker-only.
Checkout and artifact-transfer actions are pinned to full commits; checkout
does not persist credentials, and repository credentials are scoped to
download steps. A secretless verification job copies assets through
no-follow descriptors, checks them before and after inspection, writes an
exact receipt, fully reverifies a root-owned read-only snapshot, and
transfers only that data through the pinned artifact service while passing
the receipt digest separately through a job output. The dependent publish
job uses a bounded `ubuntu-latest` runner with no checkout or release-tag
code, verifies that digest plus the exact receipt, asset hashes, and
file-only layout, root-seals the data again, and installs Snapcraft directly.
Its final fixed shell step alone receives the Store credential, resolves no
PATH command, executes no released code, and exposes that credential only to
each exact
`/snap/bin/snapcraft upload --release=edge` process. Candidate/stable
promotion is manual after installed-Snap frame-copy and missing-runtime
fallback smoke; GitHub Actions never promotes automatically. On Windows,
package validation requires the exact MPV DLL named by the helper's PE import
table beside the executable.
Backend adapter:
`apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`;
shared-controls adapter:
`libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`;
helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime
contracts: `docs/architecture/embedded-mpv-native.md` and
`tools/embedded-mpv/README.md`.
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (`WebVideoSourceTracks` for HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via `setSubtitleTrack`), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports `playing`, so the engine's own caption menu keeps working. Mode selection is the optional `playbackStarted` probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (`subtitleTrack = -1`) rather than hiding, since `subtitleDisplay` would override the vendorLine truncated
- Shared web picture-in-picture stays inside that default-off rollout.
`PlayerController` exposes capability `pictureInPicture`, state
`pictureInPictureActive`/`canPictureInPicture`, and command
`togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard
element PiP from the adapter's attached video; shared ArtPlayer keeps vendor
`pip: false`, while preference-off native/vendor paths remain unchanged. The
capability-gated button sits before fullscreen and uses active enter/exit
semantics; entry is disabled until metadata, and the action is disabled while
an operation is pending. Embedded MPV reports capability/state false with a
no-op command and has no popup/mini-window.
- `WebVideoControlsAdapter` supplies its current video and binding generation to
`WebVideoPictureInPictureController`; the controller reads the video's
`ownerDocument`, while browser enter/leave events remain authoritative.
Exact-owner exit stays available if request support changes. Request/exit
invocation remains synchronous for user activation, one operation is
serialized, and binding generation plus exact video identity protects
replacement and teardown from stale completion. Video.js Tech reset and
ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a
retained target preserve PiP.
Standard PiP shows the browser/OS video surface without Angular control
chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP
keyboard shortcut, and Embedded MPV popup/native support are out of scope.
**VOD/Series Detail Pages (two-state layout)**:
- Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes)
- The inline player (`PortalInlinePlayerComponent`) renders a full-width **theater stage** (`.player-shell__viewport`): the 16:9 player is centered and letterboxed so the leftover on wide-short windows is always the stage's black background, never app surface. An opt-in `playerAmbientMode` setting (Settings → Playback, default off, built-in web players only) fills that leftover with a blurred, dimmed copy of the poster (YouTube "Ambient mode" style)
- For inline **series** playback on wide windows the stage instead docks the player left and shows an **"Up Next" episode rail** in the leftover column (`app-up-next-rail` in `libs/ui/playback/src/lib/portal-inline-player/`): rest of the current season plus next-season spillover, playing episode highlighted, watch-progress bars from playback positions; clicking plays inline via the host's episode flow (both Xtream and Stalker). Gated by the `playerUpNextRail` setting (default on, web players only) and a ≥320px leftover-width check via ResizeObserver — narrower windows keep the centered theater/ambient stage; movies and live never show the rail. The rail is opaque and sits on top of the ambient fill
- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`)
- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items: quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. This metadata lets the dashboard render the tracked S/E badge for VOD-backed series. Existing playback rows without it remain badge-less until the episode is played again.
- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream) or TMDB (Stalker)
- Dashboard hero/Continue Watching clicks for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Ordinary global-recent grid clicks remain detail-only.
- See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout")
**Radio Player**:
@@ -647,19 +838,21 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- XMLTV format support
- Background parsing in worker thread
- Stored in database for quick lookup
- Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list, Stalker ITV sidebar, global favorites) → "Map EPG channel" attaches it to an uploaded-XMLTV channel; stored in `epg_channel_mappings` keyed by the M3U lookup key or a playlist-scoped portal key (`xtream:{playlistId}:{id}` / `stalker:{playlistId}:{id}`, helpers in `libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog: `libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/`
**TMDB Metadata Enrichment** (opt-in):
- Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works
- "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (`CrossPortalSimilarService` in `libs/services`, batched `DB_MATCH_TITLES`, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation
- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`)
- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (`resolveEnrichmentSeasonNumber` in `libs/shared/interfaces/src/lib/season-marker.util.ts`)
- Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per session; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`)
- Series detail views show a TMDB production-status chip (`tmdb_status`, e.g. Ended / Returning) — TMDB sends `status` in English regardless of request language, so it is normalized to a token by `normalizeSeriesStatus` and rendered via `seriesStatusLabelKey` translations; person pages show `deathday` alongside `birthday`
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-credits.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`)
- Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts`
- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`)
- Match confidence: provider `tmdb_id` trusted fully; otherwise normalized-title + year (±1) search with a strict gate — no confident match means no enrichment
- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); the section also has a "check key" button and a cache panel (row count + payload size, with a clear button); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`)
- Match confidence: a provider `tmdb_id` is a strong hint, not gospel — its payload is weighed against the item (`assessProviderId`: title or year agrees → use it; both years known and incompatible → the search may take over; title-only mismatch → keep it, since TMDB localizes titles). A 404 marks the id dead (`badProviderId:<id>` row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment
- Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded)
- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`) or in-memory (PWA); localized via the app language setting
- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`, plus `DB_GET_TMDB_CACHE_STATS` / `DB_CLEAR_TMDB_METADATA` behind the settings cache panel) or in-memory (PWA); localized via the app language setting. Search-match lookup keys are versioned, and connection startup removes obsolete unversioned rows once through the `migration:tmdb-search-lookup-v2-cache-cleanup:v1` app-state marker.
- Service layer: `libs/services/src/lib/tmdb/`; store glue: `libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts` and `libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts` (hooked in `withStalkerSelection().setSelectedItem`)
- TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About
- See `docs/architecture/tmdb-metadata-enrichment.md`
@@ -671,7 +864,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
**Internationalization**:
- Uses `@ngx-translate` with 18 language files in `apps/web/src/assets/i18n/`
- Uses `@ngx-translate` with 19 language files in `apps/web/src/assets/i18n/`
## Development Notes
@@ -718,6 +911,9 @@ Build configurations in `apps/web/project.json`:
**Factory Pattern Implementation**:
The factory pattern ensures a single codebase works in both environments without conditional checks scattered throughout the application. All environment-specific logic is encapsulated in the service implementations.
**Build Commit In About**:
CI injects the git commit into `apps/web/src/environments/build-commit.ts` via `tools/build/inject-build-commit.mjs` (same placeholder pattern as the TMDB key inject); `Settings > About` then shows `"<version> (<short-sha>)"`. The semver version itself deliberately stays untouched — a `-sha` suffix would flip electron-updater into prerelease mode and leak into installer/artifact version fields. Local/dev builds keep the placeholder empty and show the plain version.
### Testing Strategy
- **Unit tests**: Jest with `jest-preset-angular` and `ng-mocks`
+2 -8
View File
@@ -33,6 +33,7 @@ The application is a cross-platform, open-source project built with Electron and
**Playback**
- Built-in HTML5 player (HLS.js or Video.js) with a resizable, resumable inline view
- Optional unified IPTVnator controls for HTML5, Video.js, and ArtPlayer, enabled in **Settings → Playback** _(experimental)_
- External players — MPV, VLC, and IINA on macOS (`mpv.app` / `VLC.app` bundle paths supported) _(desktop)_
- Embedded MPV — native mpv rendered inside the app window on macOS, Windows & Linux 🖥️ _(experimental · desktop)_
- Dedicated radio player for `radio="true"` streams 📻
@@ -66,7 +67,7 @@ The application is a cross-platform, open-source project built with Electron and
- Cross-platform desktop (Electron) and installable PWA
- Desktop auto-updater and mobile remote control _(desktop)_
- Docker self-hosting for the PWA + web backend
- 18 languages ([translation files](apps/web/src/assets/i18n/)), light & dark themes, and keyboard shortcuts
- 19 languages ([translation files](apps/web/src/assets/i18n/)), light & dark themes, and keyboard shortcuts
## Keyboard shortcuts
@@ -299,13 +300,6 @@ This redirects the SQLite database, Electron user data, and local config under
the given directory. Delete that directory whenever you want a fresh empty
state.
If you need to debug renderer freezes or GPU/compositor issues in Electron, you
can disable hardware acceleration for a run:
```
$ IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 pnpm run serve:backend
```
If you need startup diagnostics for a white screen or a frozen route, you can
also turn on opt-in Electron tracing. These logs are written to the Electron
terminal output so they still help when the renderer DevTools never open:
@@ -0,0 +1,27 @@
import { defineConfig } from '@playwright/test';
import baseConfig from './playwright.config';
export default defineConfig({
...baseConfig,
outputDir:
'../../dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke',
reporter: [
['list'],
[
'html',
{
outputFolder:
'../../dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke',
},
],
[
'json',
{
outputFile:
'../../dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke/results.json',
},
],
],
timeout: 120000,
webServer: [],
});
@@ -0,0 +1,13 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
fullyParallel: false,
reporter: [['list']],
testDir: './src',
testMatch: '**/*.performance.ts',
timeout: 30 * 60 * 1_000,
use: {
testIdAttribute: 'data-test-id',
},
workers: 1,
});
+47
View File
@@ -12,6 +12,53 @@
"e2e": {
"dependsOn": ["electron-backend:build-e2e"]
},
"test-performance-harness": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec tsx --test src/performance/*.spec.ts"
}
},
"benchmark-m3u-refresh-cancellation": {
"dependsOn": ["electron-backend:build-performance"],
"executor": "nx:run-commands",
"cache": false,
"parallelism": false,
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts"
}
},
"benchmark-m3u-import": {
"dependsOn": ["electron-backend:build-performance"],
"executor": "nx:run-commands",
"cache": false,
"parallelism": false,
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-import.performance.ts"
}
},
"packaged-frame-copy-smoke": {
"dependsOn": ["test-packaged-frame-copy-fixtures"],
"executor": "nx:run-commands",
"cache": false,
"outputs": [
"{workspaceRoot}/dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke",
"{workspaceRoot}/dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke"
],
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec playwright test --config=playwright.packaged.config.ts src/embedded-mpv-frame-copy-packaged.e2e.ts"
}
},
"test-packaged-frame-copy-fixtures": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec tsx --test src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
@@ -0,0 +1,277 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { Locator, Page } from '@playwright/test';
import {
addXtreamPortal,
closeElectronApp,
deleteSource,
expect,
launchElectronApp,
openSettings,
openSources,
openWorkspaceSection,
resetMockServers,
restartElectronApp,
sourceRowByTitle,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
/**
* 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).
*/
test.describe('Electron playlist backup round-trip', () => {
test('exports a backup and re-imports it with hidden categories restored', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const portalName = 'Backup Roundtrip Xtream';
const exportPath = join(dataDir, 'roundtrip-backup.json');
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, { name: portalName });
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
const targetCategory = await pickVisibleCategoryWithContent(
app.mainWindow
);
let dialog = await openManageCategoriesDialog(app.mainWindow);
await setManagedCategoryChecked(dialog, targetCategory.name, false);
await dialog
.getByRole('button', { name: 'Save', exact: true })
.click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
await expect(
sidebarCategoryById(app.mainWindow, targetCategory.id)
).toHaveCount(0);
// 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) => {
nativeDialog.showSaveDialog = async () => ({
canceled: false,
filePath: path,
});
}, exportPath);
await openSettings(app.mainWindow);
const backupSection = app.mainWindow.locator('#backup');
await backupSection
.getByRole('button', { name: 'Export', exact: true })
.click();
await expect(
app.mainWindow.getByText('Playlist backup exported.')
).toBeVisible({ timeout: 15000 });
// 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 xtreamEntry = manifest.playlists.find(
(entry) => entry.portalType === 'xtream'
);
const hiddenCategories =
xtreamEntry?.userState?.hiddenCategories ?? [];
expect(hiddenCategories.length).toBeGreaterThan(0);
expect(
hiddenCategories.every(
(hiddenCategory) =>
typeof hiddenCategory.xtreamId === 'number'
)
).toBe(true);
await openSources(app.mainWindow);
await deleteSource(app.mainWindow, portalName);
await expect(
sourceRowByTitle(app.mainWindow, portalName)
).toHaveCount(0);
// Import the exported file back through the settings flow; the
// renderer opens a browser file chooser for it.
await openSettings(app.mainWindow);
const fileChooserPromise =
app.mainWindow.waitForEvent('filechooser');
await backupSection
.getByRole('button', { name: 'Import', exact: true })
.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(exportPath);
await expect(
app.mainWindow.getByText(/Backup import finished: 1 imported/)
).toBeVisible({ timeout: 15000 });
// 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
// initialization in this session. A restart matches the primary
// restore workflow (fresh install) and forces a real re-import.
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
// Opening the restored portal re-imports content from the mock
// server; the pending restore state must hide the same category
// again.
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, portalName).first().click();
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
await expect(
sidebarCategoryById(app.mainWindow, targetCategory.id)
).toHaveCount(0);
// The restored hidden flag must live in the database itself,
// not only in the rendered sidebar state.
const restoredPlaylistId =
app.mainWindow.url().match(/xtreams\/([^/]+)/)?.[1] ?? '';
expect(restoredPlaylistId).not.toEqual('');
const restoredDbRows = await app.mainWindow.evaluate(
(playlistId) =>
(
window as unknown as {
electron: {
dbGetAllCategories: (
id: string,
type: string
) => Promise<
Array<{ name: string; hidden: boolean }>
>;
};
}
).electron.dbGetAllCategories(playlistId, 'live'),
restoredPlaylistId
);
expect(restoredDbRows.length).toBeGreaterThan(0);
expect(
restoredDbRows
.filter((row) => row.hidden)
.map((row) => row.name)
).toEqual([targetCategory.name]);
dialog = await openManageCategoriesDialog(app.mainWindow);
await dialog
.locator('input[type="search"]')
.fill(targetCategory.name);
const restoredRow = dialog.locator('.category-item').first();
await expect(restoredRow).toBeVisible({ timeout: 15000 });
await expect(
restoredRow.locator('mat-checkbox input')
).not.toBeChecked();
} finally {
await closeElectronApp(app);
}
});
});
async function openManageCategoriesDialog(page: Page): Promise<Locator> {
await page.getByRole('button', { name: 'Manage categories' }).click();
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible();
await expect(dialog.locator('.category-item').first()).toBeVisible({
timeout: 15000,
});
return dialog;
}
async function setManagedCategoryChecked(
dialog: Locator,
categoryName: string,
shouldBeChecked: boolean
): Promise<void> {
await dialog.locator('input[type="search"]').fill(categoryName);
const row = dialog.locator('.category-item').first();
await expect(row).toBeVisible({ timeout: 15000 });
const checkbox = row.locator('mat-checkbox input');
if (shouldBeChecked) {
await checkbox.check();
await expect(checkbox).toBeChecked();
} else {
await checkbox.uncheck();
await expect(checkbox).not.toBeChecked();
}
}
function sidebarCategoryById(page: Page, categoryId: string): Locator {
return page.locator(
`app-workspace-context-panel .category-item[data-category-id="${categoryId}"]`
);
}
async function pickVisibleCategoryWithContent(
page: Page
): Promise<{ id: string; name: string }> {
let picked: { id: string; name: string } | null = null;
await expect
.poll(
async () => {
const categories = page.locator(
'app-workspace-context-panel .category-item:visible'
);
const count = await categories.count();
for (let index = 0; index < count; index += 1) {
const category = categories.nth(index);
const id =
(
await category.getAttribute('data-category-id')
)?.trim() ?? '';
const name =
(
await category
.locator('.nav-item-label')
.textContent()
)?.trim() ?? '';
const countText =
(
await category.locator('.item-count').textContent()
)?.trim() ?? '';
if (id && name && (Number.parseInt(countText, 10) || 0) > 0) {
picked = { id, name };
return true;
}
}
picked = null;
return false;
},
{
message:
'No visible Xtream category with content was found in the sidebar.',
timeout: 15000,
}
)
.toBe(true);
return picked!;
}
@@ -0,0 +1,172 @@
import { createServer, Server } from 'http';
import { readFileSync } from 'fs';
import { basename, join } from 'path';
import {
channelItemByTitle,
closeElectronApp,
expect,
launchElectronApp,
LaunchedElectronApp,
openAddPlaylistDialog,
test,
waitForM3uCatalog,
workspaceRoot,
} from './electron-test-fixtures';
/**
* DASH + ClearKey playback in the real Electron runtime — the only automated
* proof that ClearKey EME works in the packaged `file://` renderer (secure
* context). Uses the shared offline fixtures from apps/web-e2e/src/fixtures.
*/
const FIXTURE_DIR = join(workspaceRoot, 'apps/web-e2e/src/fixtures/dash');
const CLEARKEY_KID = '00112233445566778899aabbccddeeff';
const CLEARKEY_KEY = 'ffeeddccbbaa99887766554433221100';
type DashFixtureServer = {
close: () => Promise<void>;
origin: string;
};
/** Serves the DASH fixture directory with HTTP Range support (Shaka fetches
* init segments and the sidx via byte ranges). */
async function startDashFixtureServer(): Promise<DashFixtureServer> {
const server: Server = createServer((request, response) => {
const pathname = (request.url ?? '').split('?')[0];
const fileName = basename(pathname);
let body: Buffer;
try {
body = readFileSync(join(FIXTURE_DIR, fileName));
} catch {
response.writeHead(404);
response.end('not found');
return;
}
const contentType = fileName.endsWith('.mpd')
? 'application/dash+xml'
: 'video/mp4';
const range = /bytes=(\d+)-(\d+)?/.exec(
request.headers.range ?? ''
);
if (!range) {
response.writeHead(200, {
'Content-Type': contentType,
'Accept-Ranges': 'bytes',
'Content-Length': body.length,
});
response.end(body);
return;
}
const start = Number(range[1]);
const end = range[2] ? Number(range[2]) : body.length - 1;
const chunk = body.subarray(start, end + 1);
response.writeHead(206, {
'Content-Type': contentType,
'Accept-Ranges': 'bytes',
'Content-Range': `bytes ${start}-${end}/${body.length}`,
'Content-Length': chunk.length,
});
response.end(chunk);
});
await new Promise<void>((resolvePromise, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
resolvePromise();
});
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to resolve dash fixture server address.');
}
return {
origin: `http://127.0.0.1:${address.port}`,
close: () =>
new Promise<void>((resolvePromise, reject) => {
server.close((error) =>
error ? reject(error) : resolvePromise()
);
}),
};
}
function buildDashPlaylist(origin: string): string {
return [
'#EXTM3U',
'#EXTINF:-1 tvg-id="ck-dash" group-title="DASH",ClearKey DASH',
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
`#KODIPROP:inputstream.adaptive.license_key=${CLEARKEY_KID}:${CLEARKEY_KEY}`,
`${origin}/clearkey.mpd`,
'#EXTINF:-1 tvg-id="wv-dash" group-title="DASH",Widevine DASH',
'#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha',
'#KODIPROP:inputstream.adaptive.license_key=https://license.example.com/wv',
`${origin}/clearkey.mpd`,
].join('\n');
}
async function importDashPlaylistFromText(
app: LaunchedElectronApp,
playlist: string
): Promise<void> {
await openAddPlaylistDialog(app.mainWindow);
const dialog = app.mainWindow.locator('mat-dialog-container').last();
await dialog.getByRole('radio', { name: /Raw m3u text/i }).click();
await dialog.locator('textarea').fill(playlist);
await dialog.getByRole('button', { name: 'Import', exact: true }).click();
await dialog.waitFor({ state: 'detached' });
await waitForM3uCatalog(app.mainWindow);
}
test('@electron @dash ClearKey DASH plays inline and unsupported DRM surfaces a diagnostic', async ({
dataDir,
}) => {
const fixtureServer = await startDashFixtureServer();
const app = await launchElectronApp(dataDir);
try {
await importDashPlaylistFromText(
app,
buildDashPlaylist(fixtureServer.origin)
);
// Happy path: ClearKey EME decrypts and playback advances.
await channelItemByTitle(app.mainWindow, 'ClearKey DASH')
.first()
.click();
const video = app.mainWindow
.locator('app-web-player-view video')
.first();
await expect(video).toBeVisible({ timeout: 15_000 });
await expect
.poll(
() =>
video.evaluate(
(element: HTMLVideoElement) => element.currentTime
),
{ timeout: 20_000 }
)
.toBeGreaterThan(0.5);
await expect(
app.mainWindow.getByTestId('playback-diagnostic-banner')
).toBeHidden();
// Negative: an unsupported license type must not crash — it shows the
// DRM diagnostic instead.
await channelItemByTitle(app.mainWindow, 'Widevine DASH')
.first()
.click();
const banner = app.mainWindow.getByTestId(
'playback-diagnostic-banner'
);
await expect(banner).toBeVisible({ timeout: 15_000 });
await expect(banner).toContainText(/encrypted or DRM-protected/i);
} finally {
await closeElectronApp(app);
await fixtureServer.close();
}
});
@@ -0,0 +1,122 @@
import type { ElectronApplication, Page } from '@playwright/test';
import {
closeElectronApp,
expect,
launchElectronApp,
test,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import {
installMainCapture,
rolloverMainCapture,
startMainCapture,
stopMainCapture,
} from './performance/m3u-refresh-main-capture';
import type { MainCaptureMetrics } from './performance/m3u-refresh-cancellation-contract';
import type { RendererWindowIdentity } from './performance/renderer-window-rss-session';
test('captures the exact database worker post-GC heap in built Electron', async ({
dataDir,
}) => {
const launchedApps: LaunchedElectronApp[] = [];
try {
const app = await launchElectronApp(dataDir, {
args: ['--js-flags=--expose-gc'],
env: {
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0',
IPTVNATOR_PERF_CAPTURE: '1',
IPTVNATOR_PERF_WORKER_PROFILING: '1',
},
});
launchedApps.push(app);
await installMainCapture(app.electronApp);
const rendererWindowIdentity = await resolveRendererWindowIdentity(
app.electronApp,
app.mainWindow
);
const captureOptions = {
diagnostic: false,
outputDirectory: dataDir,
rendererWindowIdentity,
} as const;
await startMainCapture(app.electronApp, captureOptions);
await requestDatabaseWorker(app);
const rollover = await rolloverMainCapture(
app.electronApp,
captureOptions
);
expect(rollover.nextCaptureStarted).toBe(true);
expect(rollover.nextCaptureUnavailableReason).toBeNull();
const firstDatabaseWorker = expectExactDatabaseWorker(
rollover.completedCapture
);
await requestDatabaseWorker(app);
const secondDatabaseWorker = expectExactDatabaseWorker(
await stopMainCapture(app.electronApp)
);
expect(secondDatabaseWorker.ordinal).toBe(firstDatabaseWorker.ordinal);
await startMainCapture(app.electronApp, captureOptions);
const noDatabasePhase = await stopMainCapture(app.electronApp);
expect(
noDatabasePhase.workers.filter(
(worker) => worker.kind === 'database.worker'
)
).toHaveLength(0);
} finally {
await Promise.all(launchedApps.map((app) => closeElectronApp(app)));
}
});
async function requestDatabaseWorker(app: LaunchedElectronApp): Promise<void> {
await app.mainWindow.evaluate(() => window.electron.dbGetAppPlaylists());
}
function expectExactDatabaseWorker(capture: MainCaptureMetrics) {
const databaseWorkers = capture.workers.filter(
(worker) => worker.kind === 'database.worker'
);
expect(databaseWorkers).toHaveLength(1);
const databaseWorker = databaseWorkers[0];
expect(databaseWorker?.ordinal).toBeGreaterThan(0);
expect(databaseWorker?.postGcHeapUnavailableReason).toBeNull();
expect(databaseWorker?.postGcHeapUsedBytes).toBeGreaterThan(0);
expect(databaseWorker?.requests).toHaveLength(1);
expect(databaseWorker?.requests[0]).toEqual(
expect.objectContaining({
invalidReason: null,
operation: 'DB_GET_APP_PLAYLISTS',
performanceCaptureUnavailableReason: null,
playlistId: null,
requestId: expect.any(String),
success: true,
})
);
return databaseWorker;
}
async function resolveRendererWindowIdentity(
electronApp: ElectronApplication,
page: Page
): Promise<RendererWindowIdentity> {
const browserWindowHandle = await electronApp.browserWindow(page);
try {
return await browserWindowHandle.evaluate((browserWindow) => {
const exactWindow = browserWindow as unknown as {
readonly id: number;
readonly webContents: { readonly id: number };
};
return {
browserWindowId: exactWindow.id,
webContentsId: exactWindow.webContents.id,
};
});
} finally {
await browserWindowHandle.dispose();
}
}
+187 -1
View File
@@ -1,4 +1,6 @@
import { mkdirSync, readdirSync } from 'fs';
import { mkdirSync, readdirSync, readFileSync, statSync } from 'fs';
import { createServer } from 'http';
import type { AddressInfo } from 'net';
import { join } from 'path';
import type { Page } from '@playwright/test';
import {
@@ -17,6 +19,90 @@ async function openDownloadsPage(page: Page): Promise<void> {
await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/);
}
interface RangeServerRequest {
ifRange?: string;
range?: string;
}
interface ThrottledRangeServer {
close: () => Promise<void>;
payload: Buffer;
requests: RangeServerRequest[];
url: string;
}
const RANGE_SERVER_ETAG = '"e2e-range-etag"';
/**
* Serves a payload slowly on the first (full) request so the UI has a wide
* window to pause mid-transfer, and answers Range requests with an immediate
* 206 so the resumed transfer finishes fast. Records Range/If-Range headers.
*/
async function createThrottledRangeServer(): Promise<ThrottledRangeServer> {
const payload = Buffer.from(
Array.from({ length: 96 * 1024 }, (_, index) =>
String(index % 10)
).join('')
);
const requests: RangeServerRequest[] = [];
const server = createServer((req, res) => {
const range = req.headers.range;
const ifRange = req.headers['if-range'];
requests.push({
ifRange: typeof ifRange === 'string' ? ifRange : undefined,
range: typeof range === 'string' ? range : undefined,
});
const offset = range
? Number(/^bytes=(\d+)-$/.exec(range)?.[1] ?? Number.NaN)
: 0;
if (range && Number.isFinite(offset)) {
res.writeHead(206, {
'Content-Length': payload.length - offset,
'Content-Range': `bytes ${offset}-${payload.length - 1}/${payload.length}`,
'Content-Type': 'video/mp4',
ETag: RANGE_SERVER_ETAG,
});
res.end(payload.subarray(offset));
return;
}
res.writeHead(200, {
'Content-Length': payload.length,
'Content-Type': 'video/mp4',
ETag: RANGE_SERVER_ETAG,
});
// First 16 KiB immediately, then a trickle: the transfer stays alive
// for tens of seconds unless it is paused or resumed via Range.
let sent = 16 * 1024;
res.write(payload.subarray(0, sent));
const timer = setInterval(() => {
if (sent >= payload.length) {
clearInterval(timer);
res.end();
return;
}
res.write(payload.subarray(sent, sent + 2 * 1024));
sent += 2 * 1024;
}, 150);
res.on('close', () => clearInterval(timer));
});
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve)
);
const { port } = server.address() as AddressInfo;
return {
close: () =>
new Promise<void>((resolve) => server.close(() => resolve())),
payload,
requests,
url: `http://127.0.0.1:${port}/media/e2e-pause-movie.mp4`,
};
}
/**
* On a cold profile the renderer can query SQLite while the DB worker is
* still creating tables ("database is locked" / "no such table" on slow CI
@@ -163,4 +249,104 @@ test.describe('Electron Downloads', () => {
await fileServer.close();
}
});
test('@downloads @electron pauses a download, retains the partial, and resumes it with an HTTP Range request', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const rangeServer = await createThrottledRangeServer();
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: 'Pause Portal',
username: 'user1',
password: 'pass1',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openDownloadsPage(app.mainWindow);
const downloadsDir = join(dataDir, 'e2e-pause-downloads');
mkdirSync(downloadsDir, { recursive: true });
await app.electronApp.evaluate(({ dialog }, folder) => {
dialog.showOpenDialog = async () =>
({
canceled: false,
filePaths: [folder],
}) as Awaited<ReturnType<typeof dialog.showOpenDialog>>;
}, downloadsDir);
await app.mainWindow
.getByRole('button', { name: 'Change Folder' })
.click();
await expect(
app.mainWindow.locator('.downloads__folder-inline-path')
).toContainText('e2e-pause-downloads');
const startResult = await app.mainWindow.evaluate(
async ({ url, folder }) =>
window.electron?.downloadsStart?.({
playlistId: 'e2e-playlist',
xtreamId: 7373,
contentType: 'vod',
title: 'E2E Pause Movie',
url,
downloadFolder: folder,
}),
{ url: rangeServer.url, folder: downloadsDir }
);
expect(startResult?.error ?? null).toBeNull();
const item = app.mainWindow.locator('.downloads__item');
await expect(item).toHaveCount(1, { timeout: 20000 });
await expect(item.locator('.downloads__item-status')).toContainText(
'Downloading',
{ timeout: 20000 }
);
// Pause mid-transfer: the row flips to Paused and only a .part
// file exists on disk (final file absent, progress retained).
await item
.getByRole('button')
.filter({ hasText: 'pause' })
.click();
await expect(item.locator('.downloads__item-status')).toContainText(
'Paused',
{ timeout: 20000 }
);
const pausedFiles = readdirSync(downloadsDir);
expect(pausedFiles).toEqual(['E2E Pause Movie.mp4.part']);
const pausedBytes = statSync(
join(downloadsDir, 'E2E Pause Movie.mp4.part')
).size;
expect(pausedBytes).toBeGreaterThan(0);
expect(pausedBytes).toBeLessThan(rangeServer.payload.length);
// Resume: the runtime must continue via Range/If-Range instead of
// restarting, and the assembled file must match the payload.
await item
.getByRole('button')
.filter({ hasText: 'play_arrow' })
.click();
await expect(item.locator('.downloads__item-status')).toContainText(
'Completed',
{ timeout: 30000 }
);
const resumeRequest = rangeServer.requests.find(
(entry) => entry.range
);
expect(resumeRequest?.range).toMatch(/^bytes=\d+-$/);
expect(resumeRequest?.ifRange).toBe(RANGE_SERVER_ETAG);
expect(readdirSync(downloadsDir)).toEqual(['E2E Pause Movie.mp4']);
const finalFile = readFileSync(
join(downloadsDir, 'E2E Pause Movie.mp4')
);
expect(finalFile.equals(rangeServer.payload)).toBe(true);
} finally {
await closeElectronApp(app);
await rangeServer.close();
}
});
});
@@ -9,14 +9,18 @@ import {
} from '@playwright/test';
import { createServer, Server } from 'http';
import {
accessSync,
constants as fsConstants,
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
import { dirname, join, resolve } from 'path';
export const workspaceRoot = resolve(__dirname, '../../..');
export const electronMainPath = join(
@@ -70,7 +74,8 @@ type ElectronFixtures = {
dataDir: string;
};
type LaunchElectronAppOptions = {
export type LaunchElectronAppOptions = {
args?: readonly string[];
env?: Record<string, string | undefined>;
};
@@ -138,7 +143,7 @@ export async function launchElectronApp(
}
assertPackagedRendererBuildIsElectronSafe();
const args = [electronMainPath];
const args = [...(options.args ?? []), electronMainPath];
if (process.platform === 'linux' && process.env['CI']) {
args.unshift('--no-sandbox', '--disable-gpu');
@@ -171,7 +176,142 @@ export async function launchElectronApp(
};
}
function attachElectronProcessDiagnostics(electronApp: ElectronApplication): void {
/**
* Resolve the x64 unpacked Linux executable produced by electron-builder.
* An explicit path wins so CI can point at an AppImage/Flatpak extraction
* without relying on electron-builder's local output directory names.
*/
export function resolvePackagedLinuxExecutable(
explicitPath = process.env['IPTVNATOR_E2E_PACKAGED_EXECUTABLE']
): string | undefined {
if (explicitPath?.trim()) {
return resolve(explicitPath.trim());
}
const executablesRoot = join(workspaceRoot, 'dist', 'executables');
if (!existsSync(executablesRoot)) {
return undefined;
}
const unpackedDirectories = readdirSync(executablesRoot, {
withFileTypes: true,
})
.filter(
(entry) =>
entry.isDirectory() &&
entry.name.startsWith('linux') &&
entry.name.endsWith('-unpacked') &&
!entry.name.includes('arm')
)
.sort((left, right) => {
const leftPriority = left.name === 'linux-unpacked' ? 0 : 1;
const rightPriority = right.name === 'linux-unpacked' ? 0 : 1;
return (
leftPriority - rightPriority ||
left.name.localeCompare(right.name)
);
});
for (const directory of unpackedDirectories) {
for (const executableName of ['IPTVnator', 'iptvnator']) {
const candidate = join(
executablesRoot,
directory.name,
executableName
);
try {
accessSync(candidate, fsConstants.X_OK);
if (statSync(candidate).isFile()) {
return candidate;
}
} catch {
// Keep looking for the next unpacked x64 layout.
}
}
}
return undefined;
}
export function getPackagedLinuxNativeDir(executablePath: string): string {
return join(
dirname(resolve(executablePath)),
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
}
export function resolvePackagedElectronLaunchArgs(
getuid: (() => number) | undefined
): string[] {
const args = ['--ignore-gpu-blocklist'];
if (typeof getuid === 'function' && getuid() === 0) {
args.push('--no-sandbox');
}
return args;
}
/**
* Launch a real packaged Linux executable. Unlike the regular source E2E
* launcher, this deliberately keeps Chromium's GPU path enabled and ignores
* its GPU blocklist: the frame-copy smoke sets LIBGL_ALWAYS_SOFTWARE=1, and
* CI's llvmpipe WebGL2 context must prove that the shared-memory frame reaches
* the renderer canvas.
*/
export async function launchPackagedElectronApp(
executablePath: string,
dataDir: string,
options: LaunchElectronAppOptions = {}
): Promise<LaunchedElectronApp> {
if (process.platform !== 'linux') {
throw new Error(
'The packaged embedded-MPV launcher is available on Linux only.'
);
}
const resolvedExecutablePath = resolve(executablePath);
try {
accessSync(resolvedExecutablePath, fsConstants.X_OK);
if (!statSync(resolvedExecutablePath).isFile()) {
throw new Error('not a regular file');
}
} catch (error) {
throw new Error(
`Packaged Linux executable is not a regular executable file at ${resolvedExecutablePath}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
const electronApp = await electron.launch({
executablePath: resolvedExecutablePath,
args: resolvePackagedElectronLaunchArgs(process.getuid),
env: {
...process.env,
IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS:
process.env['IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS'] ?? '1',
...options.env,
ELECTRON_IS_DEV: '0',
IPTVNATOR_E2E_DATA_DIR: dataDir,
NODE_ENV: 'test',
},
});
attachElectronProcessDiagnostics(electronApp);
const mainWindow = await findMainWindow(electronApp);
await waitForAppReady(mainWindow);
return {
electronApp,
mainWindow,
};
}
function attachElectronProcessDiagnostics(
electronApp: ElectronApplication
): void {
if (!process.env['CI']) {
return;
}
@@ -235,10 +375,7 @@ async function waitForPromiseWithTimeout(
return await Promise.race([
promise.then(() => true),
new Promise<boolean>((resolvePromise) => {
timeoutId = setTimeout(
() => resolvePromise(false),
timeoutMs
);
timeoutId = setTimeout(() => resolvePromise(false), timeoutMs);
}),
]);
} finally {
@@ -297,11 +434,11 @@ async function waitForAppReady(page: Page): Promise<void> {
} catch (error) {
const diagnostics = await page.evaluate(() => ({
appRootLength:
document.querySelector('app-root')?.innerHTML.trim().length ?? 0,
document.querySelector('app-root')?.innerHTML.trim().length ??
0,
baseHref:
document
.querySelector('base')
?.getAttribute('href') ?? '<missing>',
document.querySelector('base')?.getAttribute('href') ??
'<missing>',
readyState: document.readyState,
title: document.title,
url: location.href,
@@ -357,7 +494,7 @@ export async function importM3uPlaylistFromNativeDialog(
const fileInput = dialog.locator('input[type="file"][name="playlist"]');
await fileInput.evaluate((element, selectedFilePath) => {
(element as HTMLInputElement).dataset.filePathOverride =
(element as HTMLInputElement).dataset['filePathOverride'] =
selectedFilePath;
}, filePath);
await fileInput.setInputFiles(filePath);
@@ -501,15 +638,17 @@ async function clickDialogMethodOption(
label: RegExp,
legacySelector?: string
): Promise<void> {
const optionByRadio = dialog
.getByRole('radio', { name: label })
.first();
const optionByRadio = dialog.getByRole('radio', { name: label }).first();
if ((await optionByRadio.count()) > 0) {
await optionByRadio.click();
return;
}
for (const tablistLabel of ['Source method', 'Playlist category', 'M3U source']) {
for (const tablistLabel of [
'Source method',
'Playlist category',
'M3U source',
]) {
const tablist = dialog
.locator(`[role="tablist"][aria-label="${tablistLabel}"]`)
.first();
@@ -541,9 +680,7 @@ async function clickDialogMethodOption(
}
if (!legacySelector) {
throw new Error(
`Could not find dialog option matching ${label}.`
);
throw new Error(`Could not find dialog option matching ${label}.`);
}
await dialog.locator(legacySelector).click();
@@ -632,8 +769,23 @@ export async function enableRemoteControl(
export async function saveSettings(page: Page): Promise<void> {
const saveButton = page.getByTestId('save-settings');
await saveButton.click();
// The save control is a native form submit (`<button type="submit">`
// inside `<form (ngSubmit)="onSubmit()">`). Clicking it makes Chromium
// register a form-submission navigation, which Angular's `ngSubmit`
// handler immediately cancels via `preventDefault()` — no real navigation
// ever happens. Playwright's default post-click "wait for signals" barrier
// still observes that requested-then-cancelled navigation and waits for it
// to settle; on slow/loaded CI runners that wait can stall for the full
// timeout ("waiting for scheduled navigations to finish"). We never depend
// on a navigation here, so opt out of the barrier and instead assert the
// deterministic post-save state below.
await saveButton.click({ noWaitAfter: true });
// `onSubmit()` calls `applyChangedSettings()` -> `markAsPristine()` once the
// settings write resolves, which disables the button. Awaiting that is a
// stronger, race-free confirmation that the save actually committed.
await expect(saveButton).toBeDisabled();
// Let the fire-and-forget `window.electron.updateSettings(...)` IPC flush to
// the main process before callers may relaunch the app to assert persistence.
await page.waitForTimeout(300);
}
@@ -689,9 +841,7 @@ export function buildM3uContent(channels: M3uTestChannel[]): string {
const attributes = [
channel.tvgId ? `tvg-id="${channel.tvgId}"` : '',
channel.tvgCountry ? `tvg-country="${channel.tvgCountry}"` : '',
channel.tvgLanguage
? `tvg-language="${channel.tvgLanguage}"`
: '',
channel.tvgLanguage ? `tvg-language="${channel.tvgLanguage}"` : '',
channel.tvgName ? `tvg-name="${channel.tvgName}"` : '',
channel.logo ? `tvg-logo="${channel.logo}"` : '',
channel.groupTitle ? `group-title="${channel.groupTitle}"` : '',
@@ -874,9 +1024,7 @@ export async function switchUnifiedCollectionContent(
await clickButtonToggleOption(toggleGroup, contentLabel);
}
export async function clearCurrentUnifiedCollection(
page: Page
): Promise<void> {
export async function clearCurrentUnifiedCollection(page: Page): Promise<void> {
await page
.getByRole('button', {
name: /Clear .* (favorites|recently viewed)/i,
@@ -1449,6 +1597,15 @@ export async function expectWorkspaceSearchStatus(
).toHaveText(expected);
}
/** The degraded-search hint chip must be absent (e.g. complete local search). */
export async function expectNoWorkspaceSearchStatus(
page: Page
): Promise<void> {
await expect(
page.locator('app-workspace-shell-header .search-chip--status')
).toHaveCount(0);
}
async function startPortalDebugCapture(page: Page): Promise<void> {
await page.evaluate(() => {
const electronApi = window.electron as typeof window.electron & {
@@ -0,0 +1,288 @@
import assert = require('node:assert/strict');
import {
chmodSync,
existsSync,
lstatSync,
mkdtempSync,
mkdirSync,
readFileSync,
readlinkSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, it } from 'node:test';
import {
createDisposablePackagedLinuxApp,
createPackagedEntryGuard,
readPackagedRuntimeIdentity,
} from './embedded-mpv-frame-copy-packaged-filesystem';
const temporaryDirectories = new Set<string>();
type PackageFixture = {
executablePath: string;
libmpvAliasPath: string;
libmpvSonamePath: string;
nativeDir: string;
packageRoot: string;
payloadPath: string;
runtimeManifestPath: string;
};
function createPackageFixture(): PackageFixture {
const packageRoot = mkdtempSync(
join(tmpdir(), 'iptvnator-packaged-fixture-source-')
);
temporaryDirectories.add(packageRoot);
const executablePath = join(packageRoot, 'IPTVnator');
const nativeDir = join(
packageRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
const payloadPath = join(packageRoot, 'resources', 'payload.bin');
const runtimeManifestPath = join(nativeDir, 'embedded-mpv-runtime.json');
const runtimeLibraryDir = join(nativeDir, 'lib');
const libmpvSonamePath = join(runtimeLibraryDir, 'libmpv.so.2');
const libmpvAliasPath = join(runtimeLibraryDir, 'libmpv.so');
mkdirSync(runtimeLibraryDir, { recursive: true });
writeFileSync(executablePath, '#!/bin/sh\nexit 0\n');
chmodSync(executablePath, 0o755);
writeFileSync(payloadPath, 'packaged payload');
chmodSync(payloadPath, 0o640);
writeFileSync(libmpvSonamePath, 'packaged libmpv');
symlinkSync('libmpv.so.2', libmpvAliasPath);
writeFileSync(
runtimeManifestPath,
JSON.stringify({
arch: 'x64',
libmpvSoname: 'libmpv.so.2',
platform: 'linux',
profile: 'portable',
runtimeMode: 'bundled',
})
);
if (process.platform !== 'win32') {
symlinkSync(
'payload.bin',
join(packageRoot, 'resources', 'payload-link')
);
}
return {
executablePath,
libmpvAliasPath,
libmpvSonamePath,
nativeDir,
packageRoot,
payloadPath,
runtimeManifestPath,
};
}
function entryExists(entryPath: string): boolean {
try {
lstatSync(entryPath);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
throw error;
}
}
afterEach(() => {
for (const directory of temporaryDirectories) {
rmSync(directory, { force: true, recursive: true });
}
temporaryDirectories.clear();
});
describe('disposable unpacked package clone', () => {
it('requires a safe versioned libmpv target in the packaged manifest', () => {
const source = createPackageFixture();
assert.equal(
readPackagedRuntimeIdentity(source.nativeDir).libmpvSoname,
'libmpv.so.2'
);
writeFileSync(
source.runtimeManifestPath,
JSON.stringify({
arch: 'x64',
libmpvSoname: '../libmpv.so.2',
platform: 'linux',
profile: 'portable',
runtimeMode: 'bundled',
})
);
assert.throws(
() => readPackagedRuntimeIdentity(source.nativeDir),
/libmpvSoname/
);
});
it('hides and restores a cloned regular dependency without changing the source package', () => {
const source = createPackageFixture();
const clone = createDisposablePackagedLinuxApp(source.executablePath);
temporaryDirectories.add(clone.temporaryRoot);
const clonedLibmpvPath = join(clone.nativeDir, 'lib', 'libmpv.so.2');
const guard = createPackagedEntryGuard(clonedLibmpvPath, {
expectedKind: 'regular-file',
hiddenDirectory: clone.temporaryRoot,
});
try {
assert.equal(lstatSync(clonedLibmpvPath).isFile(), true);
assert.equal(
statSync(clonedLibmpvPath).ino,
statSync(source.libmpvSonamePath).ino
);
guard.hide();
assert.equal(entryExists(clonedLibmpvPath), false);
assert.equal(lstatSync(source.libmpvSonamePath).isFile(), true);
assert.equal(
readFileSync(source.libmpvSonamePath, 'utf8'),
'packaged libmpv'
);
guard.restore();
assert.equal(lstatSync(clonedLibmpvPath).isFile(), true);
assert.equal(
statSync(clonedLibmpvPath).ino,
statSync(source.libmpvSonamePath).ino
);
} finally {
guard.restore();
clone.cleanup();
temporaryDirectories.delete(clone.temporaryRoot);
}
assert.equal(entryExists(source.libmpvSonamePath), true);
});
it('hides and restores a cloned symbolic link without dereferencing it', () => {
const source = createPackageFixture();
const clone = createDisposablePackagedLinuxApp(source.executablePath);
temporaryDirectories.add(clone.temporaryRoot);
const clonedAliasPath = join(clone.nativeDir, 'lib', 'libmpv.so');
const guard = createPackagedEntryGuard(clonedAliasPath, {
expectedKind: 'symbolic-link',
hiddenDirectory: clone.temporaryRoot,
});
try {
guard.hide();
assert.equal(entryExists(clonedAliasPath), false);
assert.equal(
lstatSync(source.libmpvAliasPath).isSymbolicLink(),
true
);
assert.equal(readlinkSync(source.libmpvAliasPath), 'libmpv.so.2');
guard.restore();
assert.equal(lstatSync(clonedAliasPath).isSymbolicLink(), true);
assert.equal(readlinkSync(clonedAliasPath), 'libmpv.so.2');
} finally {
guard.restore();
clone.cleanup();
temporaryDirectories.delete(clone.temporaryRoot);
}
});
it('hardlinks regular files and preserves modes, symlinks, and the source manifest', () => {
const source = createPackageFixture();
const clone = createDisposablePackagedLinuxApp(source.executablePath);
temporaryDirectories.add(clone.temporaryRoot);
try {
assert.notEqual(clone.packageRoot, source.packageRoot);
assert.equal(
statSync(clone.executablePath).mode & 0o777,
statSync(source.executablePath).mode & 0o777
);
const clonedPayloadPath = join(
clone.packageRoot,
'resources',
'payload.bin'
);
assert.equal(
statSync(clonedPayloadPath).ino,
statSync(source.payloadPath).ino
);
assert.equal(statSync(clonedPayloadPath).mode & 0o777, 0o640);
if (process.platform !== 'win32') {
const clonedLinkPath = join(
clone.packageRoot,
'resources',
'payload-link'
);
assert.equal(lstatSync(clonedLinkPath).isSymbolicLink(), true);
assert.equal(readlinkSync(clonedLinkPath), 'payload.bin');
}
const clonedRuntimeManifestPath = join(
clone.nativeDir,
'embedded-mpv-runtime.json'
);
assert.equal(existsSync(clonedRuntimeManifestPath), true);
assert.equal(existsSync(source.runtimeManifestPath), true);
} finally {
clone.cleanup();
temporaryDirectories.delete(clone.temporaryRoot);
}
assert.equal(existsSync(clone.temporaryRoot), false);
assert.equal(existsSync(source.runtimeManifestPath), true);
});
it('copies a regular file when hardlinking is unavailable', () => {
const source = createPackageFixture();
const clone = createDisposablePackagedLinuxApp(source.executablePath, {
linkFile() {
throw Object.assign(new Error('cross-device hardlink'), {
code: 'EXDEV',
});
},
});
temporaryDirectories.add(clone.temporaryRoot);
try {
assert.equal(statSync(clone.executablePath).mode & 0o777, 0o755);
const clonedPayloadPath = join(
clone.packageRoot,
'resources',
'payload.bin'
);
assert.equal(
readFileSync(clonedPayloadPath, 'utf8'),
readFileSync(source.payloadPath, 'utf8')
);
assert.notEqual(
statSync(clonedPayloadPath).ino,
statSync(source.payloadPath).ino
);
assert.equal(statSync(clonedPayloadPath).mode & 0o777, 0o640);
} finally {
clone.cleanup();
temporaryDirectories.delete(clone.temporaryRoot);
}
});
});
@@ -0,0 +1,255 @@
import {
chmodSync,
copyFileSync,
linkSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
readlinkSync,
renameSync,
rmSync,
symlinkSync,
type Stats,
} from 'fs';
import { tmpdir } from 'os';
import { basename, dirname, join, resolve } from 'path';
import { getPackagedLinuxNativeDir } from './electron-test-fixtures';
export type DisposablePackagedLinuxApp = {
cleanup: () => void;
executablePath: string;
nativeDir: string;
packageRoot: string;
temporaryRoot: string;
};
export type DisposablePackagedLinuxAppOptions = {
linkFile?: (existingPath: string, newPath: string) => void;
};
export type PackagedRuntimeIdentity = {
arch: string;
libmpvSoname: string;
platform: string;
profile: string;
runtimeMode: string;
};
export type PackagedEntryKind = 'regular-file' | 'symbolic-link';
export type PackagedEntryGuard = {
hide: () => void;
restore: () => void;
};
export type PackagedEntryGuardOptions = {
expectedKind: PackagedEntryKind;
hiddenDirectory: string;
};
const HARDLINK_COPY_FALLBACK_CODES = new Set([
'EACCES',
'EMLINK',
'ENOSYS',
'ENOTSUP',
'EPERM',
'EXDEV',
]);
const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/;
function entryKindMatches(
stats: Stats,
expectedKind: PackagedEntryKind
): boolean {
return expectedKind === 'regular-file'
? stats.isFile() && !stats.isSymbolicLink()
: stats.isSymbolicLink();
}
function entryExistsByLstat(entryPath: string): boolean {
try {
lstatSync(entryPath);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
throw error;
}
}
function clonePackagedEntry(
sourcePath: string,
destinationPath: string,
linkFile: (existingPath: string, newPath: string) => void
): void {
const sourceStats = lstatSync(sourcePath);
if (sourceStats.isDirectory()) {
mkdirSync(destinationPath);
for (const entry of readdirSync(sourcePath)) {
clonePackagedEntry(
join(sourcePath, entry),
join(destinationPath, entry),
linkFile
);
}
chmodSync(destinationPath, sourceStats.mode & 0o7777);
return;
}
if (sourceStats.isSymbolicLink()) {
symlinkSync(readlinkSync(sourcePath), destinationPath);
return;
}
if (!sourceStats.isFile()) {
throw new Error(
`Unsupported packaged runtime entry type at ${sourcePath}.`
);
}
try {
linkFile(sourcePath, destinationPath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (!code || !HARDLINK_COPY_FALLBACK_CODES.has(code)) {
throw error;
}
copyFileSync(sourcePath, destinationPath);
chmodSync(destinationPath, sourceStats.mode & 0o7777);
}
}
export function createDisposablePackagedLinuxApp(
executablePath: string,
options: DisposablePackagedLinuxAppOptions = {}
): DisposablePackagedLinuxApp {
const sourceExecutablePath = resolve(executablePath);
const sourcePackageRoot = dirname(sourceExecutablePath);
const temporaryRoot = mkdtempSync(
join(tmpdir(), 'iptvnator-packaged-frame-copy-')
);
const packageRoot = join(temporaryRoot, basename(sourcePackageRoot));
let cleaned = false;
try {
clonePackagedEntry(
sourcePackageRoot,
packageRoot,
options.linkFile ?? linkSync
);
} catch (error) {
rmSync(temporaryRoot, { force: true, recursive: true });
throw error;
}
const clonedExecutablePath = join(
packageRoot,
basename(sourceExecutablePath)
);
return {
cleanup() {
if (cleaned) {
return;
}
rmSync(temporaryRoot, { force: true, recursive: true });
cleaned = true;
},
executablePath: clonedExecutablePath,
nativeDir: getPackagedLinuxNativeDir(clonedExecutablePath),
packageRoot,
temporaryRoot,
};
}
export function createPackagedEntryGuard(
entryPath: string,
options: PackagedEntryGuardOptions
): PackagedEntryGuard {
const guardedEntryPath = resolve(entryPath);
const hiddenDirectory = resolve(options.hiddenDirectory);
const hiddenEntryPath = join(
hiddenDirectory,
`.${basename(guardedEntryPath)}.e2e-hidden-${process.pid}`
);
let hidden = false;
return {
hide() {
const entryStats = lstatSync(guardedEntryPath);
if (!entryKindMatches(entryStats, options.expectedKind)) {
throw new Error(
`Packaged entry is not a ${options.expectedKind}: ${guardedEntryPath}`
);
}
const hiddenDirectoryStats = lstatSync(hiddenDirectory);
if (
hiddenDirectoryStats.isSymbolicLink() ||
!hiddenDirectoryStats.isDirectory()
) {
throw new Error(
`Packaged entry stash is not a regular directory: ${hiddenDirectory}`
);
}
if (entryExistsByLstat(hiddenEntryPath)) {
throw new Error(
`Stale hidden packaged entry exists: ${hiddenEntryPath}`
);
}
renameSync(guardedEntryPath, hiddenEntryPath);
hidden = true;
},
restore() {
if (!hidden) {
return;
}
if (!entryExistsByLstat(hiddenEntryPath)) {
throw new Error(
`Hidden packaged entry disappeared before restore: ${hiddenEntryPath}`
);
}
if (entryExistsByLstat(guardedEntryPath)) {
throw new Error(
`Refusing to overwrite packaged entry during restore: ${guardedEntryPath}`
);
}
renameSync(hiddenEntryPath, guardedEntryPath);
hidden = false;
},
};
}
export function readPackagedRuntimeIdentity(
nativeDir: string
): PackagedRuntimeIdentity {
const runtimeManifestPath = join(nativeDir, 'embedded-mpv-runtime.json');
const parsed = JSON.parse(
readFileSync(runtimeManifestPath, 'utf8')
) as Partial<PackagedRuntimeIdentity>;
for (const field of [
'arch',
'platform',
'profile',
'runtimeMode',
] as const) {
if (typeof parsed[field] !== 'string' || !parsed[field]) {
throw new Error(
`Packaged runtime manifest ${field} is invalid at ${runtimeManifestPath}.`
);
}
}
if (
typeof parsed.libmpvSoname !== 'string' ||
!VERSIONED_LIBMPV_PATTERN.test(parsed.libmpvSoname)
) {
throw new Error(
`Packaged runtime manifest libmpvSoname is invalid at ${runtimeManifestPath}.`
);
}
return parsed as PackagedRuntimeIdentity;
}
@@ -0,0 +1,191 @@
import assert = require('node:assert/strict');
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { describe, it } from 'node:test';
import { isMeaningfulNativePlaybackSnapshot } from './embedded-mpv-frame-copy-packaged-fixtures';
import './embedded-mpv-frame-copy-packaged-filesystem.tests';
import { resolvePackagedElectronLaunchArgs } from './electron-test-fixtures';
import packagedPlaywrightConfig from '../playwright.packaged.config';
const projectRoot = resolve(__dirname, '..');
describe('packaged Electron launch arguments', () => {
it('keeps WebGL enabled for software rendering while disabling the sandbox only as root', () => {
assert.deepEqual(
resolvePackagedElectronLaunchArgs(() => 1000),
['--ignore-gpu-blocklist']
);
assert.deepEqual(resolvePackagedElectronLaunchArgs(undefined), [
'--ignore-gpu-blocklist',
]);
assert.deepEqual(
resolvePackagedElectronLaunchArgs(() => 0),
['--ignore-gpu-blocklist', '--no-sandbox']
);
});
});
describe('native-view playback proof', () => {
it('requires the loaded URL, a playing or paused state, and positive duration', () => {
const expectedUrl = 'http://127.0.0.1:3210/fixture.y4m';
const baseSnapshot = {
durationSeconds: 2,
status: 'playing',
streamUrl: expectedUrl,
};
assert.equal(
isMeaningfulNativePlaybackSnapshot(baseSnapshot, expectedUrl),
true
);
assert.equal(
isMeaningfulNativePlaybackSnapshot(
{ ...baseSnapshot, status: 'paused' },
`${expectedUrl}#ignored`
),
true
);
assert.equal(
isMeaningfulNativePlaybackSnapshot(
{ ...baseSnapshot, durationSeconds: null },
expectedUrl
),
false
);
assert.equal(
isMeaningfulNativePlaybackSnapshot(
{ ...baseSnapshot, status: 'idle' },
expectedUrl
),
false
);
assert.equal(
isMeaningfulNativePlaybackSnapshot(
{ ...baseSnapshot, streamUrl: `${expectedUrl}?other=1` },
expectedUrl
),
false
);
});
it('loads the fixture and observes playback before disposing the native session', () => {
const source = readFileSync(
join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'),
'utf8'
);
const fallbackStart = source.indexOf('const launchedFallbackApp');
const captureIndex = source.indexOf(
'installEmbeddedMpvSessionCapture',
fallbackStart
);
const loadIndex = source.indexOf(
'loadEmbeddedMpvPlayback',
fallbackStart
);
const proofIndex = source.indexOf(
'isMeaningfulNativePlaybackSnapshot',
fallbackStart
);
const exitCodeIndex = source.indexOf(
'electronApp.process().exitCode',
fallbackStart
);
const disposeIndex = source.indexOf(
'disposeEmbeddedMpvSession',
fallbackStart
);
assert.ok(fallbackStart >= 0);
assert.ok(captureIndex > fallbackStart);
assert.ok(loadIndex > captureIndex);
assert.ok(proofIndex > loadIndex);
assert.ok(exitCodeIndex > proofIndex);
assert.ok(disposeIndex > exitCodeIndex);
});
it('removes the manifest-declared libmpv target and expects the stable missing-library reason', () => {
const source = readFileSync(
join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'),
'utf8'
);
const manifestIdentityIndex = source.indexOf(
'const runtimeIdentity = readPackagedRuntimeIdentity'
);
const guardIndex = source.indexOf(
'createPackagedEntryGuard(',
manifestIdentityIndex
);
const sonameIndex = source.indexOf(
'runtimeIdentity.libmpvSoname',
guardIndex
);
const hideIndex = source.indexOf('.hide()', sonameIndex);
const fallbackStart = source.indexOf(
'const launchedFallbackApp',
hideIndex
);
const missingReasonIndex = source.indexOf(
"frameCopyUnavailableReason: 'runtime-library-missing'",
fallbackStart
);
assert.ok(manifestIdentityIndex >= 0);
assert.ok(guardIndex > manifestIdentityIndex);
assert.ok(sonameIndex > guardIndex);
assert.ok(hideIndex > sonameIndex);
assert.ok(fallbackStart > hideIndex);
assert.ok(missingReasonIndex > fallbackStart);
assert.doesNotMatch(source, /createRuntimeManifestGuard/);
assert.doesNotMatch(source, /runtimeManifest\.hide/);
});
});
describe('dedicated packaged smoke target', () => {
it('inherits the GL mode from the workflow environment', () => {
const source = readFileSync(
join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'),
'utf8'
);
assert.doesNotMatch(source, /LIBGL_ALWAYS_SOFTWARE\s*:/);
});
it('does not build the backend or start portal mock servers', () => {
const project = JSON.parse(
readFileSync(join(projectRoot, 'project.json'), 'utf8')
) as {
targets?: Record<
string,
{
cache?: boolean;
dependsOn?: unknown;
options?: { command?: string };
}
>;
};
const target = project.targets?.['packaged-frame-copy-smoke'];
const packagedConfig = readFileSync(
join(projectRoot, 'playwright.packaged.config.ts'),
'utf8'
);
if (!target) {
throw new Error('The packaged frame-copy smoke target is missing.');
}
assert.equal(target.cache, false);
assert.deepEqual(target.dependsOn, [
'test-packaged-frame-copy-fixtures',
]);
assert.match(
target.options?.command ?? '',
/playwright\.packaged\.config\.ts/
);
assert.match(
target.options?.command ?? '',
/embedded-mpv-frame-copy-packaged\.e2e\.ts/
);
assert.match(packagedConfig, /timeout:\s*120000/);
assert.match(packagedConfig, /webServer:\s*\[\]/);
assert.deepEqual(packagedPlaywrightConfig.webServer, []);
});
});
@@ -0,0 +1,344 @@
import type {
EmbeddedMpvSession,
EmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
import { spawnSync } from 'child_process';
import { createServer, type Server } from 'http';
import sharp = require('sharp');
import {
closeElectronApp,
expect,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import type {
DisposablePackagedLinuxApp,
PackagedEntryGuard,
} from './embedded-mpv-frame-copy-packaged-filesystem';
declare global {
interface Window {
__packagedEmbeddedMpvSessions?: EmbeddedMpvSession[];
__packagedEmbeddedMpvUnsubscribe?: () => void;
}
}
export type LocalMediaServer = {
close: () => Promise<void>;
url: string;
};
function createTwoSecondY4mFixture(): Buffer {
const width = 64;
const height = 36;
const framesPerSecond = 10;
const frameCount = framesPerSecond * 2;
const yPlaneBytes = width * height;
const chromaPlaneBytes = (width / 2) * (height / 2);
const chunks: Buffer[] = [
Buffer.from(
`YUV4MPEG2 W${width} H${height} F${framesPerSecond}:1 Ip A1:1 C420jpeg\n`,
'ascii'
),
];
for (let index = 0; index < frameCount; index += 1) {
const evenFrame = index % 2 === 0;
chunks.push(
Buffer.from('FRAME\n', 'ascii'),
Buffer.alloc(yPlaneBytes, evenFrame ? 76 : 150),
Buffer.alloc(chromaPlaneBytes, evenFrame ? 84 : 44),
Buffer.alloc(chromaPlaneBytes, evenFrame ? 255 : 21)
);
}
return Buffer.concat(chunks);
}
async function listen(server: Server): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
const onError = (error: Error) => {
server.off('listening', onListening);
rejectPromise(error);
};
const onListening = () => {
server.off('error', onError);
resolvePromise();
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(0, '127.0.0.1');
});
}
async function closeServer(server: Server): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
server.close((error) => {
if (error) {
rejectPromise(error);
return;
}
resolvePromise();
});
});
}
export async function createLocalMediaServer(): Promise<LocalMediaServer> {
const body = createTwoSecondY4mFixture();
const resourcePath = '/embedded-mpv-frame-copy-smoke.y4m';
const server = createServer((request, response) => {
const pathname = (request.url ?? '').split('?')[0];
if (pathname !== resourcePath) {
response.writeHead(404).end();
return;
}
const range = request.headers.range?.match(/^bytes=(\d+)-(\d*)$/);
if (!range) {
response.writeHead(200, {
'Accept-Ranges': 'bytes',
'Content-Length': body.length,
'Content-Type': 'video/x-yuv4mpeg',
});
response.end(request.method === 'HEAD' ? undefined : body);
return;
}
const start = Number(range[1]);
const requestedEnd = range[2] ? Number(range[2]) : body.length - 1;
const end = Math.min(requestedEnd, body.length - 1);
if (
!Number.isSafeInteger(start) ||
!Number.isSafeInteger(end) ||
start < 0 ||
start > end ||
start >= body.length
) {
response.writeHead(416, {
'Content-Range': `bytes */${body.length}`,
});
response.end();
return;
}
response.writeHead(206, {
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Range': `bytes ${start}-${end}/${body.length}`,
'Content-Type': 'video/x-yuv4mpeg',
});
response.end(
request.method === 'HEAD'
? undefined
: body.subarray(start, end + 1)
);
});
await listen(server);
const address = server.address();
if (!address || typeof address === 'string') {
await closeServer(server);
throw new Error('Unable to resolve the local media server address.');
}
return {
close: () => closeServer(server),
url: `http://127.0.0.1:${address.port}${resourcePath}`,
};
}
export function assertNativeFallbackPrerequisites(): void {
if (!process.env['DISPLAY']) {
throw new Error(
'The packaged native-view fallback smoke requires DISPLAY (run it under Xvfb/X11).'
);
}
const mpv = spawnSync('mpv', ['--version'], {
stdio: 'ignore',
timeout: 3000,
});
if (mpv.status !== 0) {
throw new Error(
'The packaged native-view fallback smoke requires a working system mpv CLI on PATH.'
);
}
}
export async function installEmbeddedMpvSessionCapture(
app: LaunchedElectronApp
): Promise<void> {
await app.mainWindow.evaluate(() => {
window.__packagedEmbeddedMpvUnsubscribe?.();
window.__packagedEmbeddedMpvSessions = [];
window.__packagedEmbeddedMpvUnsubscribe =
window.electron.onEmbeddedMpvSessionUpdate?.((session) => {
window.__packagedEmbeddedMpvSessions?.push(session);
});
});
}
export async function installFrameCanvasAndSessionCapture(
app: LaunchedElectronApp
): Promise<void> {
await installEmbeddedMpvSessionCapture(app);
await app.mainWindow.evaluate(() => {
document.querySelector('canvas[data-embedded-mpv-frame]')?.remove();
const canvas = document.createElement('canvas');
canvas.dataset['embeddedMpvFrame'] = '';
canvas.dataset['testId'] = 'packaged-embedded-mpv-frame';
Object.assign(canvas.style, {
background: '#000',
height: '180px',
left: '0',
position: 'fixed',
top: '0',
width: '320px',
zIndex: '2147483647',
});
document.body.append(canvas);
});
}
export async function getEmbeddedMpvSupport(
app: LaunchedElectronApp
): Promise<EmbeddedMpvSupport> {
return app.mainWindow.evaluate(async () => {
return window.electron.getEmbeddedMpvSupport();
});
}
export async function getLatestSession(
app: LaunchedElectronApp,
sessionId: string
): Promise<EmbeddedMpvSession | null> {
return app.mainWindow.evaluate((id) => {
const sessions =
window.__packagedEmbeddedMpvSessions?.filter(
(session) => session.id === id
) ?? [];
return sessions.at(-1) ?? null;
}, sessionId);
}
export function isMeaningfulNativePlaybackSnapshot(
snapshot: {
durationSeconds: number | null;
error?: string;
status: string;
streamUrl: string;
} | null,
expectedUrl: string
): boolean {
if (
!snapshot ||
snapshot.error ||
!['paused', 'playing'].includes(snapshot.status) ||
typeof snapshot.durationSeconds !== 'number' ||
!Number.isFinite(snapshot.durationSeconds) ||
snapshot.durationSeconds <= 0
) {
return false;
}
const normalizeUrl = (value: string): string => {
try {
const url = new URL(value);
url.hash = '';
return url.href;
} catch {
return value.trim();
}
};
return normalizeUrl(snapshot.streamUrl) === normalizeUrl(expectedUrl);
}
export async function renderedFrameSignal(
app: LaunchedElectronApp
): Promise<number> {
const canvas = app.mainWindow.getByTestId('packaged-embedded-mpv-frame');
const png = await canvas.screenshot();
const { data, info } = await sharp(png)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
let signal = 0;
for (let offset = 0; offset < data.length; offset += info.channels) {
if (data[offset] + data[offset + 1] + data[offset + 2] > 30) {
signal += 1;
}
}
return signal;
}
export async function closeAndWaitForExit(
app: LaunchedElectronApp
): Promise<void> {
const processHandle = app.electronApp.process();
await closeElectronApp(app);
await expect
.poll(
() =>
processHandle.exitCode !== null ||
processHandle.signalCode !== null,
{ timeout: 10000 }
)
.toBe(true);
}
export async function cleanupPackagedFrameCopySmoke(options: {
apps: Array<LaunchedElectronApp | undefined>;
hiddenRuntimeEntry?: PackagedEntryGuard;
media?: LocalMediaServer;
packageClone?: DisposablePackagedLinuxApp;
}): Promise<void> {
const errors: unknown[] = [];
for (const app of options.apps) {
if (!app) {
continue;
}
try {
await closeAndWaitForExit(app);
} catch (error) {
errors.push(error);
}
}
if (options.hiddenRuntimeEntry) {
try {
options.hiddenRuntimeEntry.restore();
} catch (error) {
errors.push(error);
}
}
if (options.media) {
try {
await options.media.close();
} catch (error) {
errors.push(error);
}
}
if (options.packageClone) {
try {
options.packageClone.cleanup();
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
throw new Error(
`Packaged frame-copy smoke cleanup failed: ${errors
.map((error) =>
error instanceof Error ? error.message : String(error)
)
.join('; ')}`
);
}
}
@@ -0,0 +1,311 @@
import {
expect,
launchPackagedElectronApp,
resolvePackagedLinuxExecutable,
test,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import { join } from 'path';
import {
assertNativeFallbackPrerequisites,
cleanupPackagedFrameCopySmoke,
closeAndWaitForExit,
createLocalMediaServer,
getEmbeddedMpvSupport,
getLatestSession,
installEmbeddedMpvSessionCapture,
installFrameCanvasAndSessionCapture,
isMeaningfulNativePlaybackSnapshot,
renderedFrameSignal,
type LocalMediaServer,
} from './embedded-mpv-frame-copy-packaged-fixtures';
import {
createDisposablePackagedLinuxApp,
createPackagedEntryGuard,
readPackagedRuntimeIdentity,
type DisposablePackagedLinuxApp,
type PackagedEntryGuard,
} from './embedded-mpv-frame-copy-packaged-filesystem';
const PACKAGED_FRAME_COPY_REQUIRED_ENV =
'IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY';
const FRAME_COPY_OPT_IN_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY';
const packagedExecutable = resolvePackagedLinuxExecutable();
const packagedFrameCopyRequired = isTruthy(
process.env[PACKAGED_FRAME_COPY_REQUIRED_ENV]
);
function isTruthy(value: string | undefined): boolean {
return ['1', 'true', 'yes', 'on'].includes(
(value ?? '').trim().toLowerCase()
);
}
// This smoke drives the public preload API directly so it exercises the real
// packaged main process, helper, frame reader, WebGL pump, and pause controls
// without coupling runtime validation to playlist import/settings UI state.
test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
test.skip(
process.platform !== 'linux',
'The packaged frame-copy runtime is Linux-only.'
);
test.skip(
!packagedExecutable && !packagedFrameCopyRequired,
`Set IPTVNATOR_E2E_PACKAGED_EXECUTABLE or ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1 in the dedicated packaged-runtime job.`
);
test('@critical @electron @embedded-mpv uses packaged frame-copy and fails closed to native-view', async ({
dataDir,
}) => {
expect(
process.arch,
'The official Linux frame-copy runtime is x64-only.'
).toBe('x64');
expect(
packagedExecutable,
`The dedicated packaged-runtime job must provide a real unpacked x64 executable with IPTVNATOR_E2E_PACKAGED_EXECUTABLE when ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1.`
).toBeTruthy();
const sourceExecutablePath = packagedExecutable as string;
assertNativeFallbackPrerequisites();
let packageClone: DisposablePackagedLinuxApp | undefined;
let hiddenRuntimeEntry: PackagedEntryGuard | undefined;
let media: LocalMediaServer | undefined;
let frameCopyApp: LaunchedElectronApp | undefined;
let fallbackApp: LaunchedElectronApp | undefined;
try {
packageClone =
createDisposablePackagedLinuxApp(sourceExecutablePath);
const executablePath = packageClone.executablePath;
const nativeDir = packageClone.nativeDir;
const runtimeIdentity = readPackagedRuntimeIdentity(nativeDir);
hiddenRuntimeEntry = createPackagedEntryGuard(
join(nativeDir, 'lib', runtimeIdentity.libmpvSoname),
{
expectedKind: 'regular-file',
hiddenDirectory: packageClone.temporaryRoot,
}
);
const mediaServer = await createLocalMediaServer();
media = mediaServer;
expect(runtimeIdentity).toMatchObject({
arch: 'x64',
platform: 'linux',
runtimeMode: 'bundled',
});
expect(['portable', 'flatpak']).toContain(runtimeIdentity.profile);
const launchedFrameCopyApp = await launchPackagedElectronApp(
executablePath,
dataDir,
{
env: {
[FRAME_COPY_OPT_IN_ENV]: '1',
},
}
);
frameCopyApp = launchedFrameCopyApp;
await expect
.poll(() => getEmbeddedMpvSupport(launchedFrameCopyApp))
.toMatchObject({
engine: 'frame-copy',
frameCopyAvailable: true,
platform: 'linux',
supported: true,
});
await installFrameCanvasAndSessionCapture(launchedFrameCopyApp);
const created = await launchedFrameCopyApp.mainWindow.evaluate(
async () => {
return window.electron.createEmbeddedMpvSession(
{ x: 0, y: 0, width: 320, height: 180 },
'Packaged frame-copy smoke',
0
);
}
);
await launchedFrameCopyApp.mainWindow.evaluate(
async ({ sessionId, streamUrl }) => {
await window.electron.setEmbeddedMpvPaused(sessionId, true);
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
streamUrl,
title: 'Two-second generated Y4M fixture',
isLive: false,
});
},
{ sessionId: created.id, streamUrl: mediaServer.url }
);
await expect
.poll(
() => getLatestSession(launchedFrameCopyApp, created.id),
{
timeout: 15000,
}
)
.toMatchObject({
status: 'paused',
streamUrl: mediaServer.url,
videoHeight: 36,
videoWidth: 64,
});
const attached = await launchedFrameCopyApp.mainWindow.evaluate(
async (sessionId) => {
return window.electron.attachEmbeddedMpvFrameView?.(
sessionId
);
},
created.id
);
expect(attached).toBe(true);
await expect(
launchedFrameCopyApp.mainWindow.getByTestId(
'packaged-embedded-mpv-frame'
)
).toHaveAttribute('width', '320');
await expect(
launchedFrameCopyApp.mainWindow.getByTestId(
'packaged-embedded-mpv-frame'
)
).toHaveAttribute('height', '180');
await expect
.poll(() => renderedFrameSignal(launchedFrameCopyApp), {
timeout: 15000,
})
.toBeGreaterThan(0);
await launchedFrameCopyApp.mainWindow.evaluate(
(sessionId) =>
window.electron.setEmbeddedMpvPaused(sessionId, false),
created.id
);
await expect
.poll(
async () =>
(
await getLatestSession(
launchedFrameCopyApp,
created.id
)
)?.status,
{ timeout: 10000 }
)
.toBe('playing');
await launchedFrameCopyApp.mainWindow.evaluate(
(sessionId) =>
window.electron.setEmbeddedMpvPaused(sessionId, true),
created.id
);
await expect
.poll(
async () =>
(
await getLatestSession(
launchedFrameCopyApp,
created.id
)
)?.status,
{ timeout: 10000 }
)
.toBe('paused');
await launchedFrameCopyApp.mainWindow.evaluate(
async (sessionId) => {
window.electron.detachEmbeddedMpvFrameView?.();
await window.electron.disposeEmbeddedMpvSession(sessionId);
window.__packagedEmbeddedMpvUnsubscribe?.();
},
created.id
);
await closeAndWaitForExit(launchedFrameCopyApp);
frameCopyApp = undefined;
hiddenRuntimeEntry.hide();
expect(readPackagedRuntimeIdentity(nativeDir)).toEqual(
runtimeIdentity
);
const launchedFallbackApp = await launchPackagedElectronApp(
executablePath,
dataDir,
{
env: {
[FRAME_COPY_OPT_IN_ENV]: '1',
},
}
);
fallbackApp = launchedFallbackApp;
const fallbackSupport =
await getEmbeddedMpvSupport(launchedFallbackApp);
expect(fallbackSupport).toMatchObject({
engine: 'native',
frameCopyAvailable: false,
frameCopyUnavailableReason: 'runtime-library-missing',
platform: 'linux',
supported: true,
});
await installEmbeddedMpvSessionCapture(launchedFallbackApp);
const nativeSession = await launchedFallbackApp.mainWindow.evaluate(
async () => {
return window.electron.createEmbeddedMpvSession(
{ x: 0, y: 0, width: 320, height: 180 },
'Native-view fallback smoke',
0
);
}
);
expect(nativeSession.id).toMatch(/^embedded-mpv-/);
await launchedFallbackApp.mainWindow.evaluate(
async ({ sessionId, streamUrl }) => {
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
streamUrl,
title: 'Native-view generated Y4M fixture',
isLive: false,
});
},
{ sessionId: nativeSession.id, streamUrl: mediaServer.url }
);
await expect
.poll(
async () =>
isMeaningfulNativePlaybackSnapshot(
await getLatestSession(
launchedFallbackApp,
nativeSession.id
),
mediaServer.url
),
{ timeout: 15000 }
)
.toBe(true);
expect(
launchedFallbackApp.electronApp.process().exitCode
).toBeNull();
expect(
launchedFallbackApp.electronApp.process().signalCode
).toBeNull();
await launchedFallbackApp.mainWindow.evaluate(async (sessionId) => {
await window.electron.disposeEmbeddedMpvSession(sessionId);
window.__packagedEmbeddedMpvUnsubscribe?.();
}, nativeSession.id);
await expect
.poll(() => launchedFallbackApp.mainWindow.title())
.toContain('IPTVnator');
} finally {
await cleanupPackagedFrameCopySmoke({
apps: [frameCopyApp, fallbackApp],
hiddenRuntimeEntry,
media,
packageClone,
});
}
});
});
@@ -0,0 +1,8 @@
import { test } from '@playwright/test';
import { runM3uImportBenchmark } from './performance/m3u-import.benchmark';
// eslint-disable-next-line playwright/expect-expect -- Assertions run inside the shared benchmark lifecycle.
test('profiles initial M3U imports at 10k, 50k, and 100k', async () => {
await runM3uImportBenchmark();
});
@@ -0,0 +1,8 @@
import { test } from '@playwright/test';
import { runM3uRefreshCancellationBenchmark } from './performance/m3u-refresh-cancellation.benchmark';
// eslint-disable-next-line playwright/expect-expect -- Assertions run inside the shared benchmark lifecycle.
test('profiles cancellation of a 100k M3U refresh', async () => {
await runM3uRefreshCancellationBenchmark();
});
@@ -0,0 +1,359 @@
/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import test from 'node:test';
import {
installDatabaseRequestIdentityCaptureInMain,
type DatabaseRequestIdentityCaptureApi,
} from './database-request-identity-capture';
const CHANNEL = 'IPTVNATOR_PERF_CAPTURE_MARKER';
let nextStateId = 1;
function createCapture(): {
api: DatabaseRequestIdentityCaptureApi;
ipcMain: EventEmitter;
} {
const ipcMain = new EventEmitter();
const stateKey = `__identityCapture${nextStateId}`;
nextStateId += 1;
installDatabaseRequestIdentityCaptureInMain({ ipcMain } as never, stateKey);
const api = (globalThis as unknown as Record<string, unknown>)[
stateKey
] as DatabaseRequestIdentityCaptureApi;
api.start();
return { api, ipcMain };
}
function marker(overrides: Record<string, unknown> = {}) {
return {
correlationState: 'correlated',
invalidReason: null,
ipcCallId: 2,
method: 'dbGetAppPlaylist',
operationId: 'refresh-operation-1',
phase: 'start',
playlistId: 'playlist-1',
sourceEpochMs: 100,
...overrides,
};
}
test('correlates the real preload marker to a DB request whose payload omits operationId', () => {
const { api, ipcMain } = createCapture();
ipcMain.emit(CHANNEL, {}, marker());
const identity = api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'request-1',
type: 'request',
});
assert.deepEqual(identity, {
ipcCallId: 2,
operationId: 'refresh-operation-1',
operationIdUnavailableReason: null,
sourceEpochMs: 100,
});
});
test('retains the initial-import upsert marker when its operationId is intentionally uncorrelated', () => {
const { api, ipcMain } = createCapture();
ipcMain.emit(
CHANNEL,
{},
marker({
correlationState: 'uncorrelated',
ipcCallId: 7,
method: 'dbUpsertAppPlaylist',
operationId: null,
sourceEpochMs: 1_077.25,
})
);
const identity = api.matchDatabaseRequest({
operation: 'DB_UPSERT_APP_PLAYLIST',
payload: { _id: 'playlist-1' },
requestId: 'initial-import-upsert',
type: 'request',
});
assert.deepEqual(identity, {
ipcCallId: 7,
operationId: null,
operationIdUnavailableReason: 'preload-performance-marker-uncorrelated',
sourceEpochMs: 1_077.25,
});
});
test('captures exact successful preload completion markers for DB return-clone attribution', () => {
const { api, ipcMain } = createCapture();
ipcMain.emit(
CHANNEL,
{},
marker({
correlationState: 'uncorrelated',
ipcCallId: 7,
method: 'dbUpsertAppPlaylist',
operationId: null,
phase: 'success',
sourceEpochMs: 140.25,
})
);
ipcMain.emit(
CHANNEL,
{},
marker({
correlationState: 'uncorrelated',
ipcCallId: 8,
operationId: null,
phase: 'success',
sourceEpochMs: 175.5,
})
);
assert.equal(api.successMarkerCount(), 2);
assert.deepEqual(api.takeSuccessMarkers(), [
{
ipcCallId: 7,
operation: 'DB_UPSERT_APP_PLAYLIST',
operationId: null,
playlistId: 'playlist-1',
sourceEpochMs: 140.25,
},
{
ipcCallId: 8,
operation: 'DB_GET_APP_PLAYLIST',
operationId: null,
playlistId: 'playlist-1',
sourceEpochMs: 175.5,
},
]);
assert.equal(api.successMarkerCount(), 0);
assert.deepEqual(api.takeSuccessMarkers(), []);
});
test('fails closed for malformed or invalid successful preload markers and resets completions', () => {
const { api, ipcMain } = createCapture();
for (const invalid of [
{ invalidReason: 'out-of-order' },
{ correlationState: 'invalid' },
{ ipcCallId: 0 },
{ playlistId: '' },
{ sourceEpochMs: Number.NaN },
{ method: 'refreshPlaylist' },
{ correlationState: 'correlated', operationId: null },
]) {
ipcMain.emit(
CHANNEL,
{},
marker({
phase: 'success',
...invalid,
})
);
}
assert.equal(api.successMarkerCount(), 0);
assert.deepEqual(api.takeSuccessMarkers(), []);
ipcMain.emit(
CHANNEL,
{},
marker({
phase: 'success',
})
);
api.stop();
api.start();
assert.equal(api.successMarkerCount(), 0);
assert.deepEqual(api.takeSuccessMarkers(), []);
});
test('resolves a response-retained request identity when the marker arrives later and resets between captures', () => {
const { api, ipcMain } = createCapture();
const identity = api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'request-before-marker',
type: 'request',
});
assert.equal(
identity.operationIdUnavailableReason,
'preload-performance-marker-missing'
);
const responseOutcome = { identity };
ipcMain.emit(CHANNEL, {}, marker());
assert.deepEqual(responseOutcome.identity, {
ipcCallId: 2,
operationId: 'refresh-operation-1',
operationIdUnavailableReason: null,
sourceEpochMs: 100,
});
api.stop();
ipcMain.emit(CHANNEL, {}, marker({ operationId: 'marker-while-stopped' }));
api.start();
const afterReset = api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'request-after-reset',
type: 'request',
});
assert.deepEqual(afterReset, {
ipcCallId: null,
operationId: null,
operationIdUnavailableReason: 'preload-performance-marker-missing',
sourceEpochMs: null,
});
});
test('fails closed for missing and ambiguous preload markers', () => {
const missingCapture = createCapture();
const missing = missingCapture.api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'request-missing',
type: 'request',
});
assert.deepEqual(missing, {
ipcCallId: null,
operationId: null,
operationIdUnavailableReason: 'preload-performance-marker-missing',
sourceEpochMs: null,
});
const ambiguousCapture = createCapture();
ambiguousCapture.ipcMain.emit(CHANNEL, {}, marker());
ambiguousCapture.ipcMain.emit(
CHANNEL,
{},
marker({
ipcCallId: 3,
operationId: 'refresh-operation-2',
})
);
const ambiguous = ambiguousCapture.api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'request-ambiguous',
type: 'request',
});
assert.deepEqual(ambiguous, {
ipcCallId: null,
operationId: null,
operationIdUnavailableReason: 'preload-performance-marker-ambiguous',
sourceEpochMs: null,
});
});
test('quarantines a key after response-before-marker ambiguity', () => {
const { api, ipcMain } = createCapture();
const requestA = api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'request-a',
type: 'request',
});
const requestB = api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'request-b',
type: 'request',
});
ipcMain.emit(
CHANNEL,
{},
marker({ ipcCallId: 2, operationId: 'refresh-operation-a' })
);
ipcMain.emit(
CHANNEL,
{},
marker({ ipcCallId: 3, operationId: 'refresh-operation-b' })
);
const requestC = api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'request-c',
type: 'request',
});
ipcMain.emit(
CHANNEL,
{},
marker({
ipcCallId: 4,
operationId: 'other-playlist-operation',
playlistId: 'playlist-2',
})
);
const otherPlaylist = api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-2' },
requestId: 'other-playlist-request',
type: 'request',
});
ipcMain.emit(
CHANNEL,
{},
marker({
ipcCallId: 5,
method: 'dbUpsertAppPlaylist',
operationId: 'other-db-operation',
})
);
const otherOperation = api.matchDatabaseRequest({
operation: 'DB_UPSERT_APP_PLAYLIST',
payload: { _id: 'playlist-1' },
requestId: 'other-operation-request',
type: 'request',
});
const ambiguousIdentity = {
ipcCallId: null,
operationId: null,
operationIdUnavailableReason: 'preload-performance-marker-ambiguous',
sourceEpochMs: null,
};
assert.deepEqual(requestA, ambiguousIdentity);
assert.deepEqual(requestB, ambiguousIdentity);
assert.deepEqual(requestC, ambiguousIdentity);
assert.deepEqual(otherPlaylist, {
ipcCallId: 4,
operationId: 'other-playlist-operation',
operationIdUnavailableReason: null,
sourceEpochMs: 100,
});
assert.deepEqual(otherOperation, {
ipcCallId: 5,
operationId: 'other-db-operation',
operationIdUnavailableReason: null,
sourceEpochMs: 100,
});
api.stop();
api.start();
ipcMain.emit(
CHANNEL,
{},
marker({
ipcCallId: 6,
operationId: 'fresh-capture-operation',
})
);
const freshCaptureIdentity = api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'fresh-capture-request',
type: 'request',
});
assert.deepEqual(freshCaptureIdentity, {
ipcCallId: 6,
operationId: 'fresh-capture-operation',
operationIdUnavailableReason: null,
sourceEpochMs: 100,
});
});
@@ -0,0 +1,354 @@
import type { ElectronApplication } from '@playwright/test';
export const DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY =
'__iptvnatorM3uRefreshDatabaseRequestIdentityCapture';
export interface DatabaseRequestIdentity {
ipcCallId: number | null;
operationId: string | null;
operationIdUnavailableReason: string | null;
sourceEpochMs: number | null;
}
export interface PreloadDatabaseSuccessMarker {
ipcCallId: number;
operation: 'DB_GET_APP_PLAYLIST' | 'DB_UPSERT_APP_PLAYLIST';
operationId: string | null;
playlistId: string;
sourceEpochMs: number;
}
export interface DatabaseRequestIdentityCaptureApi {
matchDatabaseRequest(message: unknown): DatabaseRequestIdentity;
start(): void;
stop(): void;
successMarkerCount(): number;
takeSuccessMarkers(): readonly PreloadDatabaseSuccessMarker[];
}
interface DatabaseRequestIdentityElectron {
readonly ipcMain: {
on(
channel: string,
listener: (event: unknown, marker: unknown) => void
): void;
};
}
export function installDatabaseRequestIdentityCaptureInMain(
{ ipcMain }: DatabaseRequestIdentityElectron,
stateKey: string
): void {
type JsonRecord = Record<string, unknown>;
interface PendingMarker {
readonly ipcCallId: number;
readonly operation: string;
readonly operationId: string | null;
readonly operationIdUnavailableReason: string | null;
readonly playlistId: string;
readonly sourceEpochMs: number;
}
interface PendingRequest {
readonly identity: DatabaseRequestIdentity;
readonly operation: string;
readonly playlistId: string;
}
interface QuarantinedKey {
readonly operation: string;
readonly playlistId: string;
}
const target = globalThis as unknown as Record<string, unknown>;
if (target[stateKey] !== undefined) {
return;
}
const markerChannel = 'IPTVNATOR_PERF_CAPTURE_MARKER';
const markerMethodToOperation: Readonly<Record<string, string>> = {
dbGetAppPlaylist: 'DB_GET_APP_PLAYLIST',
dbUpsertAppPlaylist: 'DB_UPSERT_APP_PLAYLIST',
};
let active = false;
let pendingMarkers: PendingMarker[] = [];
let pendingRequests: PendingRequest[] = [];
let quarantinedKeys: QuarantinedKey[] = [];
let successMarkers: PreloadDatabaseSuccessMarker[] = [];
const readRecord = (value: unknown): JsonRecord | null =>
typeof value === 'object' && value !== null
? (value as JsonRecord)
: null;
const readPlaylistId = (
operation: string,
payload: unknown
): string | null => {
const value = readRecord(payload);
if (!value) {
return null;
}
const playlistId =
operation === 'DB_UPSERT_APP_PLAYLIST'
? value['_id']
: value['playlistId'];
return typeof playlistId === 'string' && playlistId.length > 0
? playlistId
: null;
};
const matches = (
candidate: { operation: string; playlistId: string },
operation: string,
playlistId: string
): boolean =>
candidate.operation === operation &&
candidate.playlistId === playlistId;
const markAmbiguous = (requests: PendingRequest[]): void => {
for (const request of requests) {
request.identity.ipcCallId = null;
request.identity.operationId = null;
request.identity.operationIdUnavailableReason =
'preload-performance-marker-ambiguous';
request.identity.sourceEpochMs = null;
}
};
const unavailableIdentity = (reason: string): DatabaseRequestIdentity => ({
ipcCallId: null,
operationId: null,
operationIdUnavailableReason: reason,
sourceEpochMs: null,
});
const identityFromMarker = (
marker: PendingMarker
): DatabaseRequestIdentity => ({
ipcCallId: marker.ipcCallId,
operationId: marker.operationId,
operationIdUnavailableReason: marker.operationIdUnavailableReason,
sourceEpochMs: marker.sourceEpochMs,
});
const applyMarker = (
identity: DatabaseRequestIdentity,
marker: PendingMarker
): void => {
identity.ipcCallId = marker.ipcCallId;
identity.operationId = marker.operationId;
identity.operationIdUnavailableReason =
marker.operationIdUnavailableReason;
identity.sourceEpochMs = marker.sourceEpochMs;
};
const isQuarantined = (operation: string, playlistId: string): boolean =>
quarantinedKeys.some((key) => matches(key, operation, playlistId));
const quarantine = (operation: string, playlistId: string): void => {
if (!isQuarantined(operation, playlistId)) {
quarantinedKeys.push({ operation, playlistId });
}
const requestCandidates = pendingRequests.filter((request) =>
matches(request, operation, playlistId)
);
markAmbiguous(requestCandidates);
pendingRequests = pendingRequests.filter(
(request) => !matches(request, operation, playlistId)
);
pendingMarkers = pendingMarkers.filter(
(marker) => !matches(marker, operation, playlistId)
);
};
ipcMain.on(markerChannel, (_event, input) => {
if (!active) {
return;
}
const marker = readRecord(input);
const correlationState = marker?.['correlationState'];
const operation =
typeof marker?.['method'] === 'string'
? markerMethodToOperation[marker['method']]
: undefined;
const operationId = marker?.['operationId'];
const playlistId = marker?.['playlistId'];
const ipcCallId = marker?.['ipcCallId'];
const sourceEpochMs = marker?.['sourceEpochMs'];
const validMarkerIdentity =
operation !== undefined &&
typeof playlistId === 'string' &&
playlistId.length > 0 &&
Number.isSafeInteger(ipcCallId) &&
Number(ipcCallId) >= 1 &&
typeof sourceEpochMs === 'number' &&
Number.isFinite(sourceEpochMs) &&
sourceEpochMs >= 0;
if (
marker?.['phase'] === 'success' &&
(correlationState === 'correlated' ||
correlationState === 'uncorrelated') &&
marker['invalidReason'] === null &&
validMarkerIdentity &&
(correlationState !== 'correlated' ||
(typeof operationId === 'string' &&
operationId.length > 0))
) {
successMarkers.push({
ipcCallId: Number(ipcCallId),
operation: operation as PreloadDatabaseSuccessMarker['operation'],
operationId:
correlationState === 'correlated'
? String(operationId)
: null,
playlistId,
sourceEpochMs,
});
return;
}
if (
!marker ||
marker['phase'] !== 'start' ||
(correlationState !== 'correlated' &&
correlationState !== 'uncorrelated') ||
marker['invalidReason'] !== null
) {
return;
}
const correlatedOperationId =
correlationState === 'correlated' &&
typeof operationId === 'string' &&
operationId.length > 0
? operationId
: null;
if (
operation === undefined ||
(correlationState === 'correlated' &&
correlatedOperationId === null) ||
typeof playlistId !== 'string' ||
playlistId.length === 0 ||
!Number.isSafeInteger(ipcCallId) ||
Number(ipcCallId) < 1 ||
typeof sourceEpochMs !== 'number' ||
!Number.isFinite(sourceEpochMs) ||
sourceEpochMs < 0
) {
return;
}
const pendingMarker: PendingMarker = {
ipcCallId: Number(ipcCallId),
operation,
operationId: correlatedOperationId,
operationIdUnavailableReason:
correlationState === 'correlated'
? null
: 'preload-performance-marker-uncorrelated',
playlistId,
sourceEpochMs,
};
if (isQuarantined(operation, playlistId)) {
return;
}
const requestCandidates = pendingRequests.filter((request) =>
matches(request, operation, playlistId)
);
if (requestCandidates.length === 1) {
const request = requestCandidates[0];
if (!request) {
return;
}
applyMarker(request.identity, pendingMarker);
pendingRequests = pendingRequests.filter(
(candidate) => candidate !== request
);
return;
}
if (requestCandidates.length > 1) {
quarantine(operation, playlistId);
return;
}
pendingMarkers.push(pendingMarker);
pendingMarkers.sort((left, right) => left.ipcCallId - right.ipcCallId);
});
const api: DatabaseRequestIdentityCaptureApi = {
matchDatabaseRequest: (input) => {
const message = readRecord(input);
const operation = message?.['operation'];
const playlistId =
typeof operation === 'string'
? readPlaylistId(operation, message?.['payload'])
: null;
if (
!active ||
(operation !== 'DB_GET_APP_PLAYLIST' &&
operation !== 'DB_UPSERT_APP_PLAYLIST') ||
playlistId === null
) {
return unavailableIdentity(
'preload-performance-marker-not-applicable'
);
}
if (isQuarantined(operation, playlistId)) {
return unavailableIdentity(
'preload-performance-marker-ambiguous'
);
}
const markerCandidates = pendingMarkers.filter((marker) =>
matches(marker, operation, playlistId)
);
if (markerCandidates.length === 1) {
const marker = markerCandidates[0];
if (!marker) {
return unavailableIdentity(
'preload-performance-marker-missing'
);
}
pendingMarkers = pendingMarkers.filter(
(candidate) => candidate !== marker
);
return identityFromMarker(marker);
}
if (markerCandidates.length > 1) {
quarantine(operation, playlistId);
return unavailableIdentity(
'preload-performance-marker-ambiguous'
);
}
const identity = unavailableIdentity(
'preload-performance-marker-missing'
);
pendingRequests.push({
identity,
operation,
playlistId,
});
return identity;
},
start: () => {
pendingMarkers = [];
pendingRequests = [];
quarantinedKeys = [];
successMarkers = [];
active = true;
},
stop: () => {
active = false;
pendingMarkers = [];
pendingRequests = [];
quarantinedKeys = [];
successMarkers = [];
},
successMarkerCount: () => successMarkers.length,
takeSuccessMarkers: () => {
const captured = Object.freeze([...successMarkers]);
successMarkers = [];
return captured;
},
};
target[stateKey] = api;
}
export async function installDatabaseRequestIdentityCapture(
electronApp: ElectronApplication
): Promise<void> {
await electronApp.evaluate(
installDatabaseRequestIdentityCaptureInMain,
DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY
);
}
@@ -0,0 +1,98 @@
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync(
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
'utf8'
);
function section(startMarker: string, endMarker: string): string {
const start = source.indexOf(startMarker);
const end = source.indexOf(endMarker, start);
assert.ok(start >= 0, `missing start marker: ${startMarker}`);
assert.ok(end > start, `missing end marker: ${endMarker}`);
return source.slice(start, end);
}
test('counts only finite nonnegative worker heap and external-memory samples per capture generation', () => {
const workerRecord = section(
'interface WorkerRecord',
'interface TimelineRecord'
);
const createRecord = section(
'const record: WorkerRecord = {',
'nextWorkerOrdinal += 1'
);
const sampleWorker = section(
'const sampleWorker =',
'const resetWorkerForCapture'
);
const resetWorker = section(
'const resetWorkerForCapture',
'const startWorker'
);
assert.match(workerRecord, /externalMemorySampleCount: number/);
assert.match(workerRecord, /heapUsedSampleCount: number/);
assert.match(createRecord, /externalMemorySampleCount: 0/);
assert.match(createRecord, /heapUsedSampleCount: 0/);
assert.match(resetWorker, /record\.externalMemorySampleCount = 0/);
assert.match(resetWorker, /record\.heapUsedSampleCount = 0/);
assert.match(sampleWorker, /const heapUsedSample = stats\.used_heap_size/);
assert.match(
sampleWorker,
/Number\.isFinite\(heapUsedSample\)[\s\S]*heapUsedSample >= 0/
);
assert.match(sampleWorker, /record\.heapUsedSampleCount \+= 1/);
assert.match(
sampleWorker,
/const externalMemorySample = stats\.external_memory/
);
assert.match(
sampleWorker,
/Number\.isFinite\(externalMemorySample\)[\s\S]*externalMemorySample >= 0/
);
assert.match(sampleWorker, /record\.externalMemorySampleCount \+= 1/);
assert.doesNotMatch(sampleWorker, /used_heap_size \?\? 0/);
assert.doesNotMatch(sampleWorker, /external_memory \?\? 0/);
});
test('raw worker metrics distinguish missing samples from measured zero-byte peaks', () => {
const transport = section(
'const workers = currentWorkerRecords',
'const transport: MainCaptureGenerationTransport'
);
assert.match(
transport,
/externalMemorySampleCount:\s*record\.externalMemorySampleCount/
);
assert.match(
transport,
/heapUsedSampleCount:\s*record\.heapUsedSampleCount/
);
assert.match(
transport,
/peakExternalBytes:\s*record\.externalMemorySampleCount > 0[\s\S]*\?\s*record\.externalPeak[\s\S]*:\s*null/
);
assert.match(
transport,
/peakHeapUsedBytes:\s*record\.heapUsedSampleCount > 0[\s\S]*\?\s*record\.heapPeak[\s\S]*:\s*null/
);
assert.match(
transport,
/peakExternalUnavailableReason:\s*record\.externalMemorySampleCount > 0[\s\S]*\?\s*null[\s\S]*:\s*'worker-external-memory-samples-missing'/
);
assert.match(
transport,
/peakHeapUsedUnavailableReason:\s*record\.heapUsedSampleCount > 0[\s\S]*\?\s*null[\s\S]*:\s*'worker-heap-used-samples-missing'/
);
assert.doesNotMatch(
transport,
/peakExternalBytes: record\.externalPeak[,}]/
);
assert.doesNotMatch(transport, /peakHeapUsedBytes: record\.heapPeak[,}]/);
});
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
assessDatabaseWorkerPeakMemoryValidity,
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON,
} from './database-worker-peak-memory-validity';
describe('database worker peak-memory validity', () => {
it('requires successful heap and external-memory samples in every measured run', () => {
const validity = assessDatabaseWorkerPeakMemoryValidity([
iteration('warmup', 'warmup', worker(0, 0)),
iteration('run-01', 'measured', worker(3, 4)),
iteration('diagnostic', 'diagnostic', worker(0, 0)),
]);
assert.deepEqual(validity, {
invalidMeasuredRuns: [],
measuredRunCount: 1,
validForComparison: true,
validMeasuredRunCount: 1,
});
});
it('fails closed for absent samples instead of accepting initialized zero peaks', () => {
const missingCounts = assessDatabaseWorkerPeakMemoryValidity([
iteration('run-01', 'measured', {
kind: 'database.worker',
peakExternalBytes: 0,
peakHeapUsedBytes: 0,
}),
iteration('run-02', 'measured', worker(0, 1)),
iteration('run-03', 'measured', worker(1, 0)),
]);
assert.deepEqual(missingCounts.invalidMeasuredRuns, [
{
reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING,
runId: 'run-01',
},
{
reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING,
runId: 'run-02',
},
{
reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING,
runId: 'run-03',
},
]);
assert.equal(missingCounts.validForComparison, false);
assert.equal(missingCounts.validMeasuredRunCount, 0);
});
it('reports stable worker-cardinality and malformed-peak reasons', () => {
const validity = assessDatabaseWorkerPeakMemoryValidity([
{ kind: 'measured', main: { workers: [] }, runId: 'missing' },
{
kind: 'measured',
main: { workers: [worker(1, 1), worker(1, 1)] },
runId: 'multiple',
},
iteration('heap-invalid', 'measured', {
...worker(1, 1),
peakHeapUsedBytes: Number.NaN,
}),
iteration('external-invalid', 'measured', {
...worker(1, 1),
peakExternalBytes: -1,
}),
iteration('heap-reason-incoherent', 'measured', {
...worker(1, 1),
peakHeapUsedUnavailableReason:
'worker-heap-used-samples-missing',
}),
iteration('external-reason-incoherent', 'measured', {
...worker(1, 1),
peakExternalUnavailableReason:
'worker-external-memory-samples-missing',
}),
]);
assert.deepEqual(
validity.invalidMeasuredRuns.map(({ reason }) => reason),
[
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING,
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.MULTIPLE_DATABASE_WORKERS,
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID,
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID,
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID,
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID,
]
);
});
});
function iteration(
runId: string,
kind: string,
databaseWorker: Record<string, unknown>
) {
return {
kind,
main: { workers: [databaseWorker] },
runId,
};
}
function worker(
heapUsedSampleCount: number,
externalMemorySampleCount: number
) {
return {
externalMemorySampleCount,
heapUsedSampleCount,
kind: 'database.worker',
peakExternalBytes: 600,
peakExternalUnavailableReason: null,
peakHeapUsedBytes: 1_500,
peakHeapUsedUnavailableReason: null,
};
}
@@ -0,0 +1,114 @@
export const DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON = {
EXTERNAL_MEMORY_PEAK_INVALID:
'database-worker-external-memory-peak-invalid',
EXTERNAL_MEMORY_SAMPLES_MISSING:
'database-worker-external-memory-samples-missing',
HEAP_PEAK_INVALID: 'database-worker-heap-peak-invalid',
HEAP_SAMPLES_MISSING: 'database-worker-heap-samples-missing',
MULTIPLE_DATABASE_WORKERS: 'multiple-database-workers',
DATABASE_WORKER_MISSING: 'database-worker-missing',
} as const;
export type DatabaseWorkerPeakMemoryInvalidReason =
(typeof DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON)[keyof typeof DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON];
export interface DatabaseWorkerPeakMemoryValidity {
readonly invalidMeasuredRuns: readonly {
readonly reason: DatabaseWorkerPeakMemoryInvalidReason;
readonly runId: string;
}[];
readonly measuredRunCount: number;
readonly validForComparison: boolean;
readonly validMeasuredRunCount: number;
}
interface PeakMemoryWorker {
readonly externalMemorySampleCount?: unknown;
readonly heapUsedSampleCount?: unknown;
readonly kind: string;
readonly peakExternalBytes: unknown;
readonly peakExternalUnavailableReason?: unknown;
readonly peakHeapUsedBytes: unknown;
readonly peakHeapUsedUnavailableReason?: unknown;
}
interface PeakMemoryIteration {
readonly kind: string;
readonly main: {
readonly workers: readonly PeakMemoryWorker[];
};
readonly runId: string;
}
export function assessDatabaseWorkerPeakMemoryValidity(
iterations: readonly PeakMemoryIteration[]
): DatabaseWorkerPeakMemoryValidity {
const measured = iterations.filter(
(iteration) => iteration.kind === 'measured'
);
const invalidMeasuredRuns: {
reason: DatabaseWorkerPeakMemoryInvalidReason;
runId: string;
}[] = [];
for (const iteration of measured) {
const workers = iteration.main.workers.filter(
(worker) => worker.kind === 'database.worker'
);
const reason = assessWorkers(workers);
if (reason !== null) {
invalidMeasuredRuns.push({ reason, runId: iteration.runId });
}
}
return Object.freeze({
invalidMeasuredRuns: Object.freeze(invalidMeasuredRuns),
measuredRunCount: measured.length,
validForComparison:
measured.length > 0 && invalidMeasuredRuns.length === 0,
validMeasuredRunCount: measured.length - invalidMeasuredRuns.length,
});
}
function assessWorkers(
workers: readonly PeakMemoryWorker[]
): DatabaseWorkerPeakMemoryInvalidReason | null {
if (workers.length === 0) {
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING;
}
if (workers.length !== 1) {
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.MULTIPLE_DATABASE_WORKERS;
}
const worker = workers[0];
if (!worker) {
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING;
}
if (!isPositiveSampleCount(worker.heapUsedSampleCount)) {
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING;
}
if (
!isPeak(worker.peakHeapUsedBytes) ||
worker.peakHeapUsedUnavailableReason !== null
) {
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID;
}
if (!isPositiveSampleCount(worker.externalMemorySampleCount)) {
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING;
}
if (
!isPeak(worker.peakExternalBytes) ||
worker.peakExternalUnavailableReason !== null
) {
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID;
}
return null;
}
function isPositiveSampleCount(value: unknown): value is number {
return Number.isSafeInteger(value) && Number(value) > 0;
}
function isPeak(value: unknown): value is number {
return Number.isSafeInteger(value) && Number(value) >= 0;
}
@@ -0,0 +1,116 @@
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
type RequestDisposition = 'after-cutoff' | 'capture' | 'outside-capture';
interface CutoffApi {
beginCapture(): void;
beginStop(): void;
finishStop(): void;
observeDatabaseRequest(): RequestDisposition;
rolloverCapture?(): void;
snapshot(): {
readonly lateRequestCount: number;
readonly phase: 'active' | 'idle' | 'stopping';
};
}
interface CutoffModule {
createDatabaseWorkerPostGcCutoffApi?: () => CutoffApi;
}
const cutoffModulePromise = import(
new URL('./database-worker-post-gc-cutoff.ts', import.meta.url).href
)
.then((module) => module as CutoffModule)
.catch(() => null);
async function restoreSerializableApi(): Promise<CutoffApi> {
const module = await cutoffModulePromise;
assert.ok(module, 'database worker post-GC cutoff module must exist');
const factory = module.createDatabaseWorkerPostGcCutoffApi;
assert.equal(typeof factory, 'function');
const source = factory.toString();
assert.doesNotMatch(source, /__name/);
const restoredFactory = Function(
`"use strict"; return (${source});`
)() as () => CutoffApi;
return restoredFactory();
}
test('classifies requests after the synchronous capture cutoff without carrying state across generations', async () => {
const api = await restoreSerializableApi();
assert.equal(api.observeDatabaseRequest(), 'outside-capture');
api.beginCapture();
assert.equal(api.observeDatabaseRequest(), 'capture');
api.beginStop();
assert.equal(api.observeDatabaseRequest(), 'after-cutoff');
assert.equal(api.observeDatabaseRequest(), 'after-cutoff');
assert.deepEqual(api.snapshot(), {
lateRequestCount: 2,
phase: 'stopping',
});
api.finishStop();
assert.equal(api.observeDatabaseRequest(), 'outside-capture');
api.beginCapture();
assert.deepEqual(api.snapshot(), {
lateRequestCount: 0,
phase: 'active',
});
});
test('atomically rolls a clean cutoff into the next generation without erasing contamination', async () => {
const clean = await restoreSerializableApi();
assert.equal(typeof clean.rolloverCapture, 'function');
clean.beginCapture();
clean.beginStop();
clean.rolloverCapture?.();
assert.deepEqual(clean.snapshot(), {
lateRequestCount: 0,
phase: 'active',
});
assert.equal(clean.observeDatabaseRequest(), 'capture');
const contaminated = await restoreSerializableApi();
assert.equal(typeof contaminated.rolloverCapture, 'function');
contaminated.beginCapture();
contaminated.beginStop();
assert.equal(contaminated.observeDatabaseRequest(), 'after-cutoff');
assert.throws(
() => contaminated.rolloverCapture?.(),
/database-worker-post-gc-capture-contaminated/
);
assert.deepEqual(contaminated.snapshot(), {
lateRequestCount: 1,
phase: 'stopping',
});
});
test('main capture arms cutoff before awaiting and rejects late DB work before startWorker can restart profiling', () => {
const source = readFileSync(
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
'utf8'
);
const stopStart = source.indexOf('stop: async (');
const cutoffStart = source.indexOf(
'databaseWorkerPostGcCutoffApi.beginStop()',
stopStart
);
const activeFalse = source.indexOf('state.active = false', stopStart);
const firstAwait = source.indexOf('await ', stopStart);
const observeRequest = source.indexOf(
'databaseWorkerPostGcCutoffApi.observeDatabaseRequest()'
);
const startWorker = source.indexOf('startWorker(record)', observeRequest);
assert.ok(stopStart >= 0);
assert.ok(cutoffStart > stopStart && cutoffStart < firstAwait);
assert.ok(activeFalse > stopStart && activeFalse < firstAwait);
assert.ok(observeRequest >= 0 && observeRequest < startWorker);
assert.match(source, /database-worker-activity-after-cutoff/);
});
@@ -0,0 +1,74 @@
export type DatabaseWorkerPostGcRequestDisposition =
'after-cutoff' | 'capture' | 'outside-capture';
export interface DatabaseWorkerPostGcCutoffSnapshot {
readonly lateRequestCount: number;
readonly phase: 'active' | 'idle' | 'stopping';
}
export interface DatabaseWorkerPostGcCutoffApi {
beginCapture(): void;
beginStop(): void;
finishStop(): void;
observeDatabaseRequest(): DatabaseWorkerPostGcRequestDisposition;
rolloverCapture(): void;
snapshot(): DatabaseWorkerPostGcCutoffSnapshot;
}
export function createDatabaseWorkerPostGcCutoffApi(): DatabaseWorkerPostGcCutoffApi {
let lateRequestCount = 0;
let phase: DatabaseWorkerPostGcCutoffSnapshot['phase'] = 'idle';
const api: DatabaseWorkerPostGcCutoffApi = {
beginCapture(): void {
if (phase !== 'idle') {
throw new Error(
phase === 'stopping'
? 'database-worker-post-gc-stop-in-progress'
: 'database-worker-post-gc-capture-already-active'
);
}
lateRequestCount = 0;
phase = 'active';
},
beginStop(): void {
if (phase !== 'active') {
throw new Error('database-worker-post-gc-capture-not-active');
}
phase = 'stopping';
},
finishStop(): void {
phase = 'idle';
},
observeDatabaseRequest(): DatabaseWorkerPostGcRequestDisposition {
if (phase === 'active') {
return 'capture';
}
if (phase === 'stopping') {
lateRequestCount += 1;
return 'after-cutoff';
}
return 'outside-capture';
},
rolloverCapture(): void {
if (phase !== 'stopping') {
throw new Error('database-worker-post-gc-stop-not-in-progress');
}
if (lateRequestCount > 0) {
throw new Error('database-worker-post-gc-capture-contaminated');
}
lateRequestCount = 0;
phase = 'active';
},
snapshot(): DatabaseWorkerPostGcCutoffSnapshot {
return Object.freeze({ lateRequestCount, phase });
},
};
return Object.freeze(api);
}
@@ -0,0 +1,392 @@
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
type AncillaryFailureStage = 'heap-snapshot' | 'post-gc-probe' | 'profile-stop';
type PostGcOutcome =
| {
readonly postGcHeapUsedBytes: number;
readonly unavailableReason: null;
}
| {
readonly postGcHeapUsedBytes: null;
readonly unavailableReason: string;
};
interface FinalizationInput {
readonly finalizationKey: object;
readonly joinFinalSample: () => Promise<void>;
readonly probePostGc: () => Promise<PostGcOutcome>;
readonly reportAncillaryFailure: (
stage: AncillaryFailureStage,
error: unknown
) => void;
readonly stopProfile: () => Promise<void>;
readonly stopSampling: () => void;
readonly takeHeapSnapshot?: () => Promise<void>;
}
interface FinalizationApi {
finalize(input: FinalizationInput): Promise<PostGcOutcome>;
}
interface FinalizationModule {
createDatabaseWorkerPostGcFinalizationApi?: () => FinalizationApi;
}
const finalizationModulePromise = import(
new URL('./database-worker-post-gc-finalization.ts', import.meta.url).href
)
.then((module) => module as FinalizationModule)
.catch(() => null);
function deferred(): {
readonly promise: Promise<void>;
readonly resolve: () => void;
} {
let resolve!: () => void;
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
async function restoreSerializableApi(): Promise<FinalizationApi> {
const module = await finalizationModulePromise;
assert.ok(module, 'database worker post-GC finalization module must exist');
const factory = module.createDatabaseWorkerPostGcFinalizationApi;
assert.equal(typeof factory, 'function');
const source = factory.toString();
assert.doesNotMatch(source, /__name/);
const restoredFactory = Function(
`"use strict"; return (${source});`
)() as () => FinalizationApi;
return restoredFactory();
}
test('coordinates database worker post-GC finalization in order and single-flight', async () => {
const api = await restoreSerializableApi();
const finalSampleGate = deferred();
const events: string[] = [];
let probeCalls = 0;
const successfulPostGc = {
postGcHeapUsedBytes: 98_304,
unavailableReason: null,
} as const;
const input: FinalizationInput = {
finalizationKey: {},
joinFinalSample: async () => {
events.push('final-sample:start');
await finalSampleGate.promise;
events.push('final-sample:joined');
},
probePostGc: async () => {
probeCalls += 1;
events.push('post-gc-probe');
return successfulPostGc;
},
reportAncillaryFailure: () => {
assert.fail('the successful path has no ancillary failures');
},
stopProfile: async () => {
events.push('profile-stop');
},
stopSampling: () => {
events.push('sampling-stop');
},
takeHeapSnapshot: async () => {
events.push('heap-snapshot');
},
};
const firstFinalization = api.finalize(input);
const concurrentFinalization = api.finalize(input);
await Promise.resolve();
assert.deepEqual(events, ['sampling-stop', 'final-sample:start']);
assert.equal(probeCalls, 0);
finalSampleGate.resolve();
const [firstResult, concurrentResult] = await Promise.all([
firstFinalization,
concurrentFinalization,
]);
assert.deepEqual(events, [
'sampling-stop',
'final-sample:start',
'final-sample:joined',
'profile-stop',
'post-gc-probe',
'heap-snapshot',
]);
assert.equal(probeCalls, 1);
assert.deepEqual(firstResult, successfulPostGc);
assert.deepEqual(concurrentResult, successfulPostGc);
});
test('reports profile and snapshot failures without erasing successful post-GC', async () => {
const api = await restoreSerializableApi();
const events: string[] = [];
const profileError = new Error('profile-stop-failed');
const snapshotError = new Error('heap-snapshot-failed');
const failures: {
readonly error: unknown;
readonly stage: AncillaryFailureStage;
}[] = [];
const result = await api.finalize({
finalizationKey: {},
joinFinalSample: async () => {
events.push('final-sample:joined');
},
probePostGc: async () => {
events.push('post-gc-probe');
return {
postGcHeapUsedBytes: 131_072,
unavailableReason: null,
};
},
reportAncillaryFailure: (stage, error) => {
failures.push({ error, stage });
},
stopProfile: async () => {
events.push('profile-stop');
throw profileError;
},
stopSampling: () => {
events.push('sampling-stop');
},
takeHeapSnapshot: async () => {
events.push('heap-snapshot');
throw snapshotError;
},
});
assert.deepEqual(events, [
'sampling-stop',
'final-sample:joined',
'profile-stop',
'post-gc-probe',
'heap-snapshot',
]);
assert.deepEqual(failures, [
{ error: profileError, stage: 'profile-stop' },
{ error: snapshotError, stage: 'heap-snapshot' },
]);
assert.deepEqual(result, {
postGcHeapUsedBytes: 131_072,
unavailableReason: null,
});
});
test('fails closed when the post-GC probe throws without relabelling profile artifacts', async () => {
const api = await restoreSerializableApi();
const probeError = new Error('probe-failed');
const failures: {
readonly error: unknown;
readonly stage: AncillaryFailureStage;
}[] = [];
const result = await api.finalize({
finalizationKey: {},
joinFinalSample: async () => undefined,
probePostGc: async () => {
throw probeError;
},
reportAncillaryFailure: (stage, error) => {
failures.push({ error, stage });
},
stopProfile: async () => undefined,
stopSampling: () => undefined,
});
assert.deepEqual(result, {
postGcHeapUsedBytes: null,
unavailableReason: 'capture-failed',
});
assert.deepEqual(failures, [{ error: probeError, stage: 'post-gc-probe' }]);
});
test('the Electron main capture wires exact DB selection and explicit-GC finalization', () => {
const source = readFileSync(
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
'utf8'
);
assert.match(
source,
/databaseWorkerPostGcSelectionApiFactorySource:\s+createDatabaseWorkerPostGcSelectionApi\.toString\(\)/
);
assert.match(
source,
/databaseWorkerPostGcProbeApiFactorySource:\s+createDatabaseWorkerPostGcProbeApi\.toString\(\)/
);
assert.match(
source,
/databaseWorkerPostGcFinalizationApiFactorySource:\s+createDatabaseWorkerPostGcFinalizationApi\.toString\(\)/
);
assert.match(source, /new workerThreads\.MessageChannel\(\)/);
assert.match(
source,
/databaseWorkerPostGcSelectionApi\.select\(\s*currentWorkerRecords,\s*state\.captureGeneration\s*\)/
);
assert.match(source, /databaseWorkerPostGcFinalizationApi\s*\.finalize\(/);
assert.match(source, /pendingCount: 0/);
assert.match(source, /record\.pendingCount \+= 1/);
assert.match(
source,
/request\.record\.pendingCount = Math\.max\(\s*0,\s*request\.record\.pendingCount - 1\s*\)/
);
assert.match(source, /samplePromise/);
assert.match(source, /postGcHeapUnavailableReason/);
assert.match(source, /ordinal: nextWorkerOrdinal/);
assert.match(
source,
/`\$\{record\.kind\}-\$\{record\.ordinal\}\.cpuprofile`/
);
assert.doesNotMatch(source, /sampleBusy/);
assert.doesNotMatch(source, /const postSnapshot/);
});
test('main CPU profiling stops at the operation cutoff before worker artifact finalization', () => {
const source = readFileSync(
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
'utf8'
);
const stopStart = source.indexOf('stop: async (');
const mainProfileStop = source.indexOf("'Profiler.stop'", stopStart);
const databaseSelection = source.indexOf(
'databaseWorkerPostGcSelectionApi.select',
stopStart
);
const databaseFinalization = source.indexOf(
'await finalizeDatabaseWorker',
stopStart
);
assert.ok(stopStart >= 0);
assert.ok(mainProfileStop > stopStart);
assert.ok(mainProfileStop < databaseSelection);
assert.ok(mainProfileStop < databaseFinalization);
});
test('worker CPU profile serialization runs only after the main CPU profiler stops', () => {
const source = readFileSync(
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
'utf8'
);
const profileStopStart = source.indexOf('const stopWorkerProfile');
const profileStopEnd = source.indexOf(
'const writeWorkerProfile',
profileStopStart
);
const profileStop = source.slice(profileStopStart, profileStopEnd);
const stopStart = source.indexOf('stop: async (');
const mainProfileStop = source.indexOf("'Profiler.stop'", stopStart);
const workerProfileFlush = source.indexOf(
'flushWorkerProfiles(currentWorkerRecords)',
mainProfileStop
);
assert.match(profileStop, /const profileResult = await handle\.stop\(\)/);
assert.match(profileStop, /record\.profileResult = profileResult/);
assert.doesNotMatch(profileStop, /writeFileSync|JSON\.stringify/);
assert.ok(mainProfileStop > stopStart);
assert.ok(workerProfileFlush > mainProfileStop);
});
test('measured termination stays unperturbed while diagnostic capture finalizes its profile before termination', () => {
const source = readFileSync(
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
'utf8'
);
const terminateStart = source.indexOf(
'WorkerClass.prototype.terminate = function'
);
const terminateEnd = source.indexOf('const inspectorPost', terminateStart);
const terminateBlock = source.slice(terminateStart, terminateEnd);
const terminatingFinalizerStart = source.indexOf(
'const finalizeTerminatingWorker'
);
const terminatingFinalizerEnd = source.indexOf(
'WorkerClass.prototype.postMessage',
terminatingFinalizerStart
);
const terminatingFinalizer = source.slice(
terminatingFinalizerStart,
terminatingFinalizerEnd
);
const measuredTerminate = terminateBlock.indexOf(
'const termination = originalTerminate.call(this)'
);
const measuredFinalization = terminateBlock.indexOf(
'void finalizeTerminatingWorker(record, false)'
);
const diagnosticFinalizationStart = terminateBlock.indexOf(
'void finalizeTerminatingWorker(record, true)'
);
const diagnosticFinalizationWait = terminateBlock.indexOf(
'await waitForTerminatingWorkerFinalization(record)'
);
const diagnosticTerminate = terminateBlock.indexOf(
'return originalTerminate.call(this)',
diagnosticFinalizationWait
);
assert.ok(
diagnosticFinalizationStart >= 0 &&
diagnosticFinalizationStart < diagnosticFinalizationWait &&
diagnosticFinalizationWait < diagnosticTerminate,
'diagnostic capture must start profile finalization, bound its wait, and then terminate the worker'
);
assert.ok(
measuredTerminate >= 0 && measuredTerminate < measuredFinalization,
'measured capture must initiate termination before best-effort finalization'
);
assert.doesNotMatch(terminatingFinalizer, /takeWorkerHeapSnapshot/);
assert.match(
terminatingFinalizer,
/stopWorkerProfile\(record, waitForProfileHandle\)/
);
assert.match(source, /resolvedProfileHandle/);
assert.match(source, /worker-profile-finalization-timeout/);
assert.match(source, /finalizationTimedOut/);
assert.match(source, /profileCaptureKey/);
assert.match(source, /record\.profileCaptureKey !== profileCaptureKey/);
});
test('worker capture failures use an artifact-neutral timeline label', () => {
const source = readFileSync(
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
'utf8'
);
assert.match(source, /worker-artifact-error:\$\{stage\}/);
assert.doesNotMatch(source, /worker-profile-error:\$\{stage\}/);
});
test('capture generation resets artifact paths and always emits current DB records', () => {
const source = readFileSync(
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
'utf8'
);
const start = source.indexOf('const startCapture = async (');
const diagnostic = source.indexOf('if (state.diagnostic)', start);
const profileReset = source.indexOf('state.mainProfilePath = null', start);
const snapshotReset = source.indexOf(
'state.mainSnapshotPath = null',
start
);
assert.ok(profileReset > start && profileReset < diagnostic);
assert.ok(snapshotReset > start && snapshotReset < diagnostic);
assert.match(
source,
/record\.captureGeneration === state\.captureGeneration[\s\S]*record\.kind === 'database\.worker'/
);
});
@@ -0,0 +1,90 @@
import type { DatabaseWorkerPostGcProbeUnavailableReason } from './database-worker-post-gc-probe';
import type { DatabaseWorkerPostGcUnavailableReason } from './database-worker-post-gc-selection';
export type DatabaseWorkerPostGcAncillaryFailureStage =
'heap-snapshot' | 'post-gc-probe' | 'profile-stop' | 'profile-write';
export type DatabaseWorkerPostGcFinalizationOutcome =
| {
readonly postGcHeapUsedBytes: number;
readonly unavailableReason: null;
}
| {
readonly postGcHeapUsedBytes: null;
readonly unavailableReason:
| DatabaseWorkerPostGcProbeUnavailableReason
| DatabaseWorkerPostGcUnavailableReason;
};
export interface DatabaseWorkerPostGcFinalizationInput {
readonly finalizationKey: object;
readonly joinFinalSample: () => Promise<void>;
readonly probePostGc: () => Promise<DatabaseWorkerPostGcFinalizationOutcome>;
readonly reportAncillaryFailure: (
stage: DatabaseWorkerPostGcAncillaryFailureStage,
error: unknown
) => void;
readonly stopProfile: () => Promise<void>;
readonly stopSampling: () => void;
readonly takeHeapSnapshot?: () => Promise<void>;
}
export interface DatabaseWorkerPostGcFinalizationApi {
finalize(
input: DatabaseWorkerPostGcFinalizationInput
): Promise<DatabaseWorkerPostGcFinalizationOutcome>;
}
export function createDatabaseWorkerPostGcFinalizationApi(): DatabaseWorkerPostGcFinalizationApi {
const finalizations = new WeakMap<
object,
Promise<DatabaseWorkerPostGcFinalizationOutcome>
>();
const helpers = {
async run(
input: DatabaseWorkerPostGcFinalizationInput
): Promise<DatabaseWorkerPostGcFinalizationOutcome> {
input.stopSampling();
await input.joinFinalSample();
try {
await input.stopProfile();
} catch (error: unknown) {
input.reportAncillaryFailure('profile-stop', error);
}
let outcome: DatabaseWorkerPostGcFinalizationOutcome;
try {
outcome = await input.probePostGc();
} catch (error: unknown) {
input.reportAncillaryFailure('post-gc-probe', error);
outcome = {
postGcHeapUsedBytes: null,
unavailableReason: 'capture-failed',
};
}
if (input.takeHeapSnapshot) {
try {
await input.takeHeapSnapshot();
} catch (error: unknown) {
input.reportAncillaryFailure('heap-snapshot', error);
}
}
return outcome;
},
finalize(
input: DatabaseWorkerPostGcFinalizationInput
): Promise<DatabaseWorkerPostGcFinalizationOutcome> {
const existing = finalizations.get(input.finalizationKey);
if (existing) {
return existing;
}
const finalization = helpers.run(input);
finalizations.set(input.finalizationKey, finalization);
return finalization;
},
};
return Object.freeze({ finalize: helpers.finalize });
}
@@ -0,0 +1,469 @@
/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */
/* eslint-disable max-lines -- The one-shot transport contract keeps all terminal-path fixtures together. */
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import test from 'node:test';
type WorkerUnavailableReason =
'capture-failed' | 'gc-unavailable' | 'profiling-disabled' | 'worker-busy';
type MainUnavailableReason =
| 'post-gc-probe-invalid-response'
| 'post-gc-probe-message-error'
| 'post-gc-probe-port-closed'
| 'post-gc-probe-post-failed'
| 'post-gc-probe-timeout';
type ProbeUnavailableReason = WorkerUnavailableReason | MainUnavailableReason;
type ProbeResult =
| {
readonly postGcHeapUsedBytes: number;
readonly unavailableReason: null;
}
| {
readonly postGcHeapUsedBytes: null;
readonly unavailableReason: ProbeUnavailableReason;
};
interface ProbePort {
close(): void;
off(event: 'close', listener: () => void): unknown;
off(event: 'message', listener: (message: unknown) => void): unknown;
off(event: 'messageerror', listener: (error: unknown) => void): unknown;
on(event: 'close', listener: () => void): unknown;
on(event: 'message', listener: (message: unknown) => void): unknown;
on(event: 'messageerror', listener: (error: unknown) => void): unknown;
postMessage(message: unknown): void;
start(): void;
}
interface ProbeWorker {
postMessage(message: unknown, transferList: readonly unknown[]): void;
}
interface ProbeTimers {
clearTimeout(handle: unknown): void;
setTimeout(callback: () => void, delayMs: number): unknown;
}
interface ProbeApi {
probe(input: {
readonly createMessageChannel: () => {
readonly port1: ProbePort;
readonly port2: ProbePort;
};
readonly timeoutMs?: number;
readonly timers?: ProbeTimers;
readonly worker: ProbeWorker;
}): Promise<ProbeResult>;
}
interface ProbeModule {
createDatabaseWorkerPostGcProbeApi?: () => ProbeApi;
}
interface ProductionWorkerPostGcModule {
DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON?: Readonly<
Record<string, WorkerUnavailableReason>
>;
}
const probeModulePromise = import(
new URL('./database-worker-post-gc-probe.ts', import.meta.url).href
)
.then((module) => module as ProbeModule)
.catch(() => null);
const productionWorkerPostGcModulePromise = import(
new URL(
'../../../electron-backend/src/app/workers/database-worker-post-gc-heap.ts',
import.meta.url
).href
)
.then((module) => module as ProductionWorkerPostGcModule)
.catch(() => null);
class FakePort extends EventEmitter implements ProbePort {
closeCalls = 0;
peer: FakePort | null = null;
startCalls = 0;
close(): void {
this.closeCalls += 1;
}
postMessage(message: unknown): void {
this.peer?.emit('message', message);
}
start(): void {
this.startCalls += 1;
}
}
class FakeTimers implements ProbeTimers {
readonly cleared: unknown[] = [];
readonly scheduled: {
readonly callback: () => void;
readonly delayMs: number;
readonly handle: number;
}[] = [];
private nextHandle = 1;
clearTimeout(handle: unknown): void {
this.cleared.push(handle);
}
fire(handle: number): void {
const timer = this.scheduled.find(
(candidate) => candidate.handle === handle
);
assert.ok(timer, `timer ${handle} must exist`);
timer.callback();
}
setTimeout(callback: () => void, delayMs: number): number {
const handle = this.nextHandle;
this.nextHandle += 1;
this.scheduled.push({ callback, delayMs, handle });
return handle;
}
}
interface ProbeHarness {
readonly createMessageChannel: () => {
readonly port1: FakePort;
readonly port2: FakePort;
};
readonly port1: FakePort;
readonly port2: FakePort;
readonly timers: FakeTimers;
}
function createProbeHarness(): ProbeHarness {
const port1 = new FakePort();
const port2 = new FakePort();
port1.peer = port2;
port2.peer = port1;
const timers = new FakeTimers();
return {
createMessageChannel: () => ({ port1, port2 }),
port1,
port2,
timers,
};
}
async function restoreSerializableApi(): Promise<ProbeApi> {
const module = await probeModulePromise;
assert.ok(module, 'database worker post-GC probe module must exist');
const factory = module.createDatabaseWorkerPostGcProbeApi;
assert.equal(typeof factory, 'function');
const source = factory.toString();
assert.doesNotMatch(source, /__name/);
const restoredFactory = Function(
`"use strict"; return (${source});`
)() as () => ProbeApi;
return restoredFactory();
}
function assertCoherentResult(result: ProbeResult): void {
const hasHeap =
Number.isSafeInteger(result.postGcHeapUsedBytes) &&
Number(result.postGcHeapUsedBytes) >= 0;
assert.equal(
hasHeap,
result.unavailableReason === null,
'probe result must preserve the heap/reason XOR'
);
}
function cleanupCounts(port: FakePort): Record<string, number> {
return {
close: port.listenerCount('close'),
message: port.listenerCount('message'),
messageerror: port.listenerCount('messageerror'),
};
}
test('serializes the factory and sends the exact one-shot worker request with its transfer port', async () => {
const api = await restoreSerializableApi();
const harness = createProbeHarness();
let postedMessage: unknown;
let postedTransferList: readonly unknown[] | null = null;
let terminateCalls = 0;
const worker = {
postMessage(message: unknown, transferList: readonly unknown[]): void {
postedMessage = message;
postedTransferList = transferList;
const responsePort = (
message as { readonly responsePort: ProbePort }
).responsePort;
responsePort.postMessage({
type: 'performance:post-gc-heap-result',
postGcHeapUsedBytes: 73_728,
unavailableReason: null,
});
},
terminate(): void {
terminateCalls += 1;
},
};
const result = await api.probe({
createMessageChannel: harness.createMessageChannel,
timers: harness.timers,
worker,
});
assert.deepEqual(postedMessage, {
type: 'performance:collect-post-gc-heap',
responsePort: harness.port2,
});
assert.deepEqual(postedTransferList, [harness.port2]);
assert.deepEqual(result, {
postGcHeapUsedBytes: 73_728,
unavailableReason: null,
});
assertCoherentResult(result);
assert.equal(harness.port1.startCalls, 1);
assert.equal(harness.port1.closeCalls, 1);
assert.deepEqual(cleanupCounts(harness.port1), {
close: 0,
message: 0,
messageerror: 0,
});
assert.deepEqual(
harness.timers.scheduled.map((timer) => timer.delayMs),
[5_000]
);
assert.deepEqual(harness.timers.cleared, [1]);
assert.equal(terminateCalls, 0);
});
test('preserves every coherent worker-side unavailable result', async () => {
const api = await restoreSerializableApi();
const productionModule = await productionWorkerPostGcModulePromise;
assert.ok(productionModule, 'production worker post-GC module must load');
const productionReasons =
productionModule.DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON;
assert.ok(productionReasons, 'production worker reasons must be exported');
const reasons = Object.values(productionReasons);
assert.deepEqual(
new Set(reasons),
new Set<WorkerUnavailableReason>([
'capture-failed',
'gc-unavailable',
'profiling-disabled',
'worker-busy',
])
);
for (const unavailableReason of reasons) {
const harness = createProbeHarness();
const result = await api.probe({
createMessageChannel: harness.createMessageChannel,
timers: harness.timers,
worker: {
postMessage(message: unknown): void {
(
message as { readonly responsePort: ProbePort }
).responsePort.postMessage({
type: 'performance:post-gc-heap-result',
postGcHeapUsedBytes: null,
unavailableReason,
});
},
},
});
assert.deepEqual(result, {
postGcHeapUsedBytes: null,
unavailableReason,
});
assertCoherentResult(result);
}
});
test('fails closed for malformed or incoherent worker responses', async () => {
const api = await restoreSerializableApi();
const malformedResponses: readonly unknown[] = [
null,
[],
{
type: 'wrong-result',
postGcHeapUsedBytes: 1,
unavailableReason: null,
},
{
type: 'performance:post-gc-heap-result',
postGcHeapUsedBytes: 1,
unavailableReason: 'capture-failed',
},
{
type: 'performance:post-gc-heap-result',
postGcHeapUsedBytes: null,
unavailableReason: null,
},
{
type: 'performance:post-gc-heap-result',
postGcHeapUsedBytes: null,
unavailableReason: 'unknown-reason',
},
...[-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY].map(
(postGcHeapUsedBytes) => ({
type: 'performance:post-gc-heap-result',
postGcHeapUsedBytes,
unavailableReason: null,
})
),
{
type: 'performance:post-gc-heap-result',
postGcHeapUsedBytes: Number.MAX_SAFE_INTEGER + 1,
unavailableReason: null,
},
];
for (const response of malformedResponses) {
const harness = createProbeHarness();
const result = await api.probe({
createMessageChannel: harness.createMessageChannel,
timers: harness.timers,
worker: {
postMessage(message: unknown): void {
(
message as { readonly responsePort: ProbePort }
).responsePort.postMessage(response);
},
},
});
assert.deepEqual(result, {
postGcHeapUsedBytes: null,
unavailableReason: 'post-gc-probe-invalid-response',
});
assertCoherentResult(result);
}
});
test('times out once with an injectable deadline and ignores every later terminal signal', async () => {
const api = await restoreSerializableApi();
const harness = createProbeHarness();
let terminateCalls = 0;
const resultPromise = api.probe({
createMessageChannel: harness.createMessageChannel,
timeoutMs: 37,
timers: harness.timers,
worker: {
postMessage(): void {
// Keep the one-shot probe pending until its bounded deadline.
},
terminate(): void {
terminateCalls += 1;
},
},
});
const timer = harness.timers.scheduled[0];
assert.ok(timer);
assert.equal(timer.delayMs, 37);
harness.timers.fire(timer.handle);
const result = await resultPromise;
harness.port1.emit('message', {
type: 'performance:post-gc-heap-result',
postGcHeapUsedBytes: 99,
unavailableReason: null,
});
harness.port1.emit('messageerror', new Error('late message error'));
harness.port1.emit('close');
timer.callback();
assert.deepEqual(result, {
postGcHeapUsedBytes: null,
unavailableReason: 'post-gc-probe-timeout',
});
assertCoherentResult(result);
assert.equal(harness.port1.closeCalls, 1);
assert.deepEqual(cleanupCounts(harness.port1), {
close: 0,
message: 0,
messageerror: 0,
});
assert.deepEqual(harness.timers.cleared, [timer.handle]);
assert.equal(terminateCalls, 0);
});
test('maps message errors and an early response-port close to fixed reasons', async () => {
const api = await restoreSerializableApi();
const cases = [
{
emit(port: FakePort): void {
port.emit('messageerror', new Error('clone failed'));
},
reason: 'post-gc-probe-message-error',
},
{
emit(port: FakePort): void {
port.emit('close');
},
reason: 'post-gc-probe-port-closed',
},
] as const;
for (const testCase of cases) {
const harness = createProbeHarness();
const resultPromise = api.probe({
createMessageChannel: harness.createMessageChannel,
timers: harness.timers,
worker: {
postMessage(): void {
testCase.emit(harness.port1);
},
},
});
const result = await resultPromise;
assert.deepEqual(result, {
postGcHeapUsedBytes: null,
unavailableReason: testCase.reason,
});
assertCoherentResult(result);
assert.equal(harness.port1.closeCalls, 1);
assert.deepEqual(cleanupCounts(harness.port1), {
close: 0,
message: 0,
messageerror: 0,
});
}
});
test('closes both ports and reports a fixed reason when the request cannot be posted', async () => {
const api = await restoreSerializableApi();
const harness = createProbeHarness();
const result = await api.probe({
createMessageChannel: harness.createMessageChannel,
timers: harness.timers,
worker: {
postMessage(): void {
throw new Error('worker already exited');
},
},
});
assert.deepEqual(result, {
postGcHeapUsedBytes: null,
unavailableReason: 'post-gc-probe-post-failed',
});
assertCoherentResult(result);
assert.equal(harness.port1.closeCalls, 1);
assert.equal(harness.port2.closeCalls, 1);
assert.deepEqual(cleanupCounts(harness.port1), {
close: 0,
message: 0,
messageerror: 0,
});
assert.deepEqual(harness.timers.cleared, [1]);
});
@@ -0,0 +1,242 @@
export type DatabaseWorkerPostGcProbeUnavailableReason =
| 'capture-failed'
| 'gc-unavailable'
| 'post-gc-probe-invalid-response'
| 'post-gc-probe-message-error'
| 'post-gc-probe-port-closed'
| 'post-gc-probe-post-failed'
| 'post-gc-probe-timeout'
| 'profiling-disabled'
| 'worker-busy';
export type DatabaseWorkerPostGcProbeResult =
| {
readonly postGcHeapUsedBytes: number;
readonly unavailableReason: null;
}
| {
readonly postGcHeapUsedBytes: null;
readonly unavailableReason: DatabaseWorkerPostGcProbeUnavailableReason;
};
export interface DatabaseWorkerPostGcProbePort {
close(): void;
off(event: 'close', listener: () => void): unknown;
off(event: 'message', listener: (message: unknown) => void): unknown;
off(event: 'messageerror', listener: (error: unknown) => void): unknown;
on(event: 'close', listener: () => void): unknown;
on(event: 'message', listener: (message: unknown) => void): unknown;
on(event: 'messageerror', listener: (error: unknown) => void): unknown;
start(): void;
}
export interface DatabaseWorkerPostGcProbeWorker {
postMessage(message: unknown, transferList: readonly unknown[]): void;
}
export interface DatabaseWorkerPostGcProbeTimers {
clearTimeout(handle: unknown): void;
setTimeout(callback: () => void, delayMs: number): unknown;
}
export interface DatabaseWorkerPostGcProbeInput {
readonly createMessageChannel: () => {
readonly port1: DatabaseWorkerPostGcProbePort;
readonly port2: DatabaseWorkerPostGcProbePort;
};
readonly timeoutMs?: number;
readonly timers?: DatabaseWorkerPostGcProbeTimers;
readonly worker: DatabaseWorkerPostGcProbeWorker;
}
export interface DatabaseWorkerPostGcProbeApi {
probe(
input: DatabaseWorkerPostGcProbeInput
): Promise<DatabaseWorkerPostGcProbeResult>;
}
export function createDatabaseWorkerPostGcProbeApi(): DatabaseWorkerPostGcProbeApi {
type JsonRecord = Record<string, unknown>;
const DEFAULT_TIMEOUT_MS = 5_000;
const REQUEST_TYPE = 'performance:collect-post-gc-heap';
const RESULT_TYPE = 'performance:post-gc-heap-result';
const workerUnavailableReasons = new Set([
'capture-failed',
'gc-unavailable',
'profiling-disabled',
'worker-busy',
]);
const defaultTimers: DatabaseWorkerPostGcProbeTimers = {
clearTimeout(handle: unknown): void {
globalThis.clearTimeout(
handle as ReturnType<typeof globalThis.setTimeout>
);
},
setTimeout(callback: () => void, delayMs: number): unknown {
return globalThis.setTimeout(callback, delayMs);
},
};
const helpers = {
unavailable(
unavailableReason: DatabaseWorkerPostGcProbeUnavailableReason
): DatabaseWorkerPostGcProbeResult {
return Object.freeze({
postGcHeapUsedBytes: null,
unavailableReason,
});
},
normalizeResponse(response: unknown): DatabaseWorkerPostGcProbeResult {
if (
typeof response !== 'object' ||
response === null ||
Array.isArray(response)
) {
return helpers.unavailable('post-gc-probe-invalid-response');
}
const candidate = response as JsonRecord;
const postGcHeapUsedBytes = candidate['postGcHeapUsedBytes'];
const unavailableReason = candidate['unavailableReason'];
if (
candidate['type'] === RESULT_TYPE &&
Number.isSafeInteger(postGcHeapUsedBytes) &&
Number(postGcHeapUsedBytes) >= 0 &&
unavailableReason === null
) {
return Object.freeze({
postGcHeapUsedBytes: Number(postGcHeapUsedBytes),
unavailableReason: null,
});
}
if (
candidate['type'] === RESULT_TYPE &&
postGcHeapUsedBytes === null &&
typeof unavailableReason === 'string' &&
workerUnavailableReasons.has(unavailableReason)
) {
return helpers.unavailable(
unavailableReason as DatabaseWorkerPostGcProbeUnavailableReason
);
}
return helpers.unavailable('post-gc-probe-invalid-response');
},
probe(
input: DatabaseWorkerPostGcProbeInput
): Promise<DatabaseWorkerPostGcProbeResult> {
let channel: ReturnType<typeof input.createMessageChannel>;
try {
channel = input.createMessageChannel();
} catch {
return Promise.resolve(
helpers.unavailable('post-gc-probe-post-failed')
);
}
const { port1, port2 } = channel;
const timers = input.timers ?? defaultTimers;
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return new Promise((resolve) => {
let settled = false;
let timerHandle: unknown;
let timerScheduled = false;
const callbacks = {
cleanup(): void {
try {
port1.off('message', callbacks.onMessage);
} catch {
// Best-effort profiling cleanup must not escape.
}
try {
port1.off('messageerror', callbacks.onMessageError);
} catch {
// Best-effort profiling cleanup must not escape.
}
try {
port1.off('close', callbacks.onClose);
} catch {
// Best-effort profiling cleanup must not escape.
}
if (timerScheduled) {
try {
timers.clearTimeout(timerHandle);
} catch {
// Best-effort profiling cleanup must not escape.
}
}
try {
port1.close();
} catch {
// The one-shot response port may already be closed.
}
},
settle(result: DatabaseWorkerPostGcProbeResult): void {
if (settled) {
return;
}
settled = true;
callbacks.cleanup();
resolve(result);
},
onClose(): void {
callbacks.settle(
helpers.unavailable('post-gc-probe-port-closed')
);
},
onMessage(message: unknown): void {
callbacks.settle(helpers.normalizeResponse(message));
},
onMessageError(): void {
callbacks.settle(
helpers.unavailable('post-gc-probe-message-error')
);
},
onTimeout(): void {
callbacks.settle(
helpers.unavailable('post-gc-probe-timeout')
);
},
};
try {
port1.on('message', callbacks.onMessage);
port1.on('messageerror', callbacks.onMessageError);
port1.on('close', callbacks.onClose);
port1.start();
timerHandle = timers.setTimeout(
callbacks.onTimeout,
timeoutMs
);
timerScheduled = true;
input.worker.postMessage(
{
type: REQUEST_TYPE,
responsePort: port2,
},
[port2]
);
} catch {
callbacks.settle(
helpers.unavailable('post-gc-probe-post-failed')
);
try {
port2.close();
} catch {
// A synchronous transfer failure may already close it.
}
}
});
},
};
return Object.freeze({ probe: helpers.probe });
}
@@ -0,0 +1,131 @@
/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */
import assert from 'node:assert/strict';
import test from 'node:test';
import {
type CancellationBenchmarkManifest,
type CancellationIterationResult,
PERFORMANCE_ITERATION_KIND,
PERFORMANCE_WORKER_KIND,
type WorkerCaptureMetrics,
} from './m3u-refresh-cancellation-contract';
import { createCancellationBenchmarkSummary } from './m3u-refresh-cancellation-report';
function databaseWorker(
postGcHeapUsedBytes: number | null,
postGcHeapUnavailableReason: string | null
): WorkerCaptureMetrics {
return {
kind: PERFORMANCE_WORKER_KIND.DATABASE,
peakExternalBytes: 0,
peakHeapUsedBytes: 0,
postGcHeapUnavailableReason,
postGcHeapUsedBytes,
requests: [],
} as unknown as WorkerCaptureMetrics;
}
function measuredIteration(
workers: readonly WorkerCaptureMetrics[]
): CancellationIterationResult {
return {
cancellationEffectObserved: true,
kind: PERFORMANCE_ITERATION_KIND.MEASURED,
main: {
eventLoopDelay: { maxMs: 0, p95Ms: 0, p99Ms: 0 },
eventLoopUtilization: null,
memory: {
peakHeapUsedBytes: 0,
peakRssBytes: 0,
postGcHeapUsedBytes: null,
postGcRssBytes: null,
},
rendererWindow: {
responsiveEvents: 0,
rss: {
identity: {
creationTime: 1_721_234_567_890,
pid: 42,
},
missingSampleCount: 0,
peakRssBytes: 2_048,
unavailableReason: null,
validSampleCount: 1,
},
unresponsiveEvents: 0,
windowIdentity: {
browserWindowId: 7,
webContentsId: 11,
},
},
workers,
},
phases: {},
renderer: {
peakHeapUsedBytes: 0,
postGcHeapUsedBytes: null,
probe: {
frameGapsMs: [],
heartbeatDelaysMs: [],
longTasksMs: [],
},
},
runId: 'measured',
} as unknown as CancellationIterationResult;
}
test('summary includes every database isolate instead of silently taking the first', () => {
const unavailable = databaseWorker(null, 'post-gc-probe-invalid-response');
const summary = createCancellationBenchmarkSummary(
{} as CancellationBenchmarkManifest,
[
measuredIteration([
databaseWorker(100, null),
databaseWorker(300, null),
unavailable,
]),
]
);
assert.deepEqual(summary.measured.databaseWorkerPostGcHeapBytes, {
count: 2,
max: 300,
mean: 200,
median: 200,
min: 100,
p95: 290,
p99: 298,
});
assert.deepEqual(
(
summary as unknown as {
readonly validity: {
readonly databaseWorkerPostGc: unknown;
};
}
).validity.databaseWorkerPostGc,
{
applicableMeasuredRunCount: 1,
invalidMeasuredRuns: [
{
databaseWorkerCount: 3,
reason: 'database-worker-unexpected-activity',
runId: 'measured',
},
],
measuredRunCount: 1,
notApplicableMeasuredRuns: [],
validForBenchmark: false,
validForComparison: false,
validMeasuredRunCount: 0,
}
);
assert.equal(
(
summary.iterations[0]?.main.workers[2] as WorkerCaptureMetrics & {
readonly postGcHeapUnavailableReason: string | null;
}
)?.postGcHeapUnavailableReason,
'post-gc-probe-invalid-response'
);
});
@@ -0,0 +1,145 @@
/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */
import assert from 'node:assert/strict';
import test from 'node:test';
type WorkerKind = 'database.worker' | 'playlist-refresh.worker';
interface TestWorkerRecord {
readonly captureGeneration: number | null;
readonly finalized: boolean;
readonly kind: WorkerKind;
readonly ordinal: number;
readonly pendingCount: number;
readonly sampleTimer: object | null;
}
type SelectionReason =
| 'database-worker-missing'
| 'database-worker-not-idle'
| 'multiple-database-workers';
interface Selection<T> {
readonly selected: T | null;
readonly unavailableReason: SelectionReason | null;
}
type Selector = <T extends TestWorkerRecord>(
records: readonly T[],
activeGeneration: number
) => Selection<T>;
interface SelectionModule {
createDatabaseWorkerPostGcSelectionApi?: () => {
readonly select: Selector;
};
}
const selectionModulePromise = import(
new URL('./database-worker-post-gc-selection.ts', import.meta.url).href
)
.then((module) => module as SelectionModule)
.catch(() => null);
function workerRecord(
ordinal: number,
overrides: Partial<TestWorkerRecord> = {}
): TestWorkerRecord {
return {
captureGeneration: 7,
finalized: true,
kind: 'database.worker',
ordinal,
pendingCount: 0,
sampleTimer: null,
...overrides,
};
}
async function restoreSerializableSelector(): Promise<Selector> {
const module = await selectionModulePromise;
assert.ok(module, 'database worker post-GC selector module must exist');
const factory = module.createDatabaseWorkerPostGcSelectionApi;
assert.equal(typeof factory, 'function');
const source = factory.toString();
assert.doesNotMatch(source, /__name/);
const restoredFactory = Function(
`"use strict"; return (${source});`
)() as () => {
readonly select: Selector;
};
return restoredFactory().select;
}
test('selects the only current-generation database worker independently of sampling and finalization state', async () => {
const select = await restoreSerializableSelector();
const currentDatabase = workerRecord(4, {
finalized: true,
sampleTimer: null,
});
const previousDatabase = workerRecord(1, {
captureGeneration: 6,
finalized: false,
sampleTimer: {},
});
const currentPlaylist = workerRecord(2, {
finalized: false,
kind: 'playlist-refresh.worker',
sampleTimer: {},
});
const selection = select(
[previousDatabase, currentPlaylist, currentDatabase],
7
);
assert.equal(selection.selected, currentDatabase);
assert.deepEqual(selection, {
selected: currentDatabase,
unavailableReason: null,
});
});
test('reports a missing database worker after ignoring playlists and previous generations', async () => {
const select = await restoreSerializableSelector();
assert.deepEqual(
select(
[
workerRecord(1, { captureGeneration: 6 }),
workerRecord(2, { kind: 'playlist-refresh.worker' }),
],
7
),
{
selected: null,
unavailableReason: 'database-worker-missing',
}
);
});
test('fails closed for multiple current-generation database workers instead of taking the first', async () => {
const select = await restoreSerializableSelector();
const first = workerRecord(1, {
finalized: false,
sampleTimer: {},
});
const second = workerRecord(2, {
finalized: true,
sampleTimer: null,
});
assert.deepEqual(select([first, second], 7), {
selected: null,
unavailableReason: 'multiple-database-workers',
});
});
test('fails closed while the only current-generation database worker is not idle', async () => {
const select = await restoreSerializableSelector();
assert.deepEqual(select([workerRecord(3, { pendingCount: 2 })], 7), {
selected: null,
unavailableReason: 'database-worker-not-idle',
});
});
@@ -0,0 +1,70 @@
export type DatabaseWorkerPostGcUnavailableReason =
| 'database-worker-missing'
| 'database-worker-not-idle'
| 'multiple-database-workers';
export interface DatabaseWorkerPostGcSelectionRecord {
readonly captureGeneration: number | null;
readonly kind: string;
readonly ordinal: number;
readonly pendingCount: number;
}
export interface DatabaseWorkerPostGcSelection<T> {
readonly selected: T | null;
readonly unavailableReason: DatabaseWorkerPostGcUnavailableReason | null;
}
export interface DatabaseWorkerPostGcSelectionApi {
select<T extends DatabaseWorkerPostGcSelectionRecord>(
records: readonly T[],
activeGeneration: number
): DatabaseWorkerPostGcSelection<T>;
}
export function createDatabaseWorkerPostGcSelectionApi(): DatabaseWorkerPostGcSelectionApi {
const helpers = {
select<T extends DatabaseWorkerPostGcSelectionRecord>(
records: readonly T[],
activeGeneration: number
): DatabaseWorkerPostGcSelection<T> {
const currentDatabaseWorkers: T[] = [];
for (const record of records) {
if (
record.captureGeneration === activeGeneration &&
record.kind === 'database.worker'
) {
currentDatabaseWorkers.push(record);
}
}
if (currentDatabaseWorkers.length === 0) {
return Object.freeze({
selected: null,
unavailableReason: 'database-worker-missing',
});
}
if (currentDatabaseWorkers.length > 1) {
return Object.freeze({
selected: null,
unavailableReason: 'multiple-database-workers',
});
}
const selected = currentDatabaseWorkers[0] as T;
if (selected.pendingCount > 0) {
return Object.freeze({
selected: null,
unavailableReason: 'database-worker-not-idle',
});
}
return Object.freeze({
selected,
unavailableReason: null,
});
},
};
return Object.freeze({ select: helpers.select });
}
@@ -0,0 +1,392 @@
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
type IterationKind = 'diagnostic' | 'measured' | 'warmup';
type WorkerKind = 'database.worker' | 'playlist-refresh.worker';
interface TestWorkerCapture {
readonly kind: WorkerKind;
readonly postGcHeapUnavailableReason: string | null;
readonly postGcHeapUsedBytes: number | null;
}
interface TestIteration {
readonly cancellationEffectObserved: boolean;
readonly kind: IterationKind;
readonly main: {
readonly timeline: readonly {
readonly type: string;
}[];
readonly workers: readonly TestWorkerCapture[];
};
readonly runId: string;
}
interface InvalidMeasuredRun {
readonly databaseWorkerCount: number;
readonly reason: string;
readonly runId: string;
}
interface DatabaseWorkerPostGcValidity {
readonly applicableMeasuredRunCount: number;
readonly invalidMeasuredRuns: readonly InvalidMeasuredRun[];
readonly measuredRunCount: number;
readonly notApplicableMeasuredRuns: readonly {
readonly reason: string;
readonly runId: string;
}[];
readonly validForBenchmark: boolean;
readonly validForComparison: boolean;
readonly validMeasuredRunCount: number;
}
interface ValidityModule {
assessDatabaseWorkerPostGcValidity?: (
iterations: readonly TestIteration[]
) => DatabaseWorkerPostGcValidity;
}
const validityModulePromise = import(
new URL('./database-worker-post-gc-validity.ts', import.meta.url).href
)
.then((module) => module as ValidityModule)
.catch(() => null);
function worker(
postGcHeapUsedBytes: number | null,
postGcHeapUnavailableReason: string | null,
kind: WorkerKind = 'database.worker'
): TestWorkerCapture {
return {
kind,
postGcHeapUnavailableReason,
postGcHeapUsedBytes,
};
}
function iteration(
runId: string,
kind: IterationKind,
workers: readonly TestWorkerCapture[],
timeline: readonly { readonly type: string }[] = [],
cancellationEffectObserved = false
): TestIteration {
return {
cancellationEffectObserved,
kind,
main: { timeline, workers },
runId,
};
}
test('validates database post-GC heap per measured iteration without losing raw reasons', async () => {
const module = await validityModulePromise;
assert.ok(module, 'database worker post-GC validity helper must exist');
const assess = module.assessDatabaseWorkerPostGcValidity;
assert.equal(typeof assess, 'function');
const valid = assess([
iteration('warmup-broken', 'warmup', []),
iteration('diagnostic-broken', 'diagnostic', [
worker(null, 'gc-unavailable'),
worker(100, null),
]),
iteration('measured-valid', 'measured', [
worker(4_096, null),
worker(
null,
'worker-force-terminated-before-gc',
'playlist-refresh.worker'
),
]),
]);
assert.deepEqual(valid, {
applicableMeasuredRunCount: 1,
invalidMeasuredRuns: [],
measuredRunCount: 1,
notApplicableMeasuredRuns: [],
validForBenchmark: true,
validForComparison: true,
validMeasuredRunCount: 1,
});
const compensatingCardinality = assess([
iteration('measured-duplicate', 'measured', [
worker(1_024, null),
worker(2_048, null),
]),
iteration(
'measured-missing',
'measured',
[worker(3_072, null, 'playlist-refresh.worker')],
[{ type: 'db-request' }]
),
]);
assert.deepEqual(compensatingCardinality, {
applicableMeasuredRunCount: 2,
invalidMeasuredRuns: [
{
databaseWorkerCount: 2,
reason: 'multiple-database-workers',
runId: 'measured-duplicate',
},
{
databaseWorkerCount: 0,
reason: 'database-worker-missing',
runId: 'measured-missing',
},
],
measuredRunCount: 2,
notApplicableMeasuredRuns: [],
validForBenchmark: false,
validForComparison: false,
validMeasuredRunCount: 0,
});
const unavailableWorker = worker(null, 'post-gc-probe-timeout');
const unavailableAndIncoherent = assess([
iteration('measured-unavailable', 'measured', [unavailableWorker]),
iteration('measured-incoherent', 'measured', [
worker(8_192, 'gc-unavailable'),
]),
]);
assert.deepEqual(unavailableAndIncoherent, {
applicableMeasuredRunCount: 2,
invalidMeasuredRuns: [
{
databaseWorkerCount: 1,
reason: 'post-gc-probe-timeout',
runId: 'measured-unavailable',
},
{
databaseWorkerCount: 1,
reason: 'post-gc-capture-invalid',
runId: 'measured-incoherent',
},
],
measuredRunCount: 2,
notApplicableMeasuredRuns: [],
validForBenchmark: false,
validForComparison: false,
validMeasuredRunCount: 0,
});
assert.equal(
unavailableWorker.postGcHeapUnavailableReason,
'post-gc-probe-timeout'
);
});
test('marks parsing cancellation before persistence as DB N/A without accepting late or missing captured DB work', async () => {
const module = await validityModulePromise;
assert.ok(module);
const assess = module.assessDatabaseWorkerPostGcValidity;
assert.equal(typeof assess, 'function');
const noDatabasePhase = assess([
iteration(
'run-01',
'measured',
[
worker(
null,
'worker-force-terminated-before-gc',
'playlist-refresh.worker'
),
],
[],
true
),
iteration(
'run-02',
'measured',
[
worker(
null,
'worker-force-terminated-before-gc',
'playlist-refresh.worker'
),
],
[],
true
),
]);
assert.deepEqual(noDatabasePhase, {
applicableMeasuredRunCount: 0,
invalidMeasuredRuns: [],
measuredRunCount: 2,
notApplicableMeasuredRuns: [
{
reason: 'operation-cancelled-before-database-phase',
runId: 'run-01',
},
{
reason: 'operation-cancelled-before-database-phase',
runId: 'run-02',
},
],
validForBenchmark: true,
validForComparison: false,
validMeasuredRunCount: 0,
});
const mixedApplicability = assess([
iteration('run-cancelled', 'measured', [], [], true),
iteration('run-persisted', 'measured', [worker(4_096, null)]),
]);
assert.deepEqual(mixedApplicability, {
applicableMeasuredRunCount: 1,
invalidMeasuredRuns: [],
measuredRunCount: 2,
notApplicableMeasuredRuns: [
{
reason: 'operation-cancelled-before-database-phase',
runId: 'run-cancelled',
},
],
validForBenchmark: true,
validForComparison: false,
validMeasuredRunCount: 1,
});
const lateDatabaseWork = assess([
iteration(
'run-late',
'measured',
[],
[{ type: 'db-request-after-capture-cutoff' }],
true
),
]);
assert.deepEqual(lateDatabaseWork, {
applicableMeasuredRunCount: 1,
invalidMeasuredRuns: [
{
databaseWorkerCount: 0,
reason: 'database-worker-activity-after-cutoff',
runId: 'run-late',
},
],
measuredRunCount: 1,
notApplicableMeasuredRuns: [],
validForBenchmark: false,
validForComparison: false,
validMeasuredRunCount: 0,
});
const unrelatedDatabaseWorker = assess([
iteration(
'run-unrelated-db',
'measured',
[worker(4_096, null)],
[{ type: 'db-request' }],
true
),
]);
assert.deepEqual(unrelatedDatabaseWorker, {
applicableMeasuredRunCount: 1,
invalidMeasuredRuns: [
{
databaseWorkerCount: 1,
reason: 'database-worker-unexpected-activity',
runId: 'run-unrelated-db',
},
],
measuredRunCount: 1,
notApplicableMeasuredRuns: [],
validForBenchmark: false,
validForComparison: false,
validMeasuredRunCount: 0,
});
const nonCancelLateRequest = assess([
iteration(
'run-non-cancel-late',
'measured',
[worker(8_192, null)],
[{ type: 'db-request-after-capture-cutoff' }]
),
]);
assert.deepEqual(nonCancelLateRequest, {
applicableMeasuredRunCount: 1,
invalidMeasuredRuns: [
{
databaseWorkerCount: 1,
reason: 'database-worker-activity-after-cutoff',
runId: 'run-non-cancel-late',
},
],
measuredRunCount: 1,
notApplicableMeasuredRuns: [],
validForBenchmark: false,
validForComparison: false,
validMeasuredRunCount: 0,
});
});
test('the benchmark preserves raw artifacts before rejecting an invalid formal comparison', () => {
const source = readFileSync(
new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url),
'utf8'
);
const summaryWrite = source.indexOf(
"writeJson(join(config.outputDirectory, 'summary.json'), summary)"
);
const validityGuard = source.indexOf(
'summary.validity.databaseWorkerPostGc.validForBenchmark'
);
const cancellationEffectGuard = source.indexOf(
'summary.cancellationEffectRate !== 1'
);
assert.ok(summaryWrite >= 0, 'summary artifact write must exist');
assert.ok(
cancellationEffectGuard >= 0,
'formal cancellation-effect guard must exist'
);
assert.ok(validityGuard >= 0, 'formal validity guard must exist');
assert.ok(
summaryWrite < cancellationEffectGuard && summaryWrite < validityGuard,
'raw summary must be durable before either formal guard fails'
);
assert.match(source, /Cancellation effect was not observed/);
assert.match(source, /Database worker post-GC capture is invalid/);
});
test('the benchmark persists the real seed DB lifecycle and atomically arms the measured generation', () => {
const source = readFileSync(
new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url),
'utf8'
);
const seedCaptureStart = source.indexOf(
'await startMainCapture(app.electronApp',
source.indexOf('const rendererWindowIdentity')
);
const seedPlaylist = source.indexOf('await seedPlaylist');
const seedSettlement = source.indexOf('await waitForSeedSettlement');
const seedRollover = source.indexOf(
'await rolloverMainCapture(app.electronApp',
seedSettlement
);
const seedArtifactWrite = source.indexOf(
"'seed-main-capture.json'",
seedRollover
);
assert.ok(seedCaptureStart >= 0 && seedCaptureStart < seedPlaylist);
assert.ok(seedPlaylist < seedSettlement);
assert.ok(seedSettlement < seedRollover);
assert.ok(seedRollover < seedArtifactWrite);
assert.doesNotMatch(
source.slice(seedRollover + 1, source.indexOf('const renderer =')),
/await startMainCapture\(app\.electronApp/
);
assert.match(source, /seedRollover\.nextCaptureStarted/);
assert.match(source, /seedRollover\.nextCaptureUnavailableReason/);
assert.match(
source,
/status\.databaseRequests\s*>\s*0[\s\S]*status\.databaseUpsertsCompleted\s*>\s*0[\s\S]*status\.databasePending\s*===\s*0/
);
});
@@ -0,0 +1,141 @@
import {
type DatabaseWorkerPostGcValidity,
type InvalidDatabaseWorkerPostGcMeasuredRun,
WORKER_POST_GC_HEAP_UNAVAILABLE_REASON,
} from './m3u-refresh-cancellation-contract';
export interface DatabaseWorkerPostGcValidityWorker {
readonly kind: string;
readonly postGcHeapUnavailableReason: unknown;
readonly postGcHeapUsedBytes: unknown;
}
export interface DatabaseWorkerPostGcValidityIteration {
readonly cancellationEffectObserved?: boolean;
readonly kind: string;
readonly main: {
readonly timeline?: readonly {
readonly type: string;
}[];
readonly workers: readonly DatabaseWorkerPostGcValidityWorker[];
};
readonly runId: string;
}
const UNAVAILABLE_REASONS = new Set<string>(
Object.values(WORKER_POST_GC_HEAP_UNAVAILABLE_REASON)
);
export function assessDatabaseWorkerPostGcValidity(
iterations: readonly DatabaseWorkerPostGcValidityIteration[]
): DatabaseWorkerPostGcValidity {
const measured = iterations.filter(
(iteration) => iteration.kind === 'measured'
);
const invalidMeasuredRuns: InvalidDatabaseWorkerPostGcMeasuredRun[] = [];
const notApplicableMeasuredRuns: {
readonly reason: 'operation-cancelled-before-database-phase';
readonly runId: string;
}[] = [];
let applicableMeasuredRunCount = 0;
let validMeasuredRunCount = 0;
for (const iteration of measured) {
const databaseWorkers = iteration.main.workers.filter(
(worker) => worker.kind === 'database.worker'
);
const timeline = iteration.main.timeline ?? [];
const lateDatabaseRequest = timeline.some(
(record) => record.type === 'db-request-after-capture-cutoff'
);
const databasePhaseObserved =
lateDatabaseRequest ||
timeline.some((record) => record.type === 'db-request');
if (lateDatabaseRequest) {
applicableMeasuredRunCount += 1;
invalidMeasuredRuns.push({
databaseWorkerCount: databaseWorkers.length,
reason: WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.DATABASE_WORKER_ACTIVITY_AFTER_CUTOFF,
runId: iteration.runId,
});
continue;
}
if (iteration.cancellationEffectObserved === true) {
if (!databasePhaseObserved && databaseWorkers.length === 0) {
notApplicableMeasuredRuns.push({
reason: 'operation-cancelled-before-database-phase',
runId: iteration.runId,
});
continue;
}
applicableMeasuredRunCount += 1;
invalidMeasuredRuns.push({
databaseWorkerCount: databaseWorkers.length,
reason: WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.DATABASE_WORKER_UNEXPECTED_ACTIVITY,
runId: iteration.runId,
});
continue;
}
if (databaseWorkers.length === 0) {
applicableMeasuredRunCount += 1;
invalidMeasuredRuns.push({
databaseWorkerCount: 0,
reason: WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.DATABASE_WORKER_MISSING,
runId: iteration.runId,
});
continue;
}
applicableMeasuredRunCount += 1;
if (databaseWorkers.length !== 1) {
invalidMeasuredRuns.push({
databaseWorkerCount: databaseWorkers.length,
reason: WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.MULTIPLE_DATABASE_WORKERS,
runId: iteration.runId,
});
continue;
}
const worker = databaseWorkers[0] as DatabaseWorkerPostGcValidityWorker;
const heap = worker.postGcHeapUsedBytes;
const reason = worker.postGcHeapUnavailableReason;
if (
Number.isSafeInteger(heap) &&
Number(heap) >= 0 &&
reason === null
) {
validMeasuredRunCount += 1;
continue;
}
invalidMeasuredRuns.push({
databaseWorkerCount: 1,
reason:
heap === null &&
typeof reason === 'string' &&
UNAVAILABLE_REASONS.has(reason)
? reason
: WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.INVALID_CAPTURE,
runId: iteration.runId,
});
}
return Object.freeze({
applicableMeasuredRunCount,
invalidMeasuredRuns: Object.freeze(invalidMeasuredRuns),
measuredRunCount: measured.length,
notApplicableMeasuredRuns: Object.freeze(notApplicableMeasuredRuns),
validForBenchmark:
measured.length > 0 && invalidMeasuredRuns.length === 0,
validForComparison:
measured.length > 0 &&
applicableMeasuredRunCount === measured.length &&
validMeasuredRunCount === measured.length &&
invalidMeasuredRuns.length === 0,
validMeasuredRunCount,
});
}
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
assessDatabaseWorkerRequestMetricsValidity,
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON,
} from './database-worker-request-metrics-validity';
import { createCompleteTestMainCapture } from './m3u-import-report.test-helpers';
import type {
WorkerCaptureMetrics,
WorkerRequestPerformanceMetrics,
} from './m3u-refresh-cancellation-contract';
describe('database worker request-metrics validity', () => {
it('requires exactly two complete request samples in every measured run', () => {
const worker = completeWorker();
const validity = assessDatabaseWorkerRequestMetricsValidity([
iteration('warmup', 'warmup', [worker]),
iteration('run-01', 'measured', [worker]),
iteration('run-02', 'measured', [worker]),
iteration('diagnostic', 'diagnostic', [worker]),
]);
assert.deepEqual(validity, {
expectedRequestCount: 4,
invalidRequests: [],
measuredRunCount: 2,
validForComparison: true,
validMeasuredRunCount: 2,
validRequestCount: 4,
});
});
it('fails each measured run with missing ELD, ELU, CPU, or raw capture metrics', () => {
const validity = assessDatabaseWorkerRequestMetricsValidity([
invalidRequestIteration('run-eld', {
eventLoopDelay: null,
eventLoopDelayUnavailableReason:
'event-loop-delay-capture-unavailable',
histogramFlushedEpochMs: null,
}),
invalidRequestIteration('run-elu', {
eventLoopUtilization: null,
eventLoopUtilizationUnavailableReason:
'event-loop-utilization-unavailable',
}),
invalidRequestIteration('run-cpu', {
threadCpuSystemMicros: null,
threadCpuUnavailableReason: 'thread-cpu-usage-unavailable',
threadCpuUserMicros: null,
}),
invalidRequestIteration('run-capture', {
performanceCaptureUnavailableReason:
'worker-performance-capture-invalid',
}),
]);
assert.equal(validity.expectedRequestCount, 8);
assert.equal(validity.validRequestCount, 4);
assert.equal(validity.validMeasuredRunCount, 0);
assert.equal(validity.validForComparison, false);
assert.deepEqual(
validity.invalidRequests.map(({ reason }) => reason),
[
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_DELAY_INVALID,
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_UTILIZATION_INVALID,
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.THREAD_CPU_INVALID,
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.WORKER_CAPTURE_INVALID,
]
);
});
it('fails closed for missing, multiple, and wrong request sets', () => {
const worker = completeWorker();
const validity = assessDatabaseWorkerRequestMetricsValidity([
iteration('missing', 'measured', []),
iteration('multiple', 'measured', [worker, worker]),
iteration('wrong-requests', 'measured', [
{
...worker,
requests: worker.requests.slice(0, 1),
},
]),
]);
assert.equal(validity.expectedRequestCount, 6);
assert.equal(validity.validRequestCount, 0);
assert.equal(validity.validForComparison, false);
assert.deepEqual(
validity.invalidRequests.map(({ reason }) => reason),
[
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.DATABASE_WORKER_MISSING,
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.MULTIPLE_DATABASE_WORKERS,
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.REQUEST_SET_INVALID,
]
);
});
});
function completeWorker(): WorkerCaptureMetrics {
const worker = createCompleteTestMainCapture().workers[0];
assert.ok(worker);
return worker;
}
function invalidRequestIteration(
runId: string,
updates: Partial<WorkerRequestPerformanceMetrics>
) {
const worker = completeWorker();
const get = worker.requests[1];
assert.ok(get);
return iteration(runId, 'measured', [
{
...worker,
requests: [worker.requests[0], { ...get, ...updates }],
},
]);
}
function iteration(
runId: string,
kind: string,
workers: readonly WorkerCaptureMetrics[]
) {
return { kind, main: { workers }, runId };
}
@@ -0,0 +1,197 @@
import type {
WorkerCaptureMetrics,
WorkerRequestPerformanceMetrics,
} from './m3u-refresh-cancellation-contract';
import {
PERFORMANCE_ITERATION_KIND,
PERFORMANCE_WORKER_KIND,
} from './m3u-refresh-cancellation-contract';
export const DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON = {
DATABASE_WORKER_MISSING: 'database-worker-missing',
EVENT_LOOP_DELAY_INVALID: 'event-loop-delay-invalid',
EVENT_LOOP_UTILIZATION_INVALID: 'event-loop-utilization-invalid',
MULTIPLE_DATABASE_WORKERS: 'multiple-database-workers',
REQUEST_SET_INVALID: 'initial-import-request-set-invalid',
THREAD_CPU_INVALID: 'thread-cpu-invalid',
WORKER_CAPTURE_INVALID: 'worker-performance-capture-invalid',
} as const;
export type DatabaseWorkerRequestMetricsInvalidReason =
(typeof DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON)[keyof typeof DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON];
export interface DatabaseWorkerRequestMetricsValidity {
readonly expectedRequestCount: number;
readonly invalidRequests: readonly {
readonly operation: string | null;
readonly reason: DatabaseWorkerRequestMetricsInvalidReason;
readonly runId: string;
}[];
readonly measuredRunCount: number;
readonly validForComparison: boolean;
readonly validMeasuredRunCount: number;
readonly validRequestCount: number;
}
interface RequestMetricsIteration {
readonly kind: string;
readonly main: {
readonly workers: readonly WorkerCaptureMetrics[];
};
readonly runId: string;
}
export function assessDatabaseWorkerRequestMetricsValidity(
iterations: readonly RequestMetricsIteration[]
): DatabaseWorkerRequestMetricsValidity {
const measured = iterations.filter(
(iteration) =>
iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED
);
const invalidRequests: {
operation: string | null;
reason: DatabaseWorkerRequestMetricsInvalidReason;
runId: string;
}[] = [];
let validMeasuredRunCount = 0;
let validRequestCount = 0;
for (const iteration of measured) {
const workers = iteration.main.workers.filter(
(worker) => worker.kind === PERFORMANCE_WORKER_KIND.DATABASE
);
if (workers.length === 0) {
invalidRequests.push({
operation: null,
reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.DATABASE_WORKER_MISSING,
runId: iteration.runId,
});
continue;
}
const worker = workers.length === 1 ? workers[0] : null;
if (!worker) {
invalidRequests.push({
operation: null,
reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.MULTIPLE_DATABASE_WORKERS,
runId: iteration.runId,
});
continue;
}
const requests = requireInitialImportRequestPair(worker);
if (requests === null) {
invalidRequests.push({
operation: null,
reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.REQUEST_SET_INVALID,
runId: iteration.runId,
});
continue;
}
let runIsValid = true;
for (const request of requests) {
const reason = assessRequest(request);
if (reason === null) {
validRequestCount += 1;
} else {
runIsValid = false;
invalidRequests.push({
operation: request.operation,
reason,
runId: iteration.runId,
});
}
}
if (runIsValid) {
validMeasuredRunCount += 1;
}
}
const expectedRequestCount = measured.length * 2;
return Object.freeze({
expectedRequestCount,
invalidRequests: Object.freeze(invalidRequests),
measuredRunCount: measured.length,
validForComparison:
measured.length > 0 &&
validMeasuredRunCount === measured.length &&
validRequestCount === expectedRequestCount,
validMeasuredRunCount,
validRequestCount,
});
}
function requireInitialImportRequestPair(
worker: WorkerCaptureMetrics
): readonly [
WorkerRequestPerformanceMetrics,
WorkerRequestPerformanceMetrics,
] | null {
const upserts = worker.requests.filter(
(request) =>
request.operation === 'DB_UPSERT_APP_PLAYLIST' && request.success
);
const gets = worker.requests.filter(
(request) =>
request.operation === 'DB_GET_APP_PLAYLIST' && request.success
);
return worker.requests.length === 2 &&
upserts.length === 1 &&
upserts[0] &&
gets.length === 1 &&
gets[0]
? [upserts[0], gets[0]]
: null;
}
function assessRequest(
request: WorkerRequestPerformanceMetrics
): DatabaseWorkerRequestMetricsInvalidReason | null {
if (
request.performanceCaptureUnavailableReason !== null ||
request.invalidReason !== null
) {
return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.WORKER_CAPTURE_INVALID;
}
if (
request.eventLoopDelayUnavailableReason !== null ||
request.histogramFlushedEpochMs === null ||
!isEventLoopDelay(request.eventLoopDelay)
) {
return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_DELAY_INVALID;
}
if (
request.eventLoopUtilizationUnavailableReason !== null ||
!isUtilization(request.eventLoopUtilization)
) {
return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_UTILIZATION_INVALID;
}
if (
request.threadCpuUnavailableReason !== null ||
!isFiniteNonNegative(request.threadCpuSystemMicros) ||
!isFiniteNonNegative(request.threadCpuUserMicros)
) {
return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.THREAD_CPU_INVALID;
}
return null;
}
function isEventLoopDelay(
value: WorkerRequestPerformanceMetrics['eventLoopDelay']
): value is NonNullable<WorkerRequestPerformanceMetrics['eventLoopDelay']> {
return (
value !== null &&
isFiniteNonNegative(value.maxMs) &&
isFiniteNonNegative(value.p95Ms) &&
isFiniteNonNegative(value.p99Ms) &&
value.p95Ms <= value.p99Ms &&
value.p99Ms <= value.maxMs
);
}
function isUtilization(value: number | null): value is number {
return isFiniteNonNegative(value) && value <= 1;
}
function isFiniteNonNegative(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}
@@ -0,0 +1,193 @@
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract';
interface DiagnosticCaptureModule {
validateDiagnosticPlaylistWorkerCapture?: (
main: MainCaptureMetrics,
iterationDirectory: string
) => Promise<void>;
}
const diagnosticCaptureModulePromise = import(
new URL('./diagnostic-playlist-worker-capture.ts', import.meta.url).href
)
.then((module) => module as DiagnosticCaptureModule)
.catch(() => null);
function mainCapture(input: {
readonly profilePath: string | null;
readonly snapshotPath?: string | null;
readonly timelineType?: string;
}): MainCaptureMetrics {
return {
timeline: input.timelineType
? [{ epochMs: 1, type: input.timelineType }]
: [],
workers: [
{
kind: 'playlist-refresh.worker',
ordinal: 2,
postGcHeapUnavailableReason:
'worker-force-terminated-before-gc',
postGcHeapUsedBytes: null,
profilePath: input.profilePath,
snapshotPath: input.snapshotPath ?? null,
terminatedEpochMs: 2,
},
],
} as unknown as MainCaptureMetrics;
}
test('accepts one parseable diagnostic playlist-worker profile inside the iteration directory', async () => {
const module = await diagnosticCaptureModulePromise;
assert.ok(
module,
'diagnostic playlist worker capture validator must exist'
);
const validate = module.validateDiagnosticPlaylistWorkerCapture;
assert.equal(typeof validate, 'function');
const directory = await mkdtemp(
join(tmpdir(), 'iptvnator-diagnostic-worker-')
);
try {
const profilePath = join(
directory,
'playlist-refresh.worker-2.cpuprofile'
);
await writeFile(
profilePath,
JSON.stringify({ nodes: [{ id: 1 }], samples: [1] })
);
await assert.doesNotReject(() =>
validate?.(mainCapture({ profilePath }), directory)
);
} finally {
await rm(directory, { force: true, recursive: true });
}
});
test('fails closed for missing, escaped, malformed, or contaminated diagnostic artifacts', async () => {
const module = await diagnosticCaptureModulePromise;
assert.ok(module);
const validate = module.validateDiagnosticPlaylistWorkerCapture;
assert.equal(typeof validate, 'function');
const directory = await mkdtemp(
join(tmpdir(), 'iptvnator-diagnostic-worker-invalid-')
);
try {
await assert.rejects(
() => validate?.(mainCapture({ profilePath: null }), directory),
/diagnostic-playlist-worker-profile-missing/
);
await assert.rejects(
() =>
validate?.(
mainCapture({
profilePath: join(
directory,
'..',
'escaped.cpuprofile'
),
}),
directory
),
/diagnostic-playlist-worker-profile-path-invalid/
);
const malformedPath = join(
directory,
'playlist-refresh.worker-2.cpuprofile'
);
await writeFile(malformedPath, '{"nodes":[]}');
await assert.rejects(
() =>
validate?.(
mainCapture({ profilePath: malformedPath }),
directory
),
/diagnostic-playlist-worker-profile-invalid/
);
await assert.rejects(
() =>
validate?.(
mainCapture({
profilePath: malformedPath,
timelineType:
'worker-artifact-error:profile-stop:failed',
}),
directory
),
/diagnostic-playlist-worker-artifact-error/
);
await assert.rejects(
() =>
validate?.(
mainCapture({
profilePath: malformedPath,
snapshotPath: join(
directory,
'playlist-refresh.worker-2.heapsnapshot'
),
}),
directory
),
/diagnostic-playlist-worker-snapshot-unexpected/
);
} finally {
await rm(directory, { force: true, recursive: true });
}
});
test('the benchmark persists diagnostic raw metrics before requiring a parseable playlist-worker profile', () => {
const source = readFileSync(
new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url),
'utf8'
);
const resultWrite = source.indexOf(
"writeJson(join(iterationDirectory, 'result.json'), result)"
);
const summaryWrite = source.indexOf(
"writeJson(join(config.outputDirectory, 'summary.json'), summary)"
);
const diagnosticValidation = source.indexOf(
'await validateDiagnosticPlaylistWorkerCapture('
);
assert.ok(resultWrite >= 0);
assert.ok(summaryWrite >= 0);
assert.ok(diagnosticValidation > summaryWrite);
});
test('the benchmark requires the diagnostic worker profile to come from an observed cancellation', () => {
const source = readFileSync(
new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url),
'utf8'
);
const summaryWrite = source.indexOf(
"writeJson(join(config.outputDirectory, 'summary.json'), summary)"
);
const diagnosticCancellationGuard = source.indexOf(
'if (!diagnosticIteration.cancellationEffectObserved)'
);
const diagnosticValidation = source.indexOf(
'await validateDiagnosticPlaylistWorkerCapture('
);
assert.ok(summaryWrite >= 0);
assert.ok(
diagnosticCancellationGuard > summaryWrite,
'raw manifest and summary must remain durable when the diagnostic cancellation is invalid'
);
assert.ok(
diagnosticCancellationGuard < diagnosticValidation,
'the cancellation guard must reject a normal-completion worker profile before artifact validation'
);
});
@@ -0,0 +1,72 @@
import { readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract';
interface CpuProfile {
readonly nodes?: unknown;
readonly samples?: unknown;
}
export async function validateDiagnosticPlaylistWorkerCapture(
main: MainCaptureMetrics,
iterationDirectory: string
): Promise<void> {
if (
main.timeline.some((record) =>
record.type.startsWith('worker-artifact-error:')
)
) {
throw new Error('diagnostic-playlist-worker-artifact-error');
}
const workers = main.workers.filter(
(worker) => worker.kind === 'playlist-refresh.worker'
);
if (workers.length !== 1) {
throw new Error('diagnostic-playlist-worker-cardinality-invalid');
}
const worker = workers[0];
if (!worker) {
throw new Error('diagnostic-playlist-worker-cardinality-invalid');
}
if (worker.snapshotPath !== null) {
throw new Error('diagnostic-playlist-worker-snapshot-unexpected');
}
if (
worker.postGcHeapUsedBytes !== null ||
worker.postGcHeapUnavailableReason !==
'worker-force-terminated-before-gc' ||
worker.terminatedEpochMs === null
) {
throw new Error('diagnostic-playlist-worker-termination-invalid');
}
if (worker.profilePath === null) {
throw new Error('diagnostic-playlist-worker-profile-missing');
}
const expectedProfilePath = join(
resolve(iterationDirectory),
`playlist-refresh.worker-${worker.ordinal}.cpuprofile`
);
if (resolve(worker.profilePath) !== expectedProfilePath) {
throw new Error('diagnostic-playlist-worker-profile-path-invalid');
}
let profile: CpuProfile;
try {
profile = JSON.parse(
await readFile(expectedProfilePath, 'utf8')
) as CpuProfile;
} catch {
throw new Error('diagnostic-playlist-worker-profile-invalid');
}
if (
!Array.isArray(profile.nodes) ||
profile.nodes.length === 0 ||
!Array.isArray(profile.samples) ||
profile.samples.length === 0
) {
throw new Error('diagnostic-playlist-worker-profile-invalid');
}
}
@@ -0,0 +1,185 @@
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function isCpuProfile(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const startTime = value['startTime'];
const endTime = value['endTime'];
const nodes = value['nodes'];
const samples = value['samples'];
const timeDeltas = value['timeDeltas'];
if (
!isNonNegativeSafeInteger(startTime) ||
!isNonNegativeSafeInteger(endTime) ||
endTime <= startTime ||
!Array.isArray(nodes) ||
nodes.length === 0 ||
!Array.isArray(samples) ||
samples.length === 0 ||
!Array.isArray(timeDeltas) ||
timeDeltas.length !== samples.length
) {
return false;
}
const nodeIds = new Set<number>();
for (const node of nodes) {
if (!isCpuProfileNode(node) || nodeIds.has(node['id'])) {
return false;
}
nodeIds.add(node['id']);
}
if (
nodes.some((node) =>
(node as Record<string, unknown>)['children'] instanceof Array
? (
(node as Record<string, unknown>)[
'children'
] as unknown[]
).some(
(childId) =>
!isNonNegativeSafeInteger(childId) ||
!nodeIds.has(childId)
)
: false
) ||
samples.some(
(sampleId) =>
!isNonNegativeSafeInteger(sampleId) ||
!nodeIds.has(sampleId)
)
) {
return false;
}
const duration = endTime - startTime;
let elapsed = 0;
for (const delta of timeDeltas) {
if (!Number.isSafeInteger(delta)) {
return false;
}
elapsed += delta as number;
if (
!Number.isSafeInteger(elapsed) ||
elapsed < 0 ||
elapsed > duration
) {
return false;
}
}
return elapsed > 0;
}
export function isHeapSnapshot(value: unknown): boolean {
if (
!isRecord(value) ||
!isRecord(value['snapshot']) ||
!isRecord(value['snapshot']['meta'])
) {
return false;
}
const meta = value['snapshot']['meta'];
const nodeFields = meta['node_fields'];
const nodeTypes = meta['node_types'];
const edgeFields = meta['edge_fields'];
const edgeTypes = meta['edge_types'];
const nodes = value['nodes'];
const edges = value['edges'];
const strings = value['strings'];
return (
isNonEmptyStringArray(nodeFields) &&
isHeapTypeTable(nodeTypes, nodeFields.length) &&
isNonEmptyStringArray(edgeFields) &&
isHeapTypeTable(edgeTypes, edgeFields.length) &&
Array.isArray(nodes) &&
nodes.length > 0 &&
nodes.length % nodeFields.length === 0 &&
Array.isArray(edges) &&
edges.length > 0 &&
edges.length % edgeFields.length === 0 &&
Array.isArray(strings) &&
strings.length > 0
);
}
export function isRendererTrace(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const events = value['traceEvents'];
return (
Array.isArray(events) &&
events.length > 0 &&
events.every(
(event) =>
isRecord(event) &&
isNonEmptyString(event['name']) &&
isNonEmptyString(event['ph']) &&
isNonNegativeFiniteNumber(event['ts'])
)
);
}
function isHeapTypeTable(value: unknown, fieldCount: number): boolean {
return (
Array.isArray(value) &&
value.length === fieldCount &&
value.every(
(entry) =>
isNonEmptyString(entry) || isNonEmptyStringArray(entry)
)
);
}
function isCpuProfileNode(
value: unknown
): value is Record<string, unknown> & { readonly id: number } {
if (
!isRecord(value) ||
!isNonNegativeSafeInteger(value['id']) ||
!isRecord(value['callFrame'])
) {
return false;
}
const callFrame = value['callFrame'];
const scriptId = callFrame['scriptId'];
const children = value['children'];
const hitCount = value['hitCount'];
return (
typeof callFrame['functionName'] === 'string' &&
(typeof scriptId === 'string' ||
isNonNegativeSafeInteger(scriptId)) &&
typeof callFrame['url'] === 'string' &&
Number.isSafeInteger(callFrame['lineNumber']) &&
Number.isSafeInteger(callFrame['columnNumber']) &&
(children === undefined ||
(Array.isArray(children) &&
children.every(isNonNegativeSafeInteger))) &&
(hitCount === undefined || isNonNegativeSafeInteger(hitCount))
);
}
function isNonEmptyStringArray(value: unknown): value is readonly string[] {
return (
Array.isArray(value) &&
value.length > 0 &&
value.every(isNonEmptyString)
);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0;
}
function isNonNegativeSafeInteger(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
function isNonNegativeFiniteNumber(value: unknown): value is number {
return (
typeof value === 'number' && Number.isFinite(value) && value >= 0
);
}
@@ -0,0 +1,234 @@
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
type InitialImportDiagnosticCapture,
validateInitialImportDiagnosticArtifacts,
} from './initial-import-diagnostic-artifacts';
import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures';
const HEAP_META = {
edge_fields: ['type', 'name_or_index', 'to_node'],
edge_types: [['context'], 'string_or_number', 'node'],
node_fields: [
'type',
'name',
'id',
'self_size',
'edge_count',
'detachedness',
],
node_types: [
['hidden'],
'string',
'number',
'number',
'number',
'number',
],
};
const HEAP_SNAPSHOT = {
edges: [0, 0, 0],
nodes: [0, 0, 1, 0, 1, 0],
snapshot: { meta: HEAP_META },
strings: ['', 'root'],
};
const TRACE = {
traceEvents: [{ name: 'initial-import', ph: 'X', ts: 1 }],
};
function capture(directory: string): InitialImportDiagnosticCapture {
return {
main: {
cpuProfilePath: join(directory, 'main.cpuprofile'),
heapSnapshotPath: join(directory, 'main.heapsnapshot'),
workers: [
{
kind: 'database.worker',
ordinal: 4,
profilePath: join(
directory,
'database.worker-4.cpuprofile'
),
snapshotPath: join(
directory,
'database.worker-4.heapsnapshot'
),
},
],
},
renderer: {
cpuProfilePath: join(directory, 'renderer.cpuprofile'),
heapSnapshotPath: join(directory, 'renderer.heapsnapshot'),
tracePath: join(directory, 'renderer.trace.json'),
},
};
}
async function writeArtifacts(directory: string): Promise<void> {
const artifacts: Readonly<Record<string, unknown>> = {
'database.worker-4.cpuprofile': CPU_PROFILE,
'database.worker-4.heapsnapshot': HEAP_SNAPSHOT,
'main.cpuprofile': CPU_PROFILE,
'main.heapsnapshot': HEAP_SNAPSHOT,
'renderer.cpuprofile': CPU_PROFILE,
'renderer.heapsnapshot': HEAP_SNAPSHOT,
'renderer.trace.json': TRACE,
};
await Promise.all(
Object.entries(artifacts).map(([filename, value]) =>
writeFile(join(directory, filename), JSON.stringify(value))
)
);
}
async function withArtifacts(
run: (directory: string) => Promise<void>
): Promise<void> {
const directory = await mkdtemp(
join(tmpdir(), 'iptvnator-initial-import-content-')
);
try {
await writeArtifacts(directory);
await run(directory);
} finally {
await rm(directory, { force: true, recursive: true });
}
}
test('accepts bounded signed CPU time deltas emitted by Chromium', async () => {
await withArtifacts(async (directory) => {
await writeFile(
join(directory, 'renderer.cpuprofile'),
JSON.stringify({
...CPU_PROFILE,
endTime: 5_000,
samples: [1, 1, 1],
timeDeltas: [1_000, -100, 1_000],
})
);
await assert.doesNotReject(() =>
validateInitialImportDiagnosticArtifacts(
capture(directory),
directory
)
);
});
});
test('rejects CPU profiles without useful bounded samples and stack references', async () => {
await withArtifacts(async (directory) => {
const invalidProfiles = [
{ ...CPU_PROFILE, samples: [], timeDeltas: [] },
{ ...CPU_PROFILE, timeDeltas: undefined },
{ ...CPU_PROFILE, timeDeltas: [] },
{
...CPU_PROFILE,
endTime: CPU_PROFILE.startTime,
},
{
...CPU_PROFILE,
samples: [2],
},
{
...CPU_PROFILE,
timeDeltas: [-1],
},
{
...CPU_PROFILE,
timeDeltas: [2_001],
},
{
...CPU_PROFILE,
nodes: [
...CPU_PROFILE.nodes,
{ ...CPU_PROFILE.nodes[0], id: 1 },
],
},
{
...CPU_PROFILE,
nodes: [{ id: 1 }],
},
];
for (const profile of invalidProfiles) {
await writeFile(
join(directory, 'main.cpuprofile'),
JSON.stringify(profile)
);
await assert.rejects(
() =>
validateInitialImportDiagnosticArtifacts(
capture(directory),
directory
),
/initial-import-diagnostic-artifact-invalid:main-cpu-profile/
);
}
});
});
test('rejects empty or malformed heap tables and invalid field strides', async () => {
await withArtifacts(async (directory) => {
const invalidSnapshots = [
{ snapshot: {}, nodes: [], edges: [], strings: [] },
{ ...HEAP_SNAPSHOT, nodes: [] },
{ ...HEAP_SNAPSHOT, edges: [] },
{ ...HEAP_SNAPSHOT, strings: [] },
{
...HEAP_SNAPSHOT,
snapshot: {
meta: { ...HEAP_META, node_types: undefined },
},
},
{ ...HEAP_SNAPSHOT, nodes: [...HEAP_SNAPSHOT.nodes, 0] },
{ ...HEAP_SNAPSHOT, edges: [...HEAP_SNAPSHOT.edges, 0] },
];
for (const snapshot of invalidSnapshots) {
await writeFile(
join(directory, 'main.heapsnapshot'),
JSON.stringify(snapshot)
);
await assert.rejects(
() =>
validateInitialImportDiagnosticArtifacts(
capture(directory),
directory
),
/initial-import-diagnostic-artifact-invalid:main-heap-snapshot/
);
}
});
});
test('rejects empty traces and malformed trace events', async () => {
await withArtifacts(async (directory) => {
const invalidTraces = [
{ traceEvents: [] },
{ traceEvents: [null] },
{ traceEvents: [{ name: '', ph: 'X', ts: 1 }] },
{ traceEvents: [{ name: 'initial-import', ph: '', ts: 1 }] },
{
traceEvents: [
{ name: 'initial-import', ph: 'X', ts: 'invalid' },
],
},
];
for (const trace of invalidTraces) {
await writeFile(
join(directory, 'renderer.trace.json'),
JSON.stringify(trace)
);
await assert.rejects(
() =>
validateInitialImportDiagnosticArtifacts(
capture(directory),
directory
),
/initial-import-diagnostic-artifact-invalid:renderer-trace/
);
}
});
});
@@ -0,0 +1,394 @@
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
import assert from 'node:assert/strict';
import { mkdtemp, rm, symlink, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures';
interface DiagnosticCapture {
readonly main: {
readonly cpuProfilePath: string | null;
readonly heapSnapshotPath: string | null;
readonly workers: readonly {
readonly kind: string;
readonly ordinal: number;
readonly profilePath: string | null;
readonly snapshotPath: string | null;
}[];
};
readonly renderer: {
readonly cpuProfilePath: string | null;
readonly heapSnapshotPath: string | null;
readonly tracePath: string | null;
};
}
interface DiagnosticArtifactsModule {
validateInitialImportDiagnosticArtifacts?: (
capture: DiagnosticCapture,
iterationDirectory: string
) => Promise<void>;
}
const diagnosticArtifactsModulePromise = import(
new URL('./initial-import-diagnostic-artifacts.ts', import.meta.url).href
)
.then((module) => module as DiagnosticArtifactsModule)
.catch(() => null);
const HEAP_SNAPSHOT = {
edges: [0, 0, 0],
nodes: [0, 0, 1, 0, 1, 0],
snapshot: {
meta: {
edge_fields: ['type', 'name_or_index', 'to_node'],
edge_types: [['context'], 'string_or_number', 'node'],
node_fields: [
'type',
'name',
'id',
'self_size',
'edge_count',
'detachedness',
],
node_types: [
['hidden'],
'string',
'number',
'number',
'number',
'number',
],
},
},
strings: ['', 'root'],
};
const RENDERER_TRACE = {
traceEvents: [{ name: 'initial-import', ph: 'X', ts: 1 }],
};
function capture(directory: string, databaseOrdinal = 4): DiagnosticCapture {
return {
main: {
cpuProfilePath: join(directory, 'main.cpuprofile'),
heapSnapshotPath: join(directory, 'main.heapsnapshot'),
workers: [
{
kind: 'database.worker',
ordinal: databaseOrdinal,
profilePath: join(
directory,
`database.worker-${databaseOrdinal}.cpuprofile`
),
snapshotPath: join(
directory,
`database.worker-${databaseOrdinal}.heapsnapshot`
),
},
],
},
renderer: {
cpuProfilePath: join(directory, 'renderer.cpuprofile'),
heapSnapshotPath: join(directory, 'renderer.heapsnapshot'),
tracePath: join(directory, 'renderer.trace.json'),
},
};
}
async function writeValidArtifacts(
directory: string,
databaseOrdinal = 4
): Promise<void> {
await Promise.all([
writeFile(
join(directory, 'main.cpuprofile'),
JSON.stringify(CPU_PROFILE)
),
writeFile(
join(directory, 'renderer.cpuprofile'),
JSON.stringify(CPU_PROFILE)
),
writeFile(
join(directory, `database.worker-${databaseOrdinal}.cpuprofile`),
JSON.stringify(CPU_PROFILE)
),
writeFile(
join(directory, `database.worker-${databaseOrdinal}.heapsnapshot`),
JSON.stringify(HEAP_SNAPSHOT)
),
writeFile(
join(directory, 'main.heapsnapshot'),
JSON.stringify(HEAP_SNAPSHOT)
),
writeFile(
join(directory, 'renderer.heapsnapshot'),
JSON.stringify(HEAP_SNAPSHOT)
),
writeFile(
join(directory, 'renderer.trace.json'),
JSON.stringify(RENDERER_TRACE)
),
]);
}
test('accepts the complete parseable initial-import diagnostic artifact set', async () => {
const module = await diagnosticArtifactsModulePromise;
assert.ok(module, 'initial-import diagnostic validator must exist');
const validate = module.validateInitialImportDiagnosticArtifacts;
assert.equal(typeof validate, 'function');
const directory = await mkdtemp(
join(tmpdir(), 'iptvnator-initial-import-diagnostic-')
);
try {
await writeValidArtifacts(directory);
await assert.doesNotReject(() =>
validate?.(capture(directory), directory)
);
} finally {
await rm(directory, { force: true, recursive: true });
}
});
test('fails closed for malformed or missing required process artifacts', async () => {
const module = await diagnosticArtifactsModulePromise;
assert.ok(module);
const validate = module.validateInitialImportDiagnosticArtifacts;
assert.equal(typeof validate, 'function');
const directory = await mkdtemp(
join(tmpdir(), 'iptvnator-initial-import-invalid-')
);
try {
await writeValidArtifacts(directory);
await writeFile(join(directory, 'renderer.trace.json'), '{');
await assert.rejects(
() => validate?.(capture(directory), directory),
/initial-import-diagnostic-artifact-invalid:renderer-trace/
);
await writeFile(
join(directory, 'renderer.trace.json'),
JSON.stringify(RENDERER_TRACE)
);
const missingMainProfile: DiagnosticCapture = {
...capture(directory),
main: {
...capture(directory).main,
cpuProfilePath: null,
},
};
await assert.rejects(
() => validate?.(missingMainProfile, directory),
/initial-import-diagnostic-artifact-missing:main-cpu-profile/
);
} finally {
await rm(directory, { force: true, recursive: true });
}
});
test('rejects escaped paths and symlinks instead of reading outside the iteration directory', async () => {
const module = await diagnosticArtifactsModulePromise;
assert.ok(module);
const validate = module.validateInitialImportDiagnosticArtifacts;
assert.equal(typeof validate, 'function');
const directory = await mkdtemp(
join(tmpdir(), 'iptvnator-initial-import-contained-')
);
const outsideDirectory = await mkdtemp(
join(tmpdir(), 'iptvnator-initial-import-outside-')
);
try {
await writeValidArtifacts(directory);
const escapedCapture: DiagnosticCapture = {
...capture(directory),
main: {
...capture(directory).main,
cpuProfilePath: join(outsideDirectory, 'main.cpuprofile'),
},
};
await writeFile(
join(outsideDirectory, 'main.cpuprofile'),
JSON.stringify(CPU_PROFILE)
);
await assert.rejects(
() => validate?.(escapedCapture, directory),
/initial-import-diagnostic-artifact-path-invalid:main-cpu-profile/
);
const rendererProfilePath = join(directory, 'renderer.cpuprofile');
await unlink(rendererProfilePath);
await symlink(
join(outsideDirectory, 'main.cpuprofile'),
rendererProfilePath
);
await assert.rejects(
() => validate?.(capture(directory), directory),
/initial-import-diagnostic-artifact-path-invalid:renderer-cpu-profile/
);
} finally {
await rm(directory, { force: true, recursive: true });
await rm(outsideDirectory, { force: true, recursive: true });
}
});
test('requires exactly one database profile and rejects every playlist-worker profile', async () => {
const module = await diagnosticArtifactsModulePromise;
assert.ok(module);
const validate = module.validateInitialImportDiagnosticArtifacts;
assert.equal(typeof validate, 'function');
const directory = await mkdtemp(
join(tmpdir(), 'iptvnator-initial-import-workers-')
);
try {
await writeValidArtifacts(directory);
await writeFile(
join(directory, 'database.worker-9.cpuprofile'),
JSON.stringify(CPU_PROFILE)
);
await assert.rejects(
() => validate?.(capture(directory), directory),
/initial-import-diagnostic-database-profile-cardinality-invalid/
);
await unlink(join(directory, 'database.worker-9.cpuprofile'));
await writeFile(
join(directory, 'playlist-refresh.worker-2.cpuprofile'),
JSON.stringify(CPU_PROFILE)
);
await assert.rejects(
() => validate?.(capture(directory), directory),
/initial-import-diagnostic-playlist-profile-unexpected/
);
} finally {
await rm(directory, { force: true, recursive: true });
}
});
test('requires a structurally valid database worker heap snapshot', async () => {
const module = await diagnosticArtifactsModulePromise;
assert.ok(module);
const validate = module.validateInitialImportDiagnosticArtifacts;
assert.equal(typeof validate, 'function');
const directory = await mkdtemp(
join(tmpdir(), 'iptvnator-initial-import-worker-snapshot-')
);
try {
await writeValidArtifacts(directory);
const missingSnapshot: DiagnosticCapture = {
...capture(directory),
main: {
...capture(directory).main,
workers: [
{
...capture(directory).main.workers[0],
snapshotPath: null,
},
],
},
};
await assert.rejects(
() => validate?.(missingSnapshot, directory),
/initial-import-diagnostic-artifact-missing:database-heap-snapshot/
);
await writeFile(
join(directory, 'database.worker-4.heapsnapshot'),
JSON.stringify({ snapshot: {}, nodes: [], edges: [] })
);
await assert.rejects(
() => validate?.(capture(directory), directory),
/initial-import-diagnostic-artifact-invalid:database-heap-snapshot/
);
} finally {
await rm(directory, { force: true, recursive: true });
}
});
test('rejects escaped and symlinked database worker heap snapshots', async () => {
const module = await diagnosticArtifactsModulePromise;
assert.ok(module);
const validate = module.validateInitialImportDiagnosticArtifacts;
assert.equal(typeof validate, 'function');
const directory = await mkdtemp(
join(tmpdir(), 'iptvnator-initial-import-worker-contained-')
);
const outsideDirectory = await mkdtemp(
join(tmpdir(), 'iptvnator-initial-import-worker-outside-')
);
try {
await writeValidArtifacts(directory);
const outsideSnapshotPath = join(
outsideDirectory,
'database.worker-4.heapsnapshot'
);
await writeFile(outsideSnapshotPath, JSON.stringify(HEAP_SNAPSHOT));
const escapedSnapshot: DiagnosticCapture = {
...capture(directory),
main: {
...capture(directory).main,
workers: [
{
...capture(directory).main.workers[0],
snapshotPath: outsideSnapshotPath,
},
],
},
};
await assert.rejects(
() => validate?.(escapedSnapshot, directory),
/initial-import-diagnostic-artifact-path-invalid:database-heap-snapshot/
);
const snapshotPath = join(directory, 'database.worker-4.heapsnapshot');
await unlink(snapshotPath);
await symlink(outsideSnapshotPath, snapshotPath);
await assert.rejects(
() => validate?.(capture(directory), directory),
/initial-import-diagnostic-artifact-path-invalid:database-heap-snapshot/
);
} finally {
await rm(directory, { force: true, recursive: true });
await rm(outsideDirectory, { force: true, recursive: true });
}
});
test('rejects extra database snapshots and every playlist-worker snapshot', async () => {
const module = await diagnosticArtifactsModulePromise;
assert.ok(module);
const validate = module.validateInitialImportDiagnosticArtifacts;
assert.equal(typeof validate, 'function');
const directory = await mkdtemp(
join(tmpdir(), 'iptvnator-initial-import-worker-snapshot-count-')
);
try {
await writeValidArtifacts(directory);
await writeFile(
join(directory, 'database.worker-9.heapsnapshot'),
JSON.stringify(HEAP_SNAPSHOT)
);
await assert.rejects(
() => validate?.(capture(directory), directory),
/initial-import-diagnostic-database-snapshot-cardinality-invalid/
);
await unlink(join(directory, 'database.worker-9.heapsnapshot'));
await writeFile(
join(directory, 'playlist-refresh.worker-2.heapsnapshot'),
JSON.stringify(HEAP_SNAPSHOT)
);
await assert.rejects(
() => validate?.(capture(directory), directory),
/initial-import-diagnostic-playlist-snapshot-unexpected/
);
} finally {
await rm(directory, { force: true, recursive: true });
}
});
@@ -0,0 +1,262 @@
import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
import {
isCpuProfile,
isHeapSnapshot,
isRendererTrace,
} from './initial-import-diagnostic-artifact-content-validation';
interface DiagnosticWorkerArtifact {
readonly kind: string;
readonly ordinal: number;
readonly profilePath: string | null;
readonly snapshotPath: string | null;
}
export interface InitialImportDiagnosticCapture {
readonly main: {
readonly cpuProfilePath: string | null;
readonly heapSnapshotPath: string | null;
readonly workers: readonly DiagnosticWorkerArtifact[];
};
readonly renderer: {
readonly cpuProfilePath: string | null;
readonly heapSnapshotPath: string | null;
readonly tracePath: string | null;
};
}
type ArtifactLabel =
| 'database-cpu-profile'
| 'database-heap-snapshot'
| 'main-cpu-profile'
| 'main-heap-snapshot'
| 'renderer-cpu-profile'
| 'renderer-heap-snapshot'
| 'renderer-trace';
interface ArtifactDescription {
readonly filename: string;
readonly label: ArtifactLabel;
readonly path: string | null;
readonly validate: (value: unknown) => boolean;
}
export async function validateInitialImportDiagnosticArtifacts(
capture: InitialImportDiagnosticCapture,
iterationDirectory: string
): Promise<void> {
const directory = resolve(iterationDirectory);
let realDirectory: string;
try {
realDirectory = await realpath(directory);
} catch {
throw new Error(
'initial-import-diagnostic-artifact-path-invalid:iteration-directory'
);
}
const databaseWorker = requireExactDatabaseWorker(capture.main.workers);
await assertWorkerArtifactCardinality(directory);
const artifacts: readonly ArtifactDescription[] = [
{
filename: 'main.cpuprofile',
label: 'main-cpu-profile',
path: capture.main.cpuProfilePath,
validate: isCpuProfile,
},
{
filename: 'renderer.cpuprofile',
label: 'renderer-cpu-profile',
path: capture.renderer.cpuProfilePath,
validate: isCpuProfile,
},
{
filename: 'renderer.trace.json',
label: 'renderer-trace',
path: capture.renderer.tracePath,
validate: isRendererTrace,
},
{
filename: 'main.heapsnapshot',
label: 'main-heap-snapshot',
path: capture.main.heapSnapshotPath,
validate: isHeapSnapshot,
},
{
filename: 'renderer.heapsnapshot',
label: 'renderer-heap-snapshot',
path: capture.renderer.heapSnapshotPath,
validate: isHeapSnapshot,
},
{
filename: `database.worker-${databaseWorker.ordinal}.cpuprofile`,
label: 'database-cpu-profile',
path: databaseWorker.profilePath,
validate: isCpuProfile,
},
{
filename: `database.worker-${databaseWorker.ordinal}.heapsnapshot`,
label: 'database-heap-snapshot',
path: databaseWorker.snapshotPath,
validate: isHeapSnapshot,
},
];
for (const artifact of artifacts) {
await validateJsonArtifact(directory, realDirectory, artifact);
}
}
function requireExactDatabaseWorker(
workers: readonly DiagnosticWorkerArtifact[]
): DiagnosticWorkerArtifact {
if (
workers.some(
(worker) =>
worker.kind === 'playlist-refresh.worker' &&
worker.profilePath !== null
)
) {
throw new Error(
'initial-import-diagnostic-playlist-profile-unexpected'
);
}
if (
workers.some(
(worker) =>
worker.kind === 'playlist-refresh.worker' &&
worker.snapshotPath !== null
)
) {
throw new Error(
'initial-import-diagnostic-playlist-snapshot-unexpected'
);
}
const databaseWorkers = workers.filter(
(worker) => worker.kind === 'database.worker'
);
const worker = databaseWorkers[0];
if (
databaseWorkers.length !== 1 ||
!worker ||
!Number.isSafeInteger(worker.ordinal) ||
worker.ordinal < 0
) {
throw new Error(
'initial-import-diagnostic-database-profile-cardinality-invalid'
);
}
return worker;
}
async function assertWorkerArtifactCardinality(
directory: string
): Promise<void> {
let entries: readonly string[];
try {
entries = await readdir(directory);
} catch {
throw new Error(
'initial-import-diagnostic-artifact-path-invalid:iteration-directory'
);
}
if (
entries.some((entry) =>
/^playlist-refresh\.worker-\d+\.cpuprofile$/u.test(entry)
)
) {
throw new Error(
'initial-import-diagnostic-playlist-profile-unexpected'
);
}
if (
entries.filter((entry) =>
/^database\.worker-\d+\.cpuprofile$/u.test(entry)
).length !== 1
) {
throw new Error(
'initial-import-diagnostic-database-profile-cardinality-invalid'
);
}
if (
entries.some((entry) =>
/^playlist-refresh\.worker-\d+\.heapsnapshot$/u.test(entry)
)
) {
throw new Error(
'initial-import-diagnostic-playlist-snapshot-unexpected'
);
}
if (
entries.filter((entry) =>
/^database\.worker-\d+\.heapsnapshot$/u.test(entry)
).length !== 1
) {
throw new Error(
'initial-import-diagnostic-database-snapshot-cardinality-invalid'
);
}
}
async function validateJsonArtifact(
directory: string,
realDirectory: string,
artifact: ArtifactDescription
): Promise<void> {
if (artifact.path === null) {
throw new Error(
`initial-import-diagnostic-artifact-missing:${artifact.label}`
);
}
const expectedPath = join(directory, artifact.filename);
if (resolve(artifact.path) !== expectedPath) {
throw new Error(
`initial-import-diagnostic-artifact-path-invalid:${artifact.label}`
);
}
let value: unknown;
try {
const stats = await lstat(expectedPath);
if (!stats.isFile() || stats.isSymbolicLink()) {
throw new Error('unsafe-artifact-type');
}
const realArtifactPath = await realpath(expectedPath);
if (!isContained(realDirectory, realArtifactPath)) {
throw new Error('escaped-artifact');
}
value = JSON.parse(await readFile(realArtifactPath, 'utf8')) as unknown;
} catch (error) {
if (
error instanceof Error &&
(error.message === 'unsafe-artifact-type' ||
error.message === 'escaped-artifact')
) {
throw new Error(
`initial-import-diagnostic-artifact-path-invalid:${artifact.label}`
);
}
throw new Error(
`initial-import-diagnostic-artifact-invalid:${artifact.label}`
);
}
if (!artifact.validate(value)) {
throw new Error(
`initial-import-diagnostic-artifact-invalid:${artifact.label}`
);
}
}
function isContained(directory: string, artifactPath: string): boolean {
const relativePath = relative(directory, artifactPath);
return (
relativePath.length > 0 &&
!isAbsolute(relativePath) &&
relativePath !== '..' &&
!relativePath.startsWith(`..${sep}`)
);
}
@@ -0,0 +1,19 @@
export const CPU_PROFILE = Object.freeze({
endTime: 3_000,
nodes: Object.freeze([
Object.freeze({
callFrame: Object.freeze({
columnNumber: -1,
functionName: '(root)',
lineNumber: -1,
scriptId: '0',
url: '',
}),
hitCount: 1,
id: 1,
}),
]),
samples: Object.freeze([1]),
startTime: 1_000,
timeDeltas: Object.freeze([1_000]),
});
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract';
import {
assertSyntheticM3uImportUrl,
createM3uImportIterationDefinitions,
listM3uImportScenarios,
M3U_IMPORT_SCENARIO_ID,
} from './m3u-import-benchmark-contract';
describe('formal M3U import benchmark contract', () => {
it('defines the exact 10k, 50k, and 100k synthetic scenarios', () => {
assert.deepEqual(listM3uImportScenarios(), [
{
channelCount: 10_000,
id: M3U_IMPORT_SCENARIO_ID.IMPORT_10K,
},
{
channelCount: 50_000,
id: M3U_IMPORT_SCENARIO_ID.IMPORT_50K,
},
{
channelCount: 100_000,
id: M3U_IMPORT_SCENARIO_ID.IMPORT_100K,
},
]);
});
it('schedules one warm-up, five measured runs, and one diagnostic per size', () => {
const definitions = createM3uImportIterationDefinitions(false);
assert.equal(definitions.length, 21);
for (const scenario of listM3uImportScenarios()) {
const scenarioRuns = definitions.filter(
(definition) => definition.scenarioId === scenario.id
);
assert.deepEqual(
scenarioRuns.map(({ kind, runId }) => ({ kind, runId })),
[
{
kind: PERFORMANCE_ITERATION_KIND.WARMUP,
runId: 'warmup-01',
},
...Array.from({ length: 5 }, (_, index) => ({
kind: PERFORMANCE_ITERATION_KIND.MEASURED,
runId: `run-${String(index + 1).padStart(2, '0')}`,
})),
{
kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC,
runId: 'diagnostic',
},
]
);
assert.ok(
scenarioRuns.every(
(definition) =>
definition.channelCount === scenario.channelCount
)
);
}
});
it('keeps fixture sizes unchanged while smoke mode reduces measured runs to one', () => {
const definitions = createM3uImportIterationDefinitions(true);
assert.equal(definitions.length, 9);
for (const scenario of listM3uImportScenarios()) {
assert.deepEqual(
definitions
.filter(
(definition) => definition.scenarioId === scenario.id
)
.map(({ kind, runId }) => ({ kind, runId })),
[
{
kind: PERFORMANCE_ITERATION_KIND.WARMUP,
runId: 'warmup-01',
},
{
kind: PERFORMANCE_ITERATION_KIND.MEASURED,
runId: 'run-01',
},
{
kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC,
runId: 'diagnostic',
},
]
);
}
});
it('accepts only the exact credential-free loopback fixture URL', () => {
assert.doesNotThrow(() =>
assertSyntheticM3uImportUrl(
'http://127.0.0.1:43210/synthetic-performance.m3u'
)
);
for (const unsafe of [
'https://127.0.0.1:43210/synthetic-performance.m3u',
'http://localhost:43210/synthetic-performance.m3u',
'http://127.0.0.1/synthetic-performance.m3u',
'http://user:pass@127.0.0.1:43210/synthetic-performance.m3u',
'http://127.0.0.1:43210/other.m3u',
'http://127.0.0.1:43210/synthetic-performance.m3u?token=secret',
'http://127.0.0.1:43210/synthetic-performance.m3u#fragment',
]) {
assert.throws(
() => assertSyntheticM3uImportUrl(unsafe),
/exact synthetic HTTP loopback URL/
);
}
});
});
@@ -0,0 +1,118 @@
import {
PERFORMANCE_ITERATION_KIND,
type PerformanceIterationKind,
} from './m3u-refresh-cancellation-contract';
import { SYNTHETIC_M3U_RESOURCE_PATH } from './synthetic-m3u-server';
import type { SyntheticM3uChannelCount } from './synthetic-m3u';
export const M3U_IMPORT_SCENARIO_ID = {
IMPORT_10K: 'm3u-import-10k',
IMPORT_50K: 'm3u-import-50k',
IMPORT_100K: 'm3u-import-100k',
} as const;
export type M3uImportScenarioId =
(typeof M3U_IMPORT_SCENARIO_ID)[keyof typeof M3U_IMPORT_SCENARIO_ID];
export interface M3uImportScenario {
readonly channelCount: SyntheticM3uChannelCount;
readonly id: M3uImportScenarioId;
}
export interface M3uImportIterationDefinition {
readonly channelCount: SyntheticM3uChannelCount;
readonly kind: PerformanceIterationKind;
readonly runId: string;
readonly scenarioId: M3uImportScenarioId;
}
const SCENARIOS: readonly M3uImportScenario[] = Object.freeze([
Object.freeze({
channelCount: 10_000,
id: M3U_IMPORT_SCENARIO_ID.IMPORT_10K,
}),
Object.freeze({
channelCount: 50_000,
id: M3U_IMPORT_SCENARIO_ID.IMPORT_50K,
}),
Object.freeze({
channelCount: 100_000,
id: M3U_IMPORT_SCENARIO_ID.IMPORT_100K,
}),
]);
export function listM3uImportScenarios(): readonly M3uImportScenario[] {
return SCENARIOS;
}
export function createM3uImportIterationDefinitions(
smoke: boolean
): readonly M3uImportIterationDefinition[] {
const measuredRuns = smoke ? 1 : 5;
const definitions: M3uImportIterationDefinition[] = [];
for (const scenario of SCENARIOS) {
definitions.push(
iteration(scenario, PERFORMANCE_ITERATION_KIND.WARMUP, 'warmup-01')
);
for (let index = 1; index <= measuredRuns; index += 1) {
definitions.push(
iteration(
scenario,
PERFORMANCE_ITERATION_KIND.MEASURED,
`run-${String(index).padStart(2, '0')}`
)
);
}
definitions.push(
iteration(
scenario,
PERFORMANCE_ITERATION_KIND.DIAGNOSTIC,
'diagnostic'
)
);
}
return Object.freeze(definitions);
}
export function assertSyntheticM3uImportUrl(value: string): void {
let url: URL;
try {
url = new URL(value);
} catch {
throw invalidSyntheticUrl();
}
if (
url.protocol !== 'http:' ||
url.hostname !== '127.0.0.1' ||
url.port.length === 0 ||
url.username.length > 0 ||
url.password.length > 0 ||
url.pathname !== SYNTHETIC_M3U_RESOURCE_PATH ||
url.search.length > 0 ||
url.hash.length > 0
) {
throw invalidSyntheticUrl();
}
}
function iteration(
scenario: M3uImportScenario,
kind: PerformanceIterationKind,
runId: string
): M3uImportIterationDefinition {
return Object.freeze({
channelCount: scenario.channelCount,
kind,
runId,
scenarioId: scenario.id,
});
}
function invalidSyntheticUrl(): Error {
return new Error(
'M3U performance fixture must use the exact synthetic HTTP loopback URL'
);
}
@@ -0,0 +1,184 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { describe, it } from 'node:test';
import {
assertM3uImportOutputPathHasNoSymlinks,
assertM3uImportSourceState,
M3U_IMPORT_RENDERER_CDP_PORT,
resolveM3uImportRendererCdpPort,
resolveM3uImportOutputLayout,
} from './m3u-import-benchmark-support';
import { assertM3uImportRequestDelta } from './m3u-import.benchmark';
describe('initial M3U import benchmark lifecycle', () => {
it('keeps requested output and variant strictly below dist/performance', () => {
const workspaceRoot = resolve('/workspace/iptvnator');
const outputRoot = resolve(
workspaceRoot,
'dist/performance/20260727T040000Z-m3u-import'
);
assert.deepEqual(
resolveM3uImportOutputLayout(workspaceRoot, outputRoot, 'baseline'),
{
outputRoot,
variantDirectory: resolve(outputRoot, 'baseline'),
}
);
for (const requestedRoot of [
'dist/performance/run',
resolve(workspaceRoot, 'dist/performance'),
resolve(workspaceRoot, 'dist/performance-sibling/run'),
resolve(workspaceRoot, '../outside'),
]) {
assert.throws(
() =>
resolveM3uImportOutputLayout(
workspaceRoot,
requestedRoot,
'baseline'
),
/absolute path below dist\/performance/
);
}
assert.throws(
() =>
resolveM3uImportOutputLayout(
workspaceRoot,
outputRoot,
'../escape'
),
/variant is invalid/i
);
});
it('requires a clean source tree for formal runs but permits dirty smoke evidence', () => {
const dirtyState = {
commit: 'a'.repeat(40),
diffSha256: 'b'.repeat(64),
dirty: true,
};
assert.throws(
() => assertM3uImportSourceState(false, dirtyState),
/Formal performance runs require a clean Git worktree/
);
assert.doesNotThrow(() => assertM3uImportSourceState(true, dirtyState));
assert.doesNotThrow(() =>
assertM3uImportSourceState(false, {
...dirtyState,
dirty: false,
})
);
});
it('rejects an existing output ancestor symlink before artifact creation', async () => {
const fixtureRoot = await mkdtemp(
join(tmpdir(), 'iptvnator-m3u-output-path-')
);
const repositoryRoot = join(fixtureRoot, 'repository');
const performanceRoot = join(
repositoryRoot,
'dist',
'performance'
);
const outside = join(fixtureRoot, 'outside');
await mkdir(performanceRoot, { recursive: true });
await mkdir(outside);
await symlink(outside, join(performanceRoot, 'redirect'));
try {
await assert.rejects(
assertM3uImportOutputPathHasNoSymlinks(
repositoryRoot,
join(performanceRoot, 'redirect', 'run')
),
/Performance output path contains a symbolic link/
);
await assert.doesNotReject(
assertM3uImportOutputPathHasNoSymlinks(
repositoryRoot,
join(performanceRoot, 'ordinary', 'run')
)
);
} finally {
await rm(fixtureRoot, { force: true, recursive: true });
}
});
it('keeps formal CDP capture on 9222 while allowing an isolated smoke port', () => {
assert.equal(
resolveM3uImportRendererCdpPort(false, undefined),
M3U_IMPORT_RENDERER_CDP_PORT
);
assert.equal(
resolveM3uImportRendererCdpPort(false, '9222'),
M3U_IMPORT_RENDERER_CDP_PORT
);
assert.equal(resolveM3uImportRendererCdpPort(true, '9322'), 9322);
assert.throws(
() => resolveM3uImportRendererCdpPort(false, '9322'),
/Formal performance runs require CDP port 9222/
);
for (const invalid of ['0', '1023', '65536', '9222.5', 'not-a-port']) {
assert.throws(
() => resolveM3uImportRendererCdpPort(true, invalid),
/IPTVNATOR_PERF_CDP_PORT is invalid/
);
}
});
it('fails closed unless one and only one app GET occurs', () => {
assert.doesNotThrow(() => assertM3uImportRequestDelta(3, 4));
assert.throws(
() => assertM3uImportRequestDelta(3, 3),
/exactly one application GET/
);
assert.throws(
() => assertM3uImportRequestDelta(3, 5),
/exactly one application GET/
);
});
it('waits for the exact upsert plus route-reload GET sequence', () => {
const source = readFileSync(
resolve(
process.cwd(),
'src/performance/m3u-import.benchmark.ts'
),
'utf8'
);
assert.match(source, /status\.databaseRequests\s*===\s*2/);
assert.match(source, /status\.databaseUpsertsCompleted\s*===\s*1/);
assert.match(source, /status\.databaseGetsCompleted\s*===\s*1/);
assert.match(source, /status\.databasePending\s*===\s*0/);
assert.match(source, /status\.preloadSuccessMarkers\s*===\s*2/);
});
it('always disposes renderer capture before closing Electron on iteration failure', () => {
const source = readFileSync(
resolve(
process.cwd(),
'src/performance/m3u-import.benchmark.ts'
),
'utf8'
);
const runIteration = source.indexOf('async function runIteration');
const finallyBlock = source.indexOf('} finally {', runIteration);
const rendererDispose = source.indexOf(
'await renderer.dispose()',
finallyBlock
);
const appClose = source.indexOf('await closeElectronApp(app)', finallyBlock);
assert.ok(finallyBlock > runIteration);
assert.ok(rendererDispose > finallyBlock);
assert.ok(appClose > rendererDispose);
});
});
@@ -0,0 +1,300 @@
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { access, lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
import { createServer as createTcpServer } from 'node:net';
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
import { expect, type Page } from '@playwright/test';
import {
type LaunchedElectronApp,
workspaceRoot,
} from '../electron-test-fixtures';
import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight';
import type { RendererWindowIdentity } from './renderer-window-rss-session';
const OUTPUT_VARIANT_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
export const M3U_IMPORT_RENDERER_CDP_PORT = 9222;
export interface M3uImportGitSourceState {
readonly commit: string;
readonly diffSha256: string;
readonly dirty: boolean;
}
export interface M3uImportOutputLayout {
readonly outputRoot: string;
readonly variantDirectory: string;
}
export interface M3uImportBenchmarkConfiguration extends M3uImportOutputLayout {
readonly electronVersion: string;
readonly measuredRuns: number;
readonly rendererCdpPort: number;
readonly smoke: boolean;
readonly sourceState: M3uImportGitSourceState;
readonly variant: string;
}
export async function resolveM3uImportBenchmarkConfiguration(): Promise<M3uImportBenchmarkConfiguration> {
const requestedRoot = process.env['IPTVNATOR_PERF_OUTPUT_DIR'];
if (!requestedRoot) {
throw new Error('IPTVNATOR_PERF_OUTPUT_DIR is required');
}
const variant = process.env['IPTVNATOR_PERF_VARIANT'] ?? 'baseline';
const layout = resolveM3uImportOutputLayout(
workspaceRoot,
requestedRoot,
variant
);
const smoke = process.env['IPTVNATOR_PERF_SMOKE'] === '1';
const rendererCdpPort = resolveM3uImportRendererCdpPort(
smoke,
process.env['IPTVNATOR_PERF_CDP_PORT']
);
const sourceState = readGitSourceState(workspaceRoot);
assertM3uImportSourceState(smoke, sourceState);
const electronPackage = JSON.parse(
await readFile(
join(workspaceRoot, 'node_modules', 'electron', 'package.json'),
'utf8'
)
) as { version?: unknown };
if (typeof electronPackage.version !== 'string') {
throw new Error('Unable to resolve the Electron runtime version');
}
await assertM3uImportOutputPathHasNoSymlinks(
workspaceRoot,
layout.outputRoot
);
await assertMissing(layout.variantDirectory);
await assertPerformanceArtifactCapacity(layout.variantDirectory);
await mkdir(layout.variantDirectory, { recursive: true });
return Object.freeze({
...layout,
electronVersion: electronPackage.version,
measuredRuns: smoke ? 1 : 5,
rendererCdpPort,
smoke,
sourceState,
variant,
});
}
export function resolveM3uImportOutputLayout(
repositoryRoot: string,
requestedRoot: string,
variant: string
): M3uImportOutputLayout {
const performanceRoot = resolve(repositoryRoot, 'dist', 'performance');
const outputRoot = resolve(requestedRoot);
if (
!isAbsolute(requestedRoot) ||
!isStrictDescendant(performanceRoot, outputRoot)
) {
throw new Error(
'Performance output must be an absolute path below dist/performance'
);
}
if (!OUTPUT_VARIANT_PATTERN.test(variant)) {
throw new Error('IPTVNATOR_PERF_VARIANT is invalid');
}
const variantDirectory = resolve(outputRoot, variant);
if (!isStrictDescendant(outputRoot, variantDirectory)) {
throw new Error('IPTVNATOR_PERF_VARIANT is invalid');
}
return Object.freeze({ outputRoot, variantDirectory });
}
export async function assertM3uImportOutputPathHasNoSymlinks(
repositoryRoot: string,
outputRoot: string
): Promise<void> {
const root = resolve(repositoryRoot);
const output = resolve(outputRoot);
if (!isStrictDescendant(root, output)) {
throw new Error('Performance output path escapes the repository');
}
const components = relative(root, output).split(sep);
let current = root;
for (const component of ['', ...components]) {
current = component === '' ? current : join(current, component);
try {
const stats = await lstat(current);
if (stats.isSymbolicLink()) {
throw new Error(
`Performance output path contains a symbolic link: ${current}`
);
}
} catch (error) {
if (isMissingPath(error)) {
return;
}
throw error;
}
}
}
export function assertM3uImportSourceState(
smoke: boolean,
state: M3uImportGitSourceState
): void {
if (!smoke && state.dirty) {
throw new Error(
'Formal performance runs require a clean Git worktree; commit or stash changes first'
);
}
}
export function resolveM3uImportRendererCdpPort(
smoke: boolean,
requestedPort: string | undefined
): number {
const port =
requestedPort === undefined
? M3U_IMPORT_RENDERER_CDP_PORT
: Number(requestedPort);
if (
(requestedPort !== undefined && !/^[1-9]\d*$/.test(requestedPort)) ||
!Number.isSafeInteger(port) ||
port < 1024 ||
port > 65_535
) {
throw new Error('IPTVNATOR_PERF_CDP_PORT is invalid');
}
if (!smoke && port !== M3U_IMPORT_RENDERER_CDP_PORT) {
throw new Error(
`Formal performance runs require CDP port ${M3U_IMPORT_RENDERER_CDP_PORT}`
);
}
return port;
}
export async function resolveRendererWindowIdentity(
app: LaunchedElectronApp
): Promise<RendererWindowIdentity> {
const handle = await app.electronApp.browserWindow(app.mainWindow);
try {
return await handle.evaluate((browserWindow) => {
const exactWindow = browserWindow as unknown as {
readonly id: number;
readonly webContents: { readonly id: number };
};
return {
browserWindowId: exactWindow.id,
webContentsId: exactWindow.webContents.id,
};
});
} finally {
await handle.dispose();
}
}
export async function assertTcpPortAvailable(port: number): Promise<void> {
const server = createTcpServer();
await new Promise<void>((resolvePromise, rejectPromise) => {
server.once('error', rejectPromise);
server.listen(
{ exclusive: true, host: '127.0.0.1', port },
resolvePromise
);
});
await new Promise<void>((resolvePromise, rejectPromise) => {
server.close((error) =>
error ? rejectPromise(error) : resolvePromise()
);
});
}
export async function assertRendererCdpTarget(
page: Page,
port: number
): Promise<void> {
await expect
.poll(
async () => {
try {
const response = await fetch(
`http://127.0.0.1:${port}/json/list`
);
const targets = response.ok
? ((await response.json()) as unknown)
: null;
return (
Array.isArray(targets) &&
targets.some(
(target) =>
typeof target === 'object' &&
target !== null &&
(target as Record<string, unknown>)['type'] ===
'page' &&
(target as Record<string, unknown>)['url'] ===
page.url()
)
);
} catch {
return false;
}
},
{ timeout: 10_000 }
)
.toBe(true);
}
export async function writeM3uImportJson(
path: string,
value: unknown
): Promise<void> {
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function readGitSourceState(repositoryRoot: string): M3uImportGitSourceState {
const git = (args: readonly string[]): string =>
execFileSync('git', [...args], {
cwd: repositoryRoot,
encoding: 'utf8',
});
const commit = git(['rev-parse', 'HEAD']).trim();
const status = git(['status', '--porcelain=v1', '--untracked-files=all']);
const diffSha256 = createHash('sha256')
.update(status)
.update('\0')
.update(git(['diff', '--binary', 'HEAD']))
.update('\0')
.update(git(['diff', '--binary', '--cached', 'HEAD']))
.digest('hex');
return Object.freeze({
commit,
diffSha256,
dirty: status.trim().length > 0,
});
}
function isStrictDescendant(parent: string, candidate: string): boolean {
const pathFromParent = relative(resolve(parent), resolve(candidate));
return (
pathFromParent.length > 0 &&
pathFromParent !== '..' &&
!pathFromParent.startsWith(`..${sep}`) &&
!isAbsolute(pathFromParent)
);
}
async function assertMissing(path: string): Promise<void> {
try {
await access(path);
} catch {
return;
}
throw new Error(`Performance output already exists: ${path}`);
}
function isMissingPath(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'ENOENT'
);
}
Loaded 100 of 1068 files, more files were not shown because too many files have changed in this diff. Show more