* chore(lint): hold tests to their own max-lines ceiling
The flat 400-line cap treated a spec like a component. A spec is a flat
list of independent cases, so hitting the cap there produces arbitrary
`-2.spec.ts` splits and hides coverage instead of surfacing design debt —
65 of the 138 files over the limit were tests.
Production code keeps 400. Tests (`**/*.spec.ts`, `**/*.e2e.ts`, and
everything under `apps/*-e2e/**`) get 1200. Blank lines and comments no
longer count, so a docblock can't be the reason a file must be split.
Both limits now live in tools/eslint/max-lines-config.mjs, imported by
eslint.config.mjs and the baseline generator alike. The generator decides
who belongs on the list by running ESLint's own max-lines rule instead of
counting lines itself — a private reimplementation would disagree with the
rule the moment either side changed (a `//` inside a template literal is
enough) and yield a baseline that turns CI red while looking correct.
The baseline drops 126 -> 68 entries with nothing added, and six now-dead
`eslint-disable max-lines` directives are removed. A new eslint-tools test
asserts the committed baseline still matches what the generator produces,
so a stale entry or a forgotten regeneration fails CI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(lint): classify eslint-tools in the coverage policy
A project with a `test` target must be assigned a coverage tier, so
adding eslint-tools broke `coverage:policy:check` before the unit suite
even ran. Tier B alongside packaging and release-tools: these are Node
tests over lint tooling, and a coverage percentage across a generated
list would not mean anything.
CI runs Tier B/C through its own `--run-non-tier-a` step, so the
baseline-consistency test executes there rather than being skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(packaging): register IPTVnator as the .m3u/.m3u8 handler
Every runtime path for an OS-supplied playlist existed, but no packaging
metadata claimed the file types — so the OS never offered IPTVnator as a
handler and `open-file` could not fire from Finder.
`fileAssociations` declares one entry per extension. Electron Builder derives
all three platform registrations from it: macOS `CFBundleDocumentTypes` (the
prerequisite for `open-file`), the NSIS registry entries, and, on Linux, the
desktop entry's `MimeType` plus `/usr/share/mime/packages/iptvnator.xml` for
deb/rpm/pacman. Neither platform needs a dedicated icon — both fall back to the
app icon.
Declaring `MimeType` under `linux.desktop.entry` would not have worked:
Electron Builder assigns the association-derived value *after* spreading that
object, so an explicit key there is silently overwritten. The per-association
`mimeType` fields produce the same entry through the supported path.
Registering the types also exposes a gap in the delivery side. The generated
Linux `Exec` ends in `%U`, so file managers hand over a percent-encoded
`file://` URI rather than a path, which `createPlaylistOpenRequest` would have
resolved into a bogus relative path. It now decodes a `file://` candidate
before the extension check. Suppressing the `%U` instead would mean putting an
exec code in `linux.executableArgs`, which also passes it to the app as a real
argument.
Verified on macOS against a signed packaged bundle: Launch Services lists the
app as a `public.m3u-playlist` handler, and an LS-initiated open imports the
playlist both on a cold launch and against the already-running process.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(playlist): open every playlist of a multi-file selection
`%U` is the plural exec code, so selecting several playlists in a Linux file
manager is one launch carrying one argument per file. Both argv paths called
`extractPlaylistOpenRequestFromArgv`, which returned at the first match, so
everything after the first playlist was silently discarded — a gap this PR
itself opened by making the desktop entry reachable in the first place.
The extractor is now plural and returns every match in argument order, and the
queue gained `enqueueAll` so a selection is pushed under a single flush: a
delivery that fails partway leaves the untouched remainder queued in arrival
order rather than interleaved.
Covered by unit tests over a mixed argv (percent-encoded `file://` URI, a
non-playlist argument, a second URI) and by a new Electron E2E that launches
with two playlist arguments and asserts both are imported.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Opening an .m3u/.m3u8 file from the command line or a file association did
nothing. The renderer parsed `process.argv` and sent an `OPEN_FILE` IPC event
that had no `ipcMain` handler and no preload channel, so `sendIpcEvent` logged
it as an unknown type and dropped it.
The path now belongs to the main process, which is where the OS actually
delivers it:
- argv is parsed on first launch (skipping the executable and Chromium
switches) and normalized to an absolute path;
- macOS gets an `open-file` listener registered before `whenReady`, since
Launch Services never puts the path in argv;
- the single-instance guard forwards a second launch's argv and working
directory instead of discarding them, so opening a playlist against a
running app works too.
Requests are queued in the main process until the renderer subscribes to the
`OPEN_FILE` push and drains the queue, which closes the startup race. The
import itself reuses the existing file path, so persistence, playlist-scoped
EPG and the navigation to the new playlist behave exactly like a dialog
import; a failed open now surfaces a snackbar instead of silence.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`removeDataDir` tolerates a locked directory rather than failing the run, but
then abandons it, and nothing collects it on our behalf: Windows never clears
%TEMP% on process exit, and the Unix equivalents only run on a schedule. Every
teardown that lost that race leaked a database and user-data tree on developer
machines and long-lived runners, invisibly, while CI stayed green.
Sweeps leftover `iptvnator-electron-e2e-*` directories once per run, before the
first one is created. Ownership is settled by pid rather than age: each run
records its pid and the sweep asks the OS via `process.kill(pid, 0)`.
- A live owner is kept, so a concurrent suite is never collected — this repo is
routinely checked out into several worktrees at once. Age cannot answer this:
writes land under `databases/` and `user-data/`, which never refreshes the
root's mtime, so a run paused in a debugger looks arbitrarily old.
- A dead owner is collected immediately.
- An undeterminable owner (missing, empty or malformed marker) falls back to a
24h cutoff. The marker is published via rename so a half-written file cannot
bypass that guard.
- A live-looking owner past a week is collected anyway, since the OS recycles
pids and a stranger inheriting one would otherwise pin the directory forever.
Covered by a 10-test spec running on Linux, macOS and Windows, since
`process.kill(pid, 0)` semantics are platform-specific. Each behaviour was
verified to fail against the preceding implementation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Settings live in the renderer's IndexedDB, and two failure modes made them look
saved while nothing reached disk.
A second app instance sharing the same userData directory cannot take the
Chromium storage lock, so its renderer reads defaults and every write is
dropped. The app now holds a single-instance lock and focuses the running window
instead of starting a rival copy. The lock is requested after the userData
override so E2E runs with their own data dir keep independent locks, and after
Squirrel event handling. IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1 opts out for local
CDP debugging.
updateSettings() patches in-memory state before persisting and the submit path
had no rejection handler, so a failed write produced an unhandled rejection and
no user-visible feedback. SettingsStore now records which half of the round trip
failed, and the settings page surfaces it through a dismissible error snackbar;
the dialog stays open on failure so the save can be retried.
Two follow-ups from review, both wider than the report:
- a second launch now re-creates the main window when the lock owner has none
left, so closing the last window on macOS no longer leaves a second launch
quitting silently with nothing on screen
- App.onMainWindowCreated() re-runs window-owned bindings for every rebuilt
window, so the downloads broadcaster stops holding a destroyed window. This
also fixes the same bug on the pre-existing dock `activate` path.
Closes#1156Closes#102
* fix(electron-backend): make test suite and lint host-agnostic across Windows/Linux checkouts
Windows checkouts (core.autocrlf=true) had 13 pre-existing jest failures
and 5 Windows-only lint errors in electron-backend while Linux CI was
green:
- embedded-mpv-native-source.spec: normalize CRLF after readFileSync so
multi-line source assertions match on autocrlf checkouts
- worker-runtime-paths.spec: build expected candidate paths with
path.join instead of hardcoded POSIX strings
- external-player-launch-context: join darwin-only paths (.app bundle
executables, Homebrew Caskroom) with path.posix.join so simulated
darwin platforms resolve correctly on win32 hosts (no-op on macOS)
- app.spec: build packaged-navigation fixtures with path.resolve +
pathToFileURL; file:///tmp/... is not a valid win32 file URL
- lint target: quote the eslint glob. The unquoted ** was expanded by
the POSIX shell on Linux (shallow match), so CI linted only a subset
of files while Windows passed the literal pattern to ESLint and
linted the full tree - the hosts checked different file sets
- fix the 5 errors full-tree linting surfaces: prefer-const in
epg-worker.service and database.worker-connection, no-unsafe-finally
in external-player-session-registry and embedded-mpv-native.service
(rewritten as catch-swallow with identical semantics, pinned by new
regression tests), intentional no-control-regex in the recording
filename sanitizer; drop stale unused disable directives
- document the quoted-glob convention in CLAUDE.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: mirror the quoted-lint-glob convention into AGENTS.md
AGENTS.md already mirrors the neighbouring max-lines/baseline paragraph from
CLAUDE.md, and it requires coding conventions to stay in sync between the two
files. The quoted-glob rule landed only in CLAUDE.md, so agents bootstrapping
from AGENTS.md could reintroduce a host-dependent lint target.
Also corrects the wording in both copies: the shallow expansion happens on
macOS as well as Linux — /bin/sh has no globstar on either — and the target
still exits 0 with a broken glob, which is why this went unnoticed. Adds the
file-count check that catches it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The mapping handlers documented a fail-soft contract but only honored half
of it. Four of them returned the database operation's promise from inside
the `try` block without awaiting it, so the rejection escaped the `catch`:
the try block exits before the promise settles. A `getDatabase()` failure
was caught, but a SQLite error in the operation rejected the
`ipcMain.handle` promise, and the renderer call threw instead of receiving
`null` / `{success:false}` / `[]`.
Adding `await` in handleGetEpgMapping, handleSetEpgMapping,
handleDeleteEpgMapping and handleSearchEpgChannels closes the gap.
resolveChannelIds and handleGetEpgMappingsBatch already awaited correctly
and are unchanged.
This is pre-existing — the same shape predates the epg.events.ts split in
636545cb, which preserved semantics faithfully and inherited the bug.
Adds epg-mapping.service.spec.ts, the first coverage these handlers have
had: table-driven over all six functions against both failure modes, plus
the guard clauses and queryByResolvedChannelIds remapping. Verified to fail
on the old behavior — reverting the four awaits fails exactly the four
operation-rejection cases.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* test(performance): deflake the real-timer event-loop delay spec
The real-timer capture spec failed under full-suite parallelism because
`monitorEventLoopDelay()` records nothing on its first internal timer
tick - that tick only seeds the previous timestamp, so the first delay
sample lands on the second tick. Condition-based arming therefore needs
two event-loop turns inside its fixed 50ms wall-clock budget, and a
machine running 10 Jest workers stretches a single turn past 20ms. The
capture then degraded to a documented `event-loop-delay-arm-timeout`,
which is the intended graceful path, while the spec asserted the happy
path of that race and turned an environmental outcome into a red build.
Retry the real-runtime capture within a 5s budget instead. The real
`node:perf_hooks` runtime and the real 20ms block are kept, since the
fake harness returns a hard-coded histogram max and never measures
anything. Every attempt still asserts a contract: instrumentation never
breaks the wrapped work, and a null delay must carry a documented
arm/flush timeout rather than being silently null. Budget exhaustion
warns instead of failing, so load can no longer produce a false failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(performance): assert the real delay measurement unconditionally
Codex flagged that budget exhaustion still passed the test, so a
regression that made arming or flushing time out on every attempt would
have been reported as a warning rather than a failure - removing the only
assertion backed by Node's real histogram and a genuine event-loop block.
Drop the tolerant retry loop. The arming deadline is read through the
injectable `readMonotonicMs()`, so scaling only that clock leaves the
wait bounded by its other limit, the 50-poll ceiling, which is ~25x the
two event-loop turns arming actually needs. Everything else stays
production code: the real `monitorEventLoopDelay()` histogram, real
`setTimeout()` polling, and real epoch/CPU/ELU boundaries. The scaled
clock reaches nothing but the wait budgets, since its only other consumer
records phase events and this spec records none.
The test now always asserts a real measurement and hard-fails otherwise.
Verified by mutation: forcing arming to never arm fails it, and dropping
the deliberate block fails it at maxMs 3.8ms against the 10ms floor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Follow-up to #1278, which split four of the files the electron-backend lint
target had been silently skipping. Four were left over; this splits them, so
no file in the project sits above the 400-line cap outside the baseline.
None are added to tools/eslint/max-lines-baseline.mjs — the baseline only
shrinks. Shared setup moves to *.test-helpers.ts, the suffix
tsconfig.app.json already excludes, so the production build never sees jest
globals.
- remote-control.events.spec.ts 588 -> 188, plus remote-control-http.spec.ts.
The mock registry moves to remote-control.test-helpers.ts; each spec keeps
its own jest.mock() factories, which resolve the mock-prefixed exports.
- downloads.events.spec.ts 530 -> 182, plus downloads-actions.spec.ts. The
jest.doMock setup is not hoisted, so the whole harness moves to
downloads.test-helpers.ts behind setupDownloadsEventsHarness().
- http-server.spec.ts 448 -> 377, plus resolve-static-file-path.spec.ts. The
resolveStaticFilePath cases were already self-contained.
- worker-performance-capture.spec.ts 408 -> 149, plus
worker-performance-capture.resilience.spec.ts, matching the .concurrency
and .histogram siblings.
Test bodies are unchanged; the helpers keep the same identifiers in scope so
the splits are a move, not a rewrite.
Verified with the glob quoted locally (that quoting is #1176's, not part of
this change): 245 files, 0 max-lines errors, and the only remaining lint
errors are the five #1176 fixes. Both tsconfig.app.json and
tsconfig.spec.json typecheck clean.
Note: worker-performance-capture.concurrency.spec.ts is flaky on master
independently of this change — it asserts a real 20ms event-loop block and
failed 4/4 clean-master runs here.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The four EPG mapping handlers wrap their DB call in try/catch but return the
promise instead of awaiting it. An async function *adopts* a returned promise
rather than awaiting it, so the catch block only ever fired when getDatabase()
itself threw — a rejection from the underlying query escaped to the IPC caller
instead of returning the intended null / {success:false} / [] fallback.
Add the missing await to handleGetEpgMapping, handleSetEpgMapping,
handleDeleteEpgMapping and handleSearchEpgChannels, matching
handleGetEpgMappingsBatch and resolveChannelIds in the same module, which
already awaited and so already failed soft for both cases. This restores the
contract stated in the module's own doc comment: a mapping lookup must never
take down an EPG request.
The behavior predates the max-lines split in #1278, which carried it over
verbatim from epg.events.ts.
The new spec drives the real IPC handlers captured from ipcMain.handle, so it
asserts the contract that matters: the caller gets a fallback, not a rejected
promise. Reverting the four awaits fails exactly those four handlers and
leaves the already-correct batch handler green.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
settings.component.spec.ts was 1516 lines and the last settings file in the
max-lines baseline. The behaviour that moved into facades now has its own
specs, driven directly instead of through the rendered page.
- settings-app-update.facade.spec.ts: status polling/retry, bridge actions,
release notes dialog, version messaging, dispose
- settings-epg.facade.spec.ts: refresh, clear flow, post-save re-fetch
- settings-playlist-reset.facade.spec.ts: summary, dialog, Electron progress,
browser fallback, failure snackbar
- settings-backup.facade.spec.ts: desktop export, browser download fallback
- settings.component.spec.ts keeps the page shell, the facade lifecycle seam
and runtime capabilities; settings.component.form.spec.ts takes hydration,
section outputs, dashboard controls and submit
- settings-section-scroll.directive.spec.ts gives the directive its first spec
- shared TestBed fixtures live in settings/test-stubs/, kept out of both the
app build (.stub.ts) and the coverage ratchet (test-stubs/)
105 settings tests, up from 94; every file is under the 400-line limit, so
settings.component.spec.ts leaves the baseline.
Moves the fictional movie catalog into `libs/shared/marketing-fixtures` so the
Xtream and Stalker mocks describe the same titles, and adds 20 rendered posters
plus the shared fixture types behind them.
Supporting changes made while getting it green:
- `shared-marketing-fixtures` is classified Tier B in the coverage policy. Not
Tier A: it is fictional fixture data, so a statement percentage over it means
nothing, and a Tier A entry would pull it into the merged coverage map and the
ratchet. Tier B still runs its spec in CI. `stalker-mock-server` needs no entry
of its own — it is already Tier C and the Tier B/C runner falls back to
`pnpm nx test <project>`, so its new `marketing-poster-url.spec.ts` runs.
- Two release-capture defects the catalog reorder introduced, both fixed in
`tools/release/capture-app-driver.ts`:
- VOD stream ids are `MARKETING_VOD_STREAM_ID_BASE + index` and the generator
now lists the showcase movies first, so 62000-62002 became Black Harbor, The
Paper Astronaut and Summer Static while the dashboard seeding still mapped
those ids to the previous titles' backdrops.
- the raw `tsx` spawn of the Xtream mock lacked `--tsconfig
tsconfig.base.json`, so the mock could not resolve
`@iptvnator/shared/marketing-fixtures` and the capture never started. Both
mock projects' own serve targets already passed the flag.
Split epg.events.ts (514), the embedded MPV frame-copy adapter (428) and two of its specs (547, 539) below the 400-line hard limit, and teach the baseline generator to skip files that already carry a justified file-wide eslint-disable max-lines.
The generated baseline list is unchanged: 128 entries before and after. No behavior change.
settings.component.ts had grown to 819 lines — past the CLAUDE.md target (<300)
and hard maximum, passing lint only because it sat in the max-lines baseline.
The behaviour moves into facades the template binds to directly, following the
precedent already in this folder: new app-update (218), form (197), epg (123),
embedded-mpv (74) and remote-control (37) facades, with playlist-reset extended
to 143 and settings-options to 200. The component is now a 259-line coordinator
holding capability flags, section nav, players() and the cross-facade flows.
settings.component.ts is removed from the max-lines baseline.
No behaviour change. One ordering detail: applyChangedSettings now applies
language/theme before kicking off the EPG re-fetch; changeTheme only touches DOM
theme sync and translate.use does not touch the form, so the two are
independent.
Adds contract-focused regression coverage for the Electron HTTP server,
remote-control events, settings events, and managed download paths, and makes
Tier A coverage fail closed when instrumentation fails or a runtime-owning
production file disappears from a project or from the merged Istanbul report.
The old `coverage:ci` exited 0 despite a `Failed to collect coverage`
diagnostic: libs/m3u-state/src/lib/effects.ts was simply absent from the merged
map. All 30 Tier A reports are now required, the merged map covers 710 files,
and effects.ts is reported as 0/159 instead of silently disappearing.
Also fixes remote static-file path containment for encoded, malformed, NUL,
POSIX and Win32-style traversal inputs, with behavior-preserving testability
seams.
Statements 69.27% -> 69.54%; http-server.ts 0% -> 90.21%,
remote-control.events.ts 0% -> 96.55%, settings.events.ts 59.25% -> 96.29%.
Supersedes #1252 and #872. uuid 14 is ESM-only, apps/web/jest.config.ts only
kept v9 working by mapping `^uuid$` at a `wrapper.mjs` that v14 no longer
ships, and the specifier also has to be synced in
libs/shared/m3u-utils/package.json or @nx/dependency-checks fails lint.
All four call sites only used `v4()`, so the dependency goes away instead.
`createRandomId()` prefers `crypto.randomUUID()` and falls back to building the
same v4 shape from `crypto.getRandomValues()` — that fallback is load-bearing,
because randomUUID is only exposed in secure contexts and the self-hosted PWA
is regularly served over plain http on a LAN address. getRandomValues stays
available there, and it is what uuid's own v4 used.
`@types/uuid` goes too; it only existed for the untyped v9 package.
Rebuilt from #1251 so the group could merge, on top of the transitive-CVE
overrides from #1258. Supersedes #1230 and #1251.
Carries axios 1.16.0 -> 1.18.1, closing seven runtime-scope advisories
including the proxy-credential leak on redirects, and sharp 0.34.5 -> 0.35.3
for the libvips CVEs.
`esModuleInterop` moves to tsconfig.base.json. artplayer 5.4.0 switched from a
Parcel build exposing `module.exports.default` to UMD assigning
`module.exports` directly; the flag was only set in apps/web, so every lib
compiled `import Artplayer from 'artplayer'` to `.default` and got undefined.
Production was never affected — esbuild resolves the ESM entry.
Two packages are deliberately held back, each for its own PR:
- epg-parser ^0.5.0 — grouped as a minor, but 0.x minors are breaking and this
one reshapes the parse output (`channel.name` -> `displayName`, icons/urls
become objects, `credits` becomes role-keyed, dates switch to ISO). Its only
consumer is the uncovered web-backend `/parse-xml` endpoint.
- electron-builder ^26.15.3 — rewrote the snap target, and the resulting snap
cannot start (`command.sh` execs a `desktop-init.sh` that never lands at the
snap root under our core22 strict config). Its two required fixes go with it:
the `engines` node floor from @electron/rebuild 4, and resolving upstream
node-gyp instead of the dropped `@electron/node-gyp` fork.
The custom minimize/maximize/close controls stayed hidden forever after
leaving HTML-element (video player) fullscreen on Windows: window state was
polled at event time, and isFullScreen() can still report the pre-transition
value while 'leave-full-screen' fires, leaving a stale push with no later
event to correct it. The same polling on the companion flag cleared
isMaximized during fullscreen transitions and stuck the maximize/restore
glyph on the wrong icon.
attachWindowStateEvents now seeds the state once at window creation and each
event patches only the flag it names, sending a copy per push. The
enter/leave-html-full-screen variants are wired too.
Regression coverage: app-window-state.spec.ts (9 cases, 6 of which fail
against the old implementation) and an Electron E2E case that toggles HTML
element fullscreen and asserts the controls come back.