mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
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:
commit
bbcbf6c1ac
1068 files changed
+160062
-13094
No files matched your search
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: feature
|
||||
area: i18n
|
||||
issues: [1192]
|
||||
---
|
||||
|
||||
IPTVnator speaks Hungarian, its 19th language — contributed by @htibcsike.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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/{}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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[,}]/);
|
||||
});
|
||||
+121
@@ -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);
|
||||
}
|
||||
+392
@@ -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,
|
||||
});
|
||||
}
|
||||
+127
@@ -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 };
|
||||
}
|
||||
+197
@@ -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');
|
||||
}
|
||||
}
|
||||
+185
@@ -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
|
||||
);
|
||||
}
|
||||
+234
@@ -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/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
+394
@@ -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
Reference in new issue
Block a user