From 650da4a1d3ba75f87f2b8a73ea2b1db818f70815 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:54:27 +0200 Subject: [PATCH] ci(test): type-check Jest spec programs and gate it in CI (#1705) * build(test): make spec tsconfigs resolve what Jest resolves Lib spec tsconfigs used module: commonjs with node10 resolution, which cannot see Angular's exports-only secondary entry points, and dropped global.d.ts, so tsc reported thousands of resolution errors and no window.electron typing. Switch them to module: preserve with bundler resolution (ts-jest still forces CommonJS emit outside ESM mode), add global.d.ts to every spec program, type jest.unstable_mockModule for the ESM workspace, include the ui-epg and ui-playback specs that jest.web-esm.workspace.ts runs under the web spec config, and drop the snack-bar stub that shadowed the real Material types. Co-Authored-By: Claude Fable 5.1 * ci(test): gate spec type-checking with typecheck:spec Add tools/typecheck/spec-typecheck.mjs, which runs tsc --noEmit over every tsconfig.spec.json with a small pool and fails on any diagnostic, wire it into the unit-and-typecheck job after typecheck:ci, and document the gate and the spec tsconfig conventions in the validation map. Also bring the non-Tier-A spec configs (remote-control-web, ui-remote-control, stalker-mock-server) to the same conventions so the gate covers the whole workspace. Co-Authored-By: Claude Fable 5.1 * test: fix the spec type errors surfaced by typecheck:spec With the spec programs resolving modules and ambient typings correctly, tsc reported 432 genuine errors across the Tier A projects: read-only capability flags assigned on Partial<> doubles, signal-store values used as types, fixtures missing required fields, index-signature property access, partial bridge doubles cast through incompatible shapes, and deferred resolvers narrowed to never. Type the doubles instead of casting to any: writable mapped types for capability flags, InstanceType, typed jest.fn signatures, protectedState: false on test signal stores, and completed fixtures. Production changes are limited to bracket access for index-signature properties under the libs' noPropertyAccessFromIndexSignature setting and two narrowing guards in the global favorites loader. Co-Authored-By: Claude Fable 5.1 * test(playback): use the ESM setup's jest global in the controls fixtures The fixture imported jest from @jest/globals, which is not a direct dependency. Jest provides that module at runtime, so tests passed, but on a clean pnpm install tsc cannot resolve it and typecheck:spec failed in CI. The ESM test setup already installs import.meta.jest as the global, typed by @types/jest, as the other ESM specs use it. Co-Authored-By: Claude Opus 5.5 * test: type the parental lock doubles merged since the gate was written The parental lock feature (#1601) and the Stalker actor route landed on master with spec doubles declared as zero-argument jest.fn()s that the tests then drive with the real arguments, plus a copy of the ResizableDirective override imported from a library that does not export it. Give the doubles the lock service's real signatures, drop the dead override as in the sibling layout specs, use bracket access for the actor route's personId param, and keep the Stalker layout spec within the 1200-line limit. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Fable 5.1 --- .github/workflows/ci.yml | 3 + apps/remote-control-web/tsconfig.spec.json | 6 +- apps/stalker-mock-server/tsconfig.spec.json | 1 + apps/web/src/app/app.component.spec.ts | 7 +- apps/web/src/app/app.routes.spec.ts | 12 +- ...edded-mpv-player-recording-message.spec.ts | 2 +- .../app-update-install.service.spec.ts | 2 +- .../parental-lock-enforcement.service.spec.ts | 12 +- .../playback-keep-awake.service.spec.ts | 8 +- .../src/app/services/settings.service.spec.ts | 3 +- ...playlist-connection-editor.service.spec.ts | 1 + .../settings-about-section.component.spec.ts | 10 + .../app/settings/settings-epg.facade.spec.ts | 7 +- ...ettings-playback-section.component.spec.ts | 22 +- .../settings-playlist-reset.facade.spec.ts | 4 +- .../settings-unload-guard.service.spec.ts | 15 +- .../settings/settings.component.form.spec.ts | 5 +- .../app/settings/settings.component.spec.ts | 10 +- .../test-stubs/settings-test-harness.stub.ts | 2 +- apps/web/src/jest-esm.d.ts | 12 + apps/web/tsconfig.spec.json | 17 +- docs/architecture/validation-map.md | 32 +- docs/maintenance/agent-context-map.md | 2 +- .../src/lib/epg-progress.service.spec.ts | 5 +- .../lib/epg-runtime-bridge.service.spec.ts | 5 +- .../data-access/src/lib/epg.service.spec.ts | 31 +- libs/epg/data-access/tsconfig.spec.json | 6 +- .../lib/reducers/playlist.reducers.spec.ts | 4 +- .../hls-playback-evidence.util.spec.ts | 2 +- .../add-playlist-dialog.component.spec.ts | 64 ++-- .../import/feature/tsconfig.spec.json | 6 +- .../m3u-epg-guide-source.service.spec.ts | 5 +- .../video-player.component.spec.ts | 20 +- .../playlist-refresh-action.service.spec.ts | 6 +- .../playlist-switcher.component.spec.ts | 1 - .../playlist-info.component.spec.ts | 7 +- .../recent-playlists.component.spec.ts | 6 +- libs/playlist/shared/ui/tsconfig.spec.json | 6 +- .../src/lib/playlist-context.facade.spec.ts | 39 ++- libs/playlist/shared/util/tsconfig.spec.json | 6 +- .../portal/catalog/feature/tsconfig.spec.json | 6 +- ...ownload-library-navigation.service.spec.ts | 4 +- .../src/lib/download-queue.component.spec.ts | 6 +- .../src/lib/downloads.component.spec.ts | 8 +- .../download-offline-detail.component.spec.ts | 10 +- .../downloads/feature/tsconfig.spec.json | 6 +- ...stream-resolver.service.epg-offset.spec.ts | 21 +- .../stream-resolver.service.spec.ts | 6 +- .../lib/collection/stream-resolver.service.ts | 4 +- .../unified-favorites-data.service.spec.ts | 8 +- .../unified-recent-data.service.spec.ts | 5 +- .../src/lib/source-health.service.spec.ts | 4 + .../shared/data-access/tsconfig.spec.json | 6 +- .../category-view/category-view.component.ts | 3 +- .../unified-collection-page.component.spec.ts | 4 +- .../unified-live-tab.component.spec.ts | 1 + libs/portal/shared/ui/tsconfig.spec.json | 6 +- .../src/lib/live-panels-controller.spec.ts | 2 +- .../lib/navigation/portal-route.utils.spec.ts | 26 +- .../util/src/lib/vod-watched-toggle.spec.ts | 4 +- libs/portal/shared/util/tsconfig.spec.json | 6 +- .../lib/stalker-live-playback.utils.spec.ts | 9 +- .../lib/stalker-portal-repair.service.spec.ts | 2 +- .../src/lib/stalker-session.service.spec.ts | 8 +- .../src/lib/stalker-vod.utils.spec.ts | 4 +- .../src/lib/stalker.store.compat.spec.ts | 11 +- .../with-stalker-content.feature.spec.ts | 63 ++-- .../with-stalker-favorites.feature.spec.ts | 2 +- .../with-stalker-portal.feature.spec.ts | 5 +- .../with-stalker-recent.feature.spec.ts | 2 +- .../with-stalker-series.feature.spec.ts | 1 + .../stalker/data-access/tsconfig.spec.json | 6 +- .../src/lib/stalker-actor-route.component.ts | 2 +- ...talker-collection-detail.component.spec.ts | 92 +++-- .../stalker-inline-detail.component.spec.ts | 16 +- .../stalker-live-auto-open.spec.ts | 12 +- .../stalker-live-navigation.spec.ts | 17 +- ...alker-live-stream-layout.component.spec.ts | 29 +- ...stream-layout.playback-session-key.spec.ts | 26 +- ...r-live-stream-layout.remote-status.spec.ts | 28 +- .../stalker-live-stream-layout.spec-stubs.ts | 8 +- .../stalker-search.component.spec.ts | 4 +- .../stalker-series-download.adapter.spec.ts | 4 +- .../stalker-series-resume.spec.ts | 8 +- ...r-series-view.playback-session-key.spec.ts | 12 +- .../stalker-vod-playback-controller.spec.ts | 26 +- .../portal/stalker/feature/tsconfig.spec.json | 6 +- .../pwa-xtream-data-source.spec.ts | 15 +- .../services/epg-queue-invalidation.spec.ts | 6 +- .../lib/services/epg-queue.service.spec.ts | 2 +- .../lib/services/xtream-url.service.spec.ts | 8 +- .../xtream-xmltv-fallback.service.spec.ts | 2 +- .../features/with-content.feature.spec.ts | 11 +- .../src/lib/stores/xtream.store.spec.ts | 44 ++- .../src/lib/with-favorites.feature.spec.ts | 11 +- .../xtream/data-access/tsconfig.spec.json | 6 +- .../account-info.component.spec.ts | 11 + .../lib/actor/xtream-actor-route.component.ts | 2 +- .../live-stream-layout-stubs.spec-data.ts | 7 - .../live-stream-layout.component.spec.ts | 21 +- .../live-stream-layout.parental-lock.spec.ts | 8 +- .../live-stream-layout.sidebar-levels.spec.ts | 17 +- .../xtream-live-channel-navigation.service.ts | 2 +- .../search-results.component.spec.ts | 43 +-- .../serial-details-playback.service.ts | 2 +- .../serial-details.component.ts | 4 +- .../vod-details-external-playback.spec.ts | 1 + .../vod-details-route-playback.spec.ts | 6 +- .../vod-details-route.component.ts | 8 +- .../vod-multi-source-host-pin.spec.ts | 1 + .../vod-multi-source-host-races.spec.ts | 1 + .../vod-multi-source-host.service.spec.ts | 10 +- ...am-workspace-route-session.service.spec.ts | 78 ++--- libs/portal/xtream/feature/tsconfig.spec.json | 6 +- libs/services/src/material-snack-bar.d.ts | 9 - libs/ui/components/tsconfig.spec.json | 6 +- .../epg-guide-keyboard.controller.spec.ts | 4 +- .../lib/epg-guide/epg-guide.component.spec.ts | 12 +- .../epg-source-status.component.spec.ts | 7 +- libs/ui/pipes/tsconfig.spec.json | 6 +- ...art-player-source-session.captions.spec.ts | 6 +- .../art-player-source-session.dash.spec.ts | 12 +- .../art-player-source-session.spec.ts | 33 +- .../art-player-video-session.spec.ts | 2 +- .../embedded-mpv-command-runner.spec.ts | 8 +- ...mpv-controls.adapter.command-order.spec.ts | 2 +- ...d-mpv-player.component.dock-panels.spec.ts | 13 +- ...v-player.component.shared-controls.spec.ts | 6 +- ...ayer.component.shared-interactions.spec.ts | 2 +- ...d-mpv-session-controller.lifecycle.spec.ts | 9 +- .../embedded-mpv-session-controller.spec.ts | 4 +- ...tml-video-player-controls.spec-fixtures.ts | 1 - .../html-video-player.component.spec.ts | 6 +- .../controls-menu-state.spec.ts | 16 +- ...ntrols.component.subtitle-settings.spec.ts | 5 +- .../player-controls.component.surface.spec.ts | 4 +- .../portal-inline-player.component.spec.ts | 7 +- .../vjs-player.component.captions.spec.ts | 2 +- .../vjs-player.component.reset.spec.ts | 2 +- ...s-player.component.shared-controls.spec.ts | 2 +- .../vjs-player/vjs-player.component.spec.ts | 2 +- libs/ui/remote-control/tsconfig.spec.json | 6 +- .../src/lib/dashboard-data.service.spec.ts | 25 ++ .../dashboard/data-access/tsconfig.spec.json | 6 +- .../dashboard/feature/tsconfig.spec.json | 6 +- .../global-favorites.service.ts | 4 +- ...space-category-lock-action.service.spec.ts | 12 +- .../workspace-context-panel.component.spec.ts | 8 +- ...rkspace-keyboard-shortcuts.service.spec.ts | 26 +- ...kspace-player-commands.contributor.spec.ts | 4 +- ...ce-shell-context-sidebar.component.spec.ts | 8 +- .../workspace-shell-header.component.spec.ts | 4 +- .../services/workspace-shell.facade.spec.ts | 43 ++- .../shell/feature/tsconfig.spec.json | 6 +- libs/workspace/shell/util/tsconfig.spec.json | 6 +- package.json | 2 + tools/typecheck/spec-typecheck.mjs | 328 ++++++++++++++++++ tools/typecheck/spec-typecheck.test.mjs | 171 +++++++++ 158 files changed, 1517 insertions(+), 632 deletions(-) create mode 100644 apps/web/src/jest-esm.d.ts delete mode 100644 libs/services/src/material-snack-bar.d.ts create mode 100644 tools/typecheck/spec-typecheck.mjs create mode 100644 tools/typecheck/spec-typecheck.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce983514b..747ec5e10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -386,6 +386,9 @@ jobs: - name: Typecheck web and Electron entry points run: pnpm run typecheck:ci + - name: Typecheck Jest spec programs + run: pnpm run typecheck:spec:test && pnpm run typecheck:spec + - name: Check i18n drift run: pnpm run i18n:check diff --git a/apps/remote-control-web/tsconfig.spec.json b/apps/remote-control-web/tsconfig.spec.json index cc2617958..19497995e 100644 --- a/apps/remote-control-web/tsconfig.spec.json +++ b/apps/remote-control-web/tsconfig.spec.json @@ -2,12 +2,12 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/apps/stalker-mock-server/tsconfig.spec.json b/apps/stalker-mock-server/tsconfig.spec.json index 09849f85f..83f6021cc 100644 --- a/apps/stalker-mock-server/tsconfig.spec.json +++ b/apps/stalker-mock-server/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "rootDir": ".", "outDir": "../../dist/out-tsc", "module": "commonjs", "moduleResolution": "node10", diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index e11422009..b66f3c6f9 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -37,6 +37,11 @@ import { AppDateLocaleService } from './app-date-locales'; import { ElectronServiceStub } from './services/electron.service.stub'; import { SettingsService } from './services/settings.service'; +/** Writable double for the bridge's read-only capability getters. */ +type EpgBridgeStub = { + -readonly [K in keyof EpgRuntimeBridgeService]?: EpgRuntimeBridgeService[K]; +}; + jest.spyOn(global.console, 'error').mockImplementation(() => { // suppress console.error output during tests }); @@ -79,7 +84,7 @@ describe('AppComponent', () => { let store: MockStore; let translateService: TranslateService; let runtimeCapabilities: Partial; - let epgBridge: Partial; + let epgBridge: EpgBridgeStub; beforeEach(waitForAsync(() => { runtimeCapabilities = { diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 00969c2fb..edd939b1c 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -1,6 +1,8 @@ import { TestBed } from '@angular/core/testing'; import { ParentalLockService, SettingsStore } from '@iptvnator/services'; +type AppRoutesModule = typeof import('./app.routes'); + describe('app routes', () => { let workspaceRoute: import('@angular/router').Route | undefined; let workspaceChildren: Array<{ @@ -11,14 +13,8 @@ describe('app routes', () => { path?: string; redirectTo?: unknown; }> = []; - let resolveElectronOnlyGlobalSearchRoute: ( - runtime: { isElectron: boolean }, - router: { parseUrl: (url: string) => unknown } - ) => unknown; - let resolveRecordingsCapabilityRoute: ( - runtime: { supportsRecordings: boolean }, - router: { parseUrl: (url: string) => unknown } - ) => unknown; + let resolveElectronOnlyGlobalSearchRoute: AppRoutesModule['resolveElectronOnlyGlobalSearchRoute']; + let resolveRecordingsCapabilityRoute: AppRoutesModule['resolveRecordingsCapabilityRoute']; beforeAll(async () => { jest.unstable_mockModule( diff --git a/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts b/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts index 5aac52ff3..7d2cd145b 100644 --- a/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts +++ b/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts @@ -125,7 +125,7 @@ describe('EmbeddedMpvPlayerComponent recording status message', () => { afterEach(() => { jest.useRealTimers(); fixture.destroy(); - delete window.electron; + delete (window as { electron?: typeof window.electron }).electron; }); it('clears the saved recording path after a short delay', async () => { diff --git a/apps/web/src/app/services/app-update-install.service.spec.ts b/apps/web/src/app/services/app-update-install.service.spec.ts index 658efc39c..842f2cf7d 100644 --- a/apps/web/src/app/services/app-update-install.service.spec.ts +++ b/apps/web/src/app/services/app-update-install.service.spec.ts @@ -67,7 +67,7 @@ describe('AppUpdateInstallService', () => { it('returns null and touches no guard without the desktop bridge', async () => { const service = createService(); service.registerUnloadGuard(guard); - window.electron = undefined; + (window as { electron?: typeof window.electron }).electron = undefined; expect(await service.installAppUpdate()).toBeNull(); expect(guard.suspendForAppQuit).not.toHaveBeenCalled(); diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts index db13c5d85..8549df0fe 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts @@ -27,14 +27,18 @@ describe('ParentalLockEnforcementService', () => { version: signal(0), active: signal(false), registerBusyProbe: jest.fn(), - isXtreamCategoryLocked: jest.fn(() => false), + isXtreamCategoryLocked: jest.fn( + (_playlistId: string, _type: string, _xtreamId: number) => false + ), isStalkerCategoryLocked: jest.fn( (_playlistId: string, _type: string, id: unknown) => id !== null && id !== undefined && lockedStalkerIds.has(String(id)) ), - isM3uGroupLocked: jest.fn(() => false), + isM3uGroupLocked: jest.fn( + (_playlistId: string, _groupTitle: string) => false + ), }; const stalkerStore = { currentPlaylist: signal<{ _id: string } | null>({ _id: 'stalker-1' }), @@ -51,7 +55,9 @@ describe('ParentalLockEnforcementService', () => { playlistId: signal('xtream-1'), selectedCategoryId: signal(null), selectedItem: signal<{ category_id?: number } | null>(null), - reloadCategories: jest.fn(async () => undefined), + reloadCategories: jest.fn( + async (_shouldPublish?: () => boolean): Promise => undefined + ), reloadCachedContent: jest.fn(async () => undefined), refreshSearchResults: jest.fn(async () => undefined), withholdCatalog: jest.fn(), diff --git a/apps/web/src/app/services/playback-keep-awake.service.spec.ts b/apps/web/src/app/services/playback-keep-awake.service.spec.ts index 7c0f1cd51..5a62246cd 100644 --- a/apps/web/src/app/services/playback-keep-awake.service.spec.ts +++ b/apps/web/src/app/services/playback-keep-awake.service.spec.ts @@ -1,7 +1,7 @@ import { PlaybackKeepAwakeService } from './playback-keep-awake.service'; -type ElectronWindow = Window & { - electron?: { setPlaybackKeepAwake?: jest.Mock }; +type ElectronWindow = { + electron?: Pick; }; const flush = () => Promise.resolve().then(() => Promise.resolve()); @@ -215,9 +215,7 @@ describe('PlaybackKeepAwakeService', () => { }); it('releases a lock that resolves after playback already stopped', async () => { - let resolveRequest: - | ((value: typeof sentinel) => void) - | undefined; + let resolveRequest: ((value: typeof sentinel) => void) | undefined; request.mockImplementationOnce( () => new Promise((resolve) => { diff --git a/apps/web/src/app/services/settings.service.spec.ts b/apps/web/src/app/services/settings.service.spec.ts index cf7462593..90fa2793e 100644 --- a/apps/web/src/app/services/settings.service.spec.ts +++ b/apps/web/src/app/services/settings.service.spec.ts @@ -78,7 +78,8 @@ describe('Service: Settings', () => { )); describe('Test theme switch', () => { - let spyOnAdd, spyOnRemove; + let spyOnAdd: jest.SpyInstance; + let spyOnRemove: jest.SpyInstance; beforeEach(() => { spyOnAdd = jest.spyOn(document.body.classList, 'add'); spyOnRemove = jest.spyOn(document.body.classList, 'remove'); diff --git a/apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts b/apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts index 91b89bd86..a59c0ce4f 100644 --- a/apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts +++ b/apps/web/src/app/services/stalker-playlist-connection-editor.service.spec.ts @@ -48,6 +48,7 @@ describe('AppStalkerPlaylistConnectionEditorService', () => { title: 'Stalker Portal', count: 0, importDate: '2026-08-08T00:00:00.000Z', + autoRefresh: false, portalUrl: 'https://portal.example.com/c', macAddress: '00:1A:79:AA:BB:CC', username: 'subscriber', diff --git a/apps/web/src/app/settings/settings-about-section.component.spec.ts b/apps/web/src/app/settings/settings-about-section.component.spec.ts index ca8dd90a3..284517821 100644 --- a/apps/web/src/app/settings/settings-about-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-about-section.component.spec.ts @@ -53,6 +53,8 @@ describe('SettingsAboutSectionComponent app updates', () => { 'https://github.com/4gray/iptvnator/releases/latest', status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available, supportedSelfUpdate: true, + channel: 'stable', + installedChannel: 'stable', }); getButton(fixture, 'app-update-download')?.click(); @@ -76,6 +78,8 @@ describe('SettingsAboutSectionComponent app updates', () => { 'https://github.com/4gray/iptvnator/releases/latest', status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded, supportedSelfUpdate: true, + channel: 'stable', + installedChannel: 'stable', }); getButton(fixture, 'app-update-install')?.click(); @@ -96,6 +100,8 @@ describe('SettingsAboutSectionComponent app updates', () => { 'https://github.com/4gray/iptvnator/releases/latest', status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable, supportedSelfUpdate: true, + channel: 'stable', + installedChannel: 'stable', }); getButton(fixture, 'app-update-release-notes')?.click(); @@ -114,6 +120,8 @@ describe('SettingsAboutSectionComponent app updates', () => { 'https://github.com/4gray/iptvnator/releases/latest', status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported, supportedSelfUpdate: false, + channel: 'stable', + installedChannel: 'stable', }); getButton(fixture, 'app-update-open-release')?.click(); @@ -129,6 +137,8 @@ describe('SettingsAboutSectionComponent app updates', () => { 'https://github.com/4gray/iptvnator/releases/latest', status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported, supportedSelfUpdate: false, + channel: 'stable', + installedChannel: 'stable', }); expect(fixture.componentInstance.appUpdateStatusLabelKey()).toBe( diff --git a/apps/web/src/app/settings/settings-epg.facade.spec.ts b/apps/web/src/app/settings/settings-epg.facade.spec.ts index dfdab30c1..1f89000ea 100644 --- a/apps/web/src/app/settings/settings-epg.facade.spec.ts +++ b/apps/web/src/app/settings/settings-epg.facade.spec.ts @@ -21,10 +21,15 @@ import { MockSettingsStore, } from './test-stubs/settings-test-harness.stub'; +/** Writable double for the bridge's read-only capability getters. */ +type EpgBridgeStub = { + -readonly [K in keyof EpgRuntimeBridgeService]?: EpgRuntimeBridgeService[K]; +}; + describe('SettingsEpgFacade', () => { let facade: SettingsEpgFacade; let formFacade: SettingsFormFacade; - let epgBridge: Partial; + let epgBridge: EpgBridgeStub; let epgService: EpgService; let dialogService: DialogService; let snackBar: MatSnackBarStub; diff --git a/apps/web/src/app/settings/settings-playback-section.component.spec.ts b/apps/web/src/app/settings/settings-playback-section.component.spec.ts index d710fc707..8ff74e9ba 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.spec.ts @@ -173,7 +173,9 @@ describe('SettingsPlaybackSectionComponent', () => { fixture.componentRef.setInput('form', form); fixture.detectChanges(); - const checkbox = fixture.nativeElement.querySelector( + const checkbox = ( + fixture.nativeElement as HTMLElement + ).querySelector( '[data-test-id="web-player-shared-controls-toggle"] input[type="checkbox"]' ); @@ -183,7 +185,9 @@ describe('SettingsPlaybackSectionComponent', () => { it('labels the rendered native shared web controls checkbox', () => { fixture.detectChanges(); - const checkbox = fixture.nativeElement.querySelector( + const checkbox = ( + fixture.nativeElement as HTMLElement + ).querySelector( '[data-test-id="web-player-shared-controls-toggle"] input[type="checkbox"]' ); @@ -348,9 +352,10 @@ describe('SettingsPlaybackSectionComponent', () => { 'SETTINGS.MPV_PLAYER_ARGUMENTS_LABEL' ); expect( - fixture.nativeElement.querySelector( - '#mpvPlayerArguments' - )?.placeholder + ( + fixture.nativeElement as HTMLElement + ).querySelector('#mpvPlayerArguments') + ?.placeholder ).toBe(MPV_ARGUMENTS_PLACEHOLDER); }); @@ -369,9 +374,10 @@ describe('SettingsPlaybackSectionComponent', () => { 'SETTINGS.VLC_PLAYER_ARGUMENTS_LABEL' ); expect( - fixture.nativeElement.querySelector( - '#vlcPlayerArguments' - )?.placeholder + ( + fixture.nativeElement as HTMLElement + ).querySelector('#vlcPlayerArguments') + ?.placeholder ).toBe(VLC_ARGUMENTS_PLACEHOLDER); }); diff --git a/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts b/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts index 874873e41..6d52202d4 100644 --- a/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts +++ b/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts @@ -85,9 +85,9 @@ describe('SettingsPlaylistResetFacade', () => { snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub; translate = TestBed.inject(TranslateService); jest.spyOn(translate, 'instant').mockImplementation( - (key: string, params?: Record) => + (key: string | string[], params?: Record) => key === 'SETTINGS.REMOVE_ALL_PROGRESS' - ? `${params?.current}/${params?.total}` + ? `${params?.['current']}/${params?.['total']}` : key ); }); diff --git a/apps/web/src/app/settings/settings-unload-guard.service.spec.ts b/apps/web/src/app/settings/settings-unload-guard.service.spec.ts index fe6185755..acf8172d8 100644 --- a/apps/web/src/app/settings/settings-unload-guard.service.spec.ts +++ b/apps/web/src/app/settings/settings-unload-guard.service.spec.ts @@ -25,6 +25,7 @@ describe('SettingsUnloadGuardService', () => { setWindowCloseGuard: jest.Mock; confirmWindowClose: jest.Mock; cancelWindowClose: jest.Mock; + installAppUpdate: jest.Mock; onWindowCloseRequested: jest.Mock; }; @@ -75,7 +76,7 @@ describe('SettingsUnloadGuardService', () => { } function activateInPwa(): void { - window.electron = undefined; + (window as { electron?: typeof window.electron }).electron = undefined; service.activate(host); } @@ -126,9 +127,7 @@ describe('SettingsUnloadGuardService', () => { // the IPC is asynchronous. A pristine close auto-confirms. activateInElectron(); - expect(electronStub.setWindowCloseGuard).toHaveBeenCalledWith( - true - ); + expect(electronStub.setWindowCloseGuard).toHaveBeenCalledWith(true); }); it('disarms the guard on destroy and unsubscribes the push', () => { @@ -235,7 +234,7 @@ describe('SettingsUnloadGuardService', () => { }); it('escalates an open reload confirmation into the requested close', async () => { - let resolveConfirm: ((value: boolean) => void) | null = null; + let resolveConfirm: ((value: boolean) => void) | undefined; host.confirmClose.mockImplementation( () => new Promise((resolve) => { @@ -263,7 +262,7 @@ describe('SettingsUnloadGuardService', () => { }); it('cancels the escalated close when the user stays', async () => { - let resolveConfirm: ((value: boolean) => void) | null = null; + let resolveConfirm: ((value: boolean) => void) | undefined; host.confirmClose.mockImplementation( () => new Promise((resolve) => { @@ -289,7 +288,7 @@ describe('SettingsUnloadGuardService', () => { // that acknowledgment is in flight must be re-asked afterwards // — never answered against the stale intent the cancel clears, // and never silently swallowed. - let resolveCancel: (() => void) | null = null; + let resolveCancel: (() => void) | undefined; electronStub.cancelWindowClose.mockImplementation( () => new Promise((resolve) => { @@ -322,7 +321,7 @@ describe('SettingsUnloadGuardService', () => { }); it('ignores repeated close requests while a dialog is open', async () => { - let resolveConfirm: ((value: boolean) => void) | null = null; + let resolveConfirm: ((value: boolean) => void) | undefined; host.confirmClose.mockImplementation( () => new Promise((resolve) => { diff --git a/apps/web/src/app/settings/settings.component.form.spec.ts b/apps/web/src/app/settings/settings.component.form.spec.ts index 4049d3272..5cd4b2d3d 100644 --- a/apps/web/src/app/settings/settings.component.form.spec.ts +++ b/apps/web/src/app/settings/settings.component.form.spec.ts @@ -106,7 +106,7 @@ describe('SettingsComponent form', () => { it('hides the portal pause setting when the desktop bridge is unavailable', () => { fixture.destroy(); - window.electron = undefined; + (window as { electron?: typeof window.electron }).electron = undefined; fixture = TestBed.createComponent(SettingsComponent); fixture.detectChanges(); expect( @@ -467,7 +467,8 @@ describe('SettingsComponent form', () => { it('preserves saved EPG settings when saving from the web settings form', async () => { fixture.destroy(); - window.electron = undefined as unknown as typeof window.electron; + (window as { electron?: typeof window.electron }).electron = + undefined; settingsStore._setSettings({ epgUrl: ['https://example.com/guide.xml'], diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 837d4646e..e03d4a8d7 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -7,6 +7,7 @@ import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { EmbeddedMpvSupport, PlaylistMeta, + Theme, VideoPlayer, } from '@iptvnator/shared/interfaces'; import { MockStore } from '@ngrx/store/testing'; @@ -29,12 +30,17 @@ import { * `settings.component.form.spec.ts`, and the per-section behaviour in the * matching `*.facade.spec.ts` files. */ +/** Writable double for the bridge's read-only capability getters. */ +type EpgBridgeStub = { + -readonly [K in keyof EpgRuntimeBridgeService]?: EpgRuntimeBridgeService[K]; +}; + describe('SettingsComponent', () => { let component: SettingsComponent; let fixture: ComponentFixture; let router: Router; let mockStore: MockStore; - let epgBridge: Partial; + let epgBridge: EpgBridgeStub; const originalElectron = window.electron; beforeEach(waitForAsync(() => { @@ -192,7 +198,7 @@ describe('SettingsComponent', () => { }); it('discard-and-leave reverts the staged edits', async () => { - component.settingsForm.get('theme')?.setValue('DARK_THEME'); + component.settingsForm.get('theme')?.setValue(Theme.DarkTheme); component.settingsForm.markAsDirty(); answerDialogWith('discard'); diff --git a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts index 9d1af7ba8..19a12e6a9 100644 --- a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts +++ b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts @@ -109,7 +109,7 @@ export const DEFAULT_SETTINGS = { vlcReuseInstance: false, remoteControl: false, remoteControlPort: 8765, - epgUrl: [], + epgUrl: [] as string[], recordingFolder: '', embeddedMpvFrameCopy: false, embeddedMpvExtraOptions: '', diff --git a/apps/web/src/jest-esm.d.ts b/apps/web/src/jest-esm.d.ts new file mode 100644 index 000000000..74b31bd18 --- /dev/null +++ b/apps/web/src/jest-esm.d.ts @@ -0,0 +1,12 @@ +/** + * Jest's ESM module-mocking API is not part of `@types/jest`, but the ESM + * Jest workspace (`jest.web-esm.workspace.ts`) relies on it. The signature + * mirrors `Jest['unstable_mockModule']` from `@jest/environment`. + */ +declare namespace jest { + function unstable_mockModule( + moduleName: string, + moduleFactory: () => T | Promise, + options?: { virtual?: boolean } + ): typeof jest; +} diff --git a/apps/web/tsconfig.spec.json b/apps/web/tsconfig.spec.json index 35ee5ed11..531b3ebc7 100644 --- a/apps/web/tsconfig.spec.json +++ b/apps/web/tsconfig.spec.json @@ -7,7 +7,8 @@ "target": "ESNext", "esModuleInterop": true, "types": ["jest", "node"], - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "lib": ["es2022", "dom"] }, "files": ["src/test-setup.ts", "../../global.d.ts"], "include": [ @@ -23,6 +24,18 @@ "../../libs/ui/components/src/**/*.d.ts", "../../libs/ui/pipes/src/**/*.test.ts", "../../libs/ui/pipes/src/**/*.spec.ts", - "../../libs/ui/pipes/src/**/*.d.ts" + "../../libs/ui/pipes/src/**/*.d.ts", + "../../libs/ui/epg/src/**/*.test.ts", + "../../libs/ui/epg/src/**/*.spec.ts", + "../../libs/ui/epg/src/**/*.d.ts", + "../../libs/ui/playback/src/**/*.test.ts", + "../../libs/ui/playback/src/**/*.spec.ts", + "../../libs/ui/playback/src/**/*.d.ts", + "../../libs/playback/util/src/**/*.test.ts", + "../../libs/playback/util/src/**/*.spec.ts", + "../../libs/playback/util/src/**/*.d.ts", + "../../libs/playlist/m3u/feature-player/src/**/*.test.ts", + "../../libs/playlist/m3u/feature-player/src/**/*.spec.ts", + "../../libs/playlist/m3u/feature-player/src/**/*.d.ts" ] } diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 897561211..0ff903425 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -27,12 +27,36 @@ using its result as evidence. Docker validation can use | ---------------------------------- | ----------------------------------- | | Angular renderer entry points | `pnpm run typecheck:web` | | Electron main process entry points | `pnpm run typecheck:backend` | +| Jest spec programs | `pnpm run typecheck:spec` | | Full unit suite (all projects) | `pnpm run test:unit:ci` | | EPG data access | `pnpm nx test epg-data-access` | | Workspace shell utilities | `pnpm nx test workspace-shell-util` | | Shared SQLite schema/connection | `pnpm nx test database` | | Packaging metadata | `pnpm nx test packaging` | +`typecheck:spec` (`tools/typecheck/spec-typecheck.mjs`) runs `tsc --noEmit` +over every `tsconfig.spec.json` under `apps/`, `libs/` and `tools/`, a few +programs at a time (`--concurrency=N` or `SPEC_TYPECHECK_CONCURRENCY`; a +positional argument filters by path), and fails on any diagnostic. ts-jest +transpiles with `isolatedModules`, so this is the only check that catches a +spec whose types drifted from the code it exercises. CI runs it in the +`unit-and-typecheck` job after `typecheck:ci`. Conventions the gate relies on: + +- Spec tsconfigs use `module: preserve` with `moduleResolution: bundler`, the + same as the library's own `tsconfig.json`; ts-jest forces CommonJS emit + outside ESM mode, so the setting only affects type-checking, and `node10` + resolution cannot see Angular's `exports`-only secondary entry points. +- Each spec program lists `global.d.ts` in `files` so `window.electron` and the + other ambient declarations resolve. +- Libraries tested through `tools/testing/run-web-esm-lib-tests.mjs` are + type-checked by `apps/web/tsconfig.spec.json`, the config + `jest.web-esm.workspace.ts` hands to ts-jest; add a new ESM-tested library's + spec globs there. `apps/web/src/jest-esm.d.ts` types `jest.unstable_mockModule`. +- Type test doubles instead of casting to `any`: `jest.Mocked`, + `InstanceType` for signal stores, and + `Object.defineProperty` or a writable mapped type for read-only capability + flags. + ## Lint ```bash @@ -78,10 +102,10 @@ count per project (defaults: `min(3, cores - 1)` in flight and project's output is printed as one block when it finishes, and the run ends with the wall-clock total and the longest projects. Spec `tsconfig`s set `isolatedModules: true`, so ts-jest transpiles files one at a time instead of -type-checking each through a language service; spec type errors therefore do -not fail Jest (the web configs already ran with `diagnostics: false`), while -`isolatedModules`-incompatible syntax such as a type re-export without -`export type` still fails at load time. +type-checking each through a language service (the web configs already ran +with `diagnostics: false`); `isolatedModules`-incompatible syntax such as a +type re-export without `export type` still fails at load time, and spec type +errors are caught by `typecheck:spec` (see Unit And Type Checks). In CI, a pull request skips the Tier A suite (and the merged-coverage upload) when every changed file is outside Tier A test inputs: diff --git a/docs/maintenance/agent-context-map.md b/docs/maintenance/agent-context-map.md index b764463d1..fe599c138 100644 --- a/docs/maintenance/agent-context-map.md +++ b/docs/maintenance/agent-context-map.md @@ -13,7 +13,7 @@ are not prerequisites for reading repository contracts. | --- | --- | --- | | Bootstrap, project placement, dependencies, aliases and lint configuration; root Nx config and project-local project.json files | [Nx boundaries](../architecture/nx-workspace-boundaries.md), [security overrides](../architecture/dependency-security-overrides.md) | [Nx architecture](../../.codex/skills/iptvnator-nx-architecture/SKILL.md) | | Angular conventions; docs and skills maintenance | [Agent workflow](../development/agent-workflow.md) | Use the area's skill below | -| Unit, E2E, lint and coverage; `tools/coverage` | [Validation map](../architecture/validation-map.md) | Use the area's validation section | +| Unit, E2E, lint and coverage; `tools/coverage`, `tools/typecheck` | [Validation map](../architecture/validation-map.md) | Use the area's validation section | | Performance journeys, counters, benchmark probes and the CI ratchet; `apps/electron-backend-e2e/src/journeys`, `apps/electron-backend-e2e/src/performance`, `tools/performance` | [Performance journeys](../architecture/performance-journeys.md) | Read the contract directly | | Electron entry/events/preload and CDP; `apps/electron-backend` | [Debugging and trace flags](../development/electron-debugging.md), [Electron security](../architecture/electron-security.md) | Use the available global electron skill for automation | | Releases, notes, screenshots, native assets, Linux manager metadata; `tools/release` | [Release pipeline](../architecture/release-pipeline.md), [note format](../../.changes/README.md) | [Release notes](../../.codex/skills/release-notes/SKILL.md), [release cut](../../.codex/skills/release-cut/SKILL.md) | diff --git a/libs/epg/data-access/src/lib/epg-progress.service.spec.ts b/libs/epg/data-access/src/lib/epg-progress.service.spec.ts index e48f1a98a..4d51d6d74 100644 --- a/libs/epg/data-access/src/lib/epg-progress.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg-progress.service.spec.ts @@ -7,8 +7,11 @@ import { SettingsStore, EpgSourceSettingsService } from '@iptvnator/services'; import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces'; import { EpgProgressService } from './epg-progress.service'; +/** Spec-only double: capability flags are read-only getters on the real class. */ +type WritablePartial = { -readonly [K in keyof T]?: T[K] }; + describe('EpgProgressService', () => { - let epgBridge: Partial; + let epgBridge: WritablePartial; let sources: { waitForReconciliation: jest.Mock }; let settingsStore: { getSettings: jest.Mock; diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts index 5bee7414c..5934e7c65 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.spec.ts @@ -2,9 +2,12 @@ import { TestBed } from '@angular/core/testing'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service'; +/** Spec-only double: capability flags are read-only getters on the real class. */ +type WritablePartial = { -readonly [K in keyof T]?: T[K] }; + describe('EpgRuntimeBridgeService', () => { let service: EpgRuntimeBridgeService; - let runtimeCapabilities: Partial; + let runtimeCapabilities: WritablePartial; const originalElectron = window.electron; beforeEach(() => { diff --git a/libs/epg/data-access/src/lib/epg.service.spec.ts b/libs/epg/data-access/src/lib/epg.service.spec.ts index 4a0fbcb12..00817916f 100644 --- a/libs/epg/data-access/src/lib/epg.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg.service.spec.ts @@ -7,12 +7,16 @@ import { PlaylistsService, SettingsStore, } from '@iptvnator/services'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service'; import { EpgService } from './epg.service'; +/** Spec-only double: capability flags are read-only getters on the real class. */ +type WritablePartial = { -readonly [K in keyof T]?: T[K] }; + describe('EpgService', () => { let service: EpgService; - let epgBridge: Partial; + let epgBridge: WritablePartial; let snackBar: { open: jest.Mock }; let settingsStore: { loadSettings: jest.Mock; @@ -164,7 +168,7 @@ describe('EpgService', () => { new Promise((resolve) => { complete = resolve; }), - } as typeof window.electron; + } as Partial as typeof window.electron; try { const sources = TestBed.inject(EpgSourceSettingsService); const reconciliation = sources.synchronize([ @@ -457,16 +461,7 @@ describe('EpgService', () => { it('deduplicates concurrent scoped current program lookups for the same source scope', async () => { epgBridge.supportsProgramLookup = true; - let resolvePrograms: - | (( - programs: { - channel: string; - start: string; - stop: string; - title: string; - }[] - ) => void) - | undefined; + let resolvePrograms: ((programs: EpgProgram[]) => void) | undefined; epgBridge.getChannelPrograms = jest.fn( () => new Promise((resolve) => { @@ -495,6 +490,8 @@ describe('EpgService', () => { start: '2026-05-23T10:00:00.000Z', stop: '2026-05-23T11:00:00.000Z', title: 'Playlist Guide Bulletin', + desc: null, + category: null, }, ]); @@ -928,7 +925,7 @@ describe('EpgService', () => { epgBridge.supportsProgramLookup = true; epgBridge.supportsCurrentProgramBatch = true; const batchResolvers: Array< - (programs: Record) => void + (programs: Record) => void > = []; epgBridge.getCurrentProgramsBatch = jest.fn( () => @@ -957,6 +954,8 @@ describe('EpgService', () => { start: '2026-05-23T10:00:00.000Z', stop: '2026-05-23T11:00:00.000Z', title: 'Playlist Guide Bulletin', + desc: null, + category: null, }, }) ); @@ -981,7 +980,7 @@ describe('EpgService', () => { epgBridge.supportsProgramLookup = true; epgBridge.supportsCurrentProgramBatch = true; let resolveBatch: - ((programs: Record) => void) | undefined; + ((programs: Record) => void) | undefined; epgBridge.getCurrentProgramsBatch = jest.fn( () => new Promise((resolve) => { @@ -1010,12 +1009,16 @@ describe('EpgService', () => { start: '2026-05-23T10:00:00.000Z', stop: '2026-05-23T11:00:00.000Z', title: 'Playlist Guide Bulletin', + desc: null, + category: null, }, 'guide-sports': { channel: 'guide-sports', start: '2026-05-23T10:00:00.000Z', stop: '2026-05-23T11:00:00.000Z', title: 'Playlist Sports Bulletin', + desc: null, + category: null, }, }); diff --git a/libs/epg/data-access/tsconfig.spec.json b/libs/epg/data-access/tsconfig.spec.json index 7f31bb031..71dd0db03 100644 --- a/libs/epg/data-access/tsconfig.spec.json +++ b/libs/epg/data-access/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts index 03cc0610a..b71da2412 100644 --- a/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts @@ -127,7 +127,7 @@ describe('playlistReducers', () => { stalkerTimeslot: 3, stalkerAccountInfo: { login: 'subscriber', - status: 'active', + status: 1, }, }, }, @@ -153,7 +153,7 @@ describe('playlistReducers', () => { stalkerTimeslot: 3, stalkerAccountInfo: { login: 'subscriber', - status: 'active', + status: 1, }, }) ); diff --git a/libs/playback/util/src/lib/diagnostics/hls-playback-evidence.util.spec.ts b/libs/playback/util/src/lib/diagnostics/hls-playback-evidence.util.spec.ts index 4ac813d5b..4f5f16ccf 100644 --- a/libs/playback/util/src/lib/diagnostics/hls-playback-evidence.util.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/hls-playback-evidence.util.spec.ts @@ -158,7 +158,7 @@ describe('HLS playback evidence', () => { networkDetails: { responseURL: `https://provider.example/xhr?token=${secret}`, responseText: secret, - }, + } satisfies Partial as XMLHttpRequest, }) ); const serialized = JSON.stringify(evidence); diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts index c47623f0a..f163db8f1 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts @@ -12,6 +12,14 @@ import { import { PlaylistType } from '@iptvnator/playlist/shared/ui'; import { AddPlaylistDialogComponent } from './add-playlist-dialog.component'; +type ChildSignalKey = + | 'urlUpload' + | 'fileUpload' + | 'textImport' + | 'xtreamImport' + | 'stalkerImport' + | 'autoImport'; + describe('AddPlaylistDialogComponent', () => { let component: AddPlaylistDialogComponent; let dataService: { sendIpcEvent: jest.Mock }; @@ -67,8 +75,20 @@ describe('AddPlaylistDialogComponent', () => { ); }); + /** + * Replaces a read-only `viewChild` signal with a stub that resolves to a + * minimal structural double of the child component, or to `undefined` + * while the child is not rendered yet. + */ + function stubChild(key: ChildSignalKey, child: object | undefined): void { + Object.defineProperty(component, key, { + value: jest.fn(() => child), + configurable: true, + }); + } + it('sends a trimmed custom title for URL playlists', () => { - (component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({ + stubChild('urlUpload', { form: { getRawValue: () => ({ playlistName: ' My Playlist ', @@ -76,7 +96,7 @@ describe('AddPlaylistDialogComponent', () => { playlistUrl: ' https://example.com/list.m3u ', }), }, - })); + }); component.submitUrlPlaylist(); @@ -92,7 +112,7 @@ describe('AddPlaylistDialogComponent', () => { }); it('omits the title when the optional name is blank', () => { - (component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({ + stubChild('urlUpload', { form: { getRawValue: () => ({ playlistName: ' ', @@ -100,7 +120,7 @@ describe('AddPlaylistDialogComponent', () => { playlistUrl: 'https://example.com/list.m3u', }), }, - })); + }); component.submitUrlPlaylist(); @@ -167,10 +187,7 @@ describe('AddPlaylistDialogComponent', () => { 'clears the current $type import surface', ({ type, childAccessor, clearMethod }) => { const clear = jest.fn(); - (component as unknown as Record)[childAccessor] = - jest.fn(() => ({ - [clearMethod]: clear, - })); + stubChild(childAccessor, { [clearMethod]: clear }); selectType(type); component.clearCurrentForm(); @@ -180,10 +197,10 @@ describe('AddPlaylistDialogComponent', () => { ); it('disables clear when a file upload has no selection', () => { - (component as { fileUpload: jest.Mock }).fileUpload = jest.fn(() => ({ + stubChild('fileUpload', { isImporting: () => false, selectedFile: () => null, - })); + }); selectType('file'); expect(component.isClearDisabled()).toBeTruthy(); @@ -260,9 +277,7 @@ describe('AddPlaylistDialogComponent', () => { it('prefills the xtream form once the child exists and clears the pending candidate', () => { const patchValue = jest.fn(); - (component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn( - () => ({ form: { patchValue } }) - ); + stubChild('xtreamImport', { form: { patchValue } }); component.onCandidateSelected({ kind: 'xtream', @@ -288,9 +303,7 @@ describe('AddPlaylistDialogComponent', () => { it('drops the candidate when the user switches to another method first', () => { const patchValue = jest.fn(); - (component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn( - () => ({ form: { patchValue } }) - ); + stubChild('xtreamImport', { form: { patchValue } }); component.onCandidateSelected({ kind: 'xtream', @@ -309,9 +322,7 @@ describe('AddPlaylistDialogComponent', () => { }); it('keeps the candidate pending while the target form does not exist yet', () => { - (component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn( - () => undefined - ); + stubChild('xtreamImport', undefined); const patchValue = jest.fn(); component.onCandidateSelected({ @@ -322,9 +333,7 @@ describe('AddPlaylistDialogComponent', () => { applyPrefill(); // Child appears on a later change-detection pass. - (component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn( - () => ({ form: { patchValue } }) - ); + stubChild('xtreamImport', { form: { patchValue } }); applyPrefill(); expect(patchValue).toHaveBeenCalledWith( @@ -334,8 +343,7 @@ describe('AddPlaylistDialogComponent', () => { it('prefills the stalker form including identity fields', () => { const patchValue = jest.fn(); - (component as { stalkerImport: jest.Mock }).stalkerImport = - jest.fn(() => ({ form: { patchValue } })); + stubChild('stalkerImport', { form: { patchValue } }); component.onCandidateSelected({ kind: 'stalker', @@ -369,9 +377,9 @@ describe('AddPlaylistDialogComponent', () => { it('prefills the URL form for an m3u-url candidate', () => { const patchValue = jest.fn(); - (component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({ + stubChild('urlUpload', { form: { patchValue }, - })); + }); component.onCandidateSelected({ kind: 'm3u-url', @@ -389,9 +397,7 @@ describe('AddPlaylistDialogComponent', () => { it('prefills the raw-text form for an m3u-text candidate', () => { const patchValue = jest.fn(); - (component as { textImport: jest.Mock }).textImport = jest.fn( - () => ({ textForm: { patchValue } }) - ); + stubChild('textImport', { textForm: { patchValue } }); component.onCandidateSelected({ kind: 'm3u-text', diff --git a/libs/playlist/import/feature/tsconfig.spec.json b/libs/playlist/import/feature/tsconfig.spec.json index 6aa6499b9..e5fb73520 100644 --- a/libs/playlist/import/feature/tsconfig.spec.json +++ b/libs/playlist/import/feature/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts index 2f5fd29de..1617a1c44 100644 --- a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts @@ -11,7 +11,10 @@ import { M3uEpgGuideSourceService } from './m3u-epg-guide-source.service'; function makeChannel( id: string, - overrides: Partial & { tvgId?: string; group?: string } = {} + overrides: Omit, 'group'> & { + tvgId?: string; + group?: string; + } = {} ): Channel { return { id, diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 1eb41988e..fea6d3e02 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -162,13 +162,16 @@ describe('VideoPlayerComponent', () => { // Mirrors the real capability check: every // remote-control bridge method must be present. get supportsRemoteControl() { - const bridge = window.electron as - Record | undefined; - return [ - 'updateRemoteControlStatus', - 'onChannelChange', - 'onRemoteControlCommand', - ].every( + const bridge: + Partial | undefined = + window.electron; + return ( + [ + 'updateRemoteControlStatus', + 'onChannelChange', + 'onRemoteControlCommand', + ] as const + ).every( (method) => typeof bridge?.[method] === 'function' ); @@ -582,7 +585,7 @@ describe('VideoPlayerComponent', () => { const updateRemoteControlStatus = jest.fn(); window.electron = { updateRemoteControlStatus, - } as typeof window.electron; + } as unknown as typeof window.electron; fixture = TestBed.createComponent(VideoPlayerComponent); component = fixture.componentInstance; @@ -687,6 +690,7 @@ describe('VideoPlayerComponent', () => { streamUrl: sampleChannel.url, startedAt: '2026-08-08T00:00:00.000Z', updatedAt: '2026-08-08T00:00:00.000Z', + canClose: true, }); fixture.detectChanges(); diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index 686e85b13..d74708295 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -451,7 +451,11 @@ describe('PlaylistRefreshActionService', () => { const originalSetItem = Storage.prototype.setItem; const setItemSpy = jest .spyOn(Storage.prototype, 'setItem') - .mockImplementation(function (key: string, value: string) { + .mockImplementation(function ( + this: Storage, + key: string, + value: string + ) { executionOrder.push('setItem'); return originalSetItem.call(this, key, value); }); diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts index 93407f1d5..8ccf73df2 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts @@ -29,7 +29,6 @@ function createPlaylist( overrides: Partial & { _id: string } ): PlaylistMeta { return { - _id: overrides._id, title: overrides.title ?? overrides.filename ?? overrides._id, filename: overrides.filename, count: overrides.count ?? 0, diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts index 9d8df0403..d713b589b 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts @@ -73,6 +73,7 @@ describe('PlaylistInfoComponent', () => { autoRefresh: false, url: 'https://example.com/playlist.m3u', } as Playlist & { id: string }; + const metaBase = { count: 1, importDate: '2026-04-01', autoRefresh: false }; beforeEach(async () => { playlistsService = { @@ -213,6 +214,7 @@ describe('PlaylistInfoComponent', () => { createComponent(); const updatedPlaylist = { + ...metaBase, _id: 'playlist-1', title: 'Updated Xtream', serverUrl: 'http://new.example:8080', @@ -251,6 +253,7 @@ describe('PlaylistInfoComponent', () => { createComponent(); await component.saveChanges({ + ...metaBase, _id: 'playlist-1', title: 'Updated Xtream', serverUrl: @@ -262,6 +265,7 @@ describe('PlaylistInfoComponent', () => { expect(store.dispatch).toHaveBeenCalledWith( PlaylistActions.updatePlaylistMeta({ playlist: { + ...metaBase, _id: 'playlist-1', title: 'Updated Xtream', serverUrl: 'http://new.example:8080/live', @@ -289,6 +293,7 @@ describe('PlaylistInfoComponent', () => { createComponent(); await component.saveChanges({ + ...metaBase, _id: 'playlist-1', title: 'Updated Xtream', serverUrl: @@ -314,7 +319,7 @@ describe('PlaylistInfoComponent', () => { window.electron = { saveFileDialog: jest.fn().mockResolvedValue('/tmp/export.m3u8'), writeFile: jest.fn().mockResolvedValue({ success: true }), - } as typeof window.electron; + } as Partial as typeof window.electron; createComponent(); await component.exportPlaylist(); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index 95c85af92..9db1e2121 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -491,7 +491,11 @@ describe('RecentPlaylistsComponent busy state', () => { const originalSetItem = Storage.prototype.setItem; const setItemSpy = jest .spyOn(Storage.prototype, 'setItem') - .mockImplementation(function (key: string, value: string) { + .mockImplementation(function ( + this: Storage, + key: string, + value: string + ) { executionOrder.push('setItem'); return originalSetItem.call(this, key, value); }); diff --git a/libs/playlist/shared/ui/tsconfig.spec.json b/libs/playlist/shared/ui/tsconfig.spec.json index 6aa6499b9..e5fb73520 100644 --- a/libs/playlist/shared/ui/tsconfig.spec.json +++ b/libs/playlist/shared/ui/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/playlist/shared/util/src/lib/playlist-context.facade.spec.ts b/libs/playlist/shared/util/src/lib/playlist-context.facade.spec.ts index 36529ca18..4d57846b1 100644 --- a/libs/playlist/shared/util/src/lib/playlist-context.facade.spec.ts +++ b/libs/playlist/shared/util/src/lib/playlist-context.facade.spec.ts @@ -22,11 +22,11 @@ function createPlaylist( overrides: Partial & { _id: string } ): PlaylistMeta { return { - _id: overrides._id, title: overrides.title ?? overrides.filename ?? overrides._id, count: overrides.count ?? 0, importDate: - overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(), + overrides.importDate ?? + new Date('2026-04-05T10:00:00.000Z').toISOString(), autoRefresh: overrides.autoRefresh ?? false, ...overrides, } as PlaylistMeta; @@ -392,24 +392,25 @@ describe('PlaylistContextFacade', () => { playlistId: xtreamA._id, section: 'recently-added', }); - expect(JSON.parse(localStorage.getItem(LAST_SECTION_STORAGE_KEY) ?? '{}')) - .toEqual({ - playlists: { - [m3uA._id]: { - provider: 'playlists', - section: 'favorites', - updatedAt: expect.any(Number), - }, - [xtreamA._id]: { - provider: 'xtreams', - section: 'recently-added', - updatedAt: expect.any(Number), - }, + expect( + JSON.parse(localStorage.getItem(LAST_SECTION_STORAGE_KEY) ?? '{}') + ).toEqual({ + playlists: { + [m3uA._id]: { + provider: 'playlists', + section: 'favorites', + updatedAt: expect.any(Number), }, - providers: { - playlists: 'favorites', - xtreams: 'recently-added', + [xtreamA._id]: { + provider: 'xtreams', + section: 'recently-added', + updatedAt: expect.any(Number), }, - }); + }, + providers: { + playlists: 'favorites', + xtreams: 'recently-added', + }, + }); }); }); diff --git a/libs/playlist/shared/util/tsconfig.spec.json b/libs/playlist/shared/util/tsconfig.spec.json index 6aa6499b9..e5fb73520 100644 --- a/libs/playlist/shared/util/tsconfig.spec.json +++ b/libs/playlist/shared/util/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/portal/catalog/feature/tsconfig.spec.json b/libs/portal/catalog/feature/tsconfig.spec.json index 6aa6499b9..e5fb73520 100644 --- a/libs/portal/catalog/feature/tsconfig.spec.json +++ b/libs/portal/catalog/feature/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts index a31da7dcf..36eb86154 100644 --- a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts @@ -381,10 +381,10 @@ describe('DownloadLibraryNavigationService', () => { ); }); - it.each([ + it.each]>([ ['is_series flag', { is_series: '1' }], ['embedded episode list', { series: [1, 2] }], - ] as const)( + ])( 'opens a recovered Stalker episode with an %s through the VOD-series detail route', async (_label, vodSeriesMetadata) => { playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts b/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts index ca74e1700..af966dec6 100644 --- a/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts @@ -510,9 +510,9 @@ describe('DownloadQueueComponent', () => { expect(row(3).querySelector('.download-queue__progress')).toBeNull(); expect( Array.from( - fixture.nativeElement.querySelectorAll( - '.download-queue__progress' - ) + ( + fixture.nativeElement as HTMLElement + ).querySelectorAll('.download-queue__progress') ).map((bar) => bar.getAttribute('aria-label')) ).toEqual([ 'Download progress for Download 1', diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts index 252d3eb83..506a2ae27 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts @@ -23,7 +23,7 @@ import { PORTAL_SHELL_ACTIONS, PortalCollectionContextService, } from '@iptvnator/portal/shared/util'; -import type { Playlist } from '@iptvnator/shared/interfaces'; +import type { Playlist, XtreamCategory } from '@iptvnator/shared/interfaces'; import { DialogService } from '@iptvnator/ui/components'; import { BehaviorSubject, type Observable, Subject } from 'rxjs'; import { DownloadLibraryNavigationService } from './download-library-navigation.service'; @@ -47,6 +47,8 @@ interface ConfirmConfig { } interface ExpectedDownloadsComponent { + readonly activeCount: () => number; + readonly categories: () => readonly XtreamCategory[]; readonly model: () => { readonly active: readonly { readonly item: DownloadItem }[]; readonly activeCount: number; @@ -780,7 +782,9 @@ describe('DownloadsComponent', () => { downloads.set([item]); fixture.detectChanges(); - const attention = fixture.nativeElement.querySelector( + const attention = ( + fixture.nativeElement as HTMLElement + ).querySelector( '[data-test-id="downloads-attention-section"]' ); const recover = attention?.querySelector( diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts index 9c11afa06..48ed9fdbc 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts @@ -12,7 +12,10 @@ import { import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; import type { WorkspaceNavigationTarget } from '@iptvnator/portal/shared/util'; import { BehaviorSubject } from 'rxjs'; -import type { DownloadActionResult } from '../download-actions'; +import type { + DownloadActionResult, + DownloadItemAction, +} from '../download-actions'; import { DownloadLibraryNavigationService } from '../download-library-navigation.service'; import { DownloadManagerActionsService } from '../download-manager-actions.service'; import { DownloadOfflineDetailComponent } from './download-offline-detail.component'; @@ -67,7 +70,10 @@ interface DownloadsFake { interface ActionsFake { readonly pendingIds: ReturnType>>; - readonly run: jest.Mock, [unknown]>; + readonly run: jest.Mock< + Promise, + [DownloadItemAction] + >; readonly showActionError: jest.Mock; } diff --git a/libs/portal/downloads/feature/tsconfig.spec.json b/libs/portal/downloads/feature/tsconfig.spec.json index 6aa6499b9..e5fb73520 100644 --- a/libs/portal/downloads/feature/tsconfig.spec.json +++ b/libs/portal/downloads/feature/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.epg-offset.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.epg-offset.spec.ts index d137ebcfc..056ddc7bd 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.epg-offset.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.epg-offset.spec.ts @@ -135,10 +135,15 @@ describe('StreamResolverService EPG display offset', () => { TestBed.inject(PlaylistsService).getPlaylistById = jest.fn(() => of({ _id: 'stalker-1', + title: 'Stalker Portal', + importDate: '2026-01-01T00:00:00.000Z', + lastUsage: '2026-01-01T00:00:00.000Z', + count: 0, + autoRefresh: false, portalUrl: 'https://stalker.example.com', macAddress: '00:11:22:33:44:55', isFullStalkerPortal: false, - } satisfies Partial) + } satisfies Playlist) ); const nowSeconds = Math.floor(Date.now() / 1000); const entry = (id: string, name: string, startOffsetMin: number) => ({ @@ -192,10 +197,15 @@ describe('StreamResolverService EPG display offset', () => { TestBed.inject(PlaylistsService).getPlaylistById = jest.fn(() => of({ _id: 'stalker-1', + title: 'Stalker Portal', + importDate: '2026-01-01T00:00:00.000Z', + lastUsage: '2026-01-01T00:00:00.000Z', + count: 0, + autoRefresh: false, portalUrl: 'https://stalker.example.com', macAddress: '00:11:22:33:44:55', isFullStalkerPortal: false, - } satisfies Partial) + } satisfies Playlist) ); const nowMs = Date.now(); const entry = (id: string, name: string, startOffsetMin: number) => ({ @@ -322,10 +332,15 @@ describe('StreamResolverService EPG display offset', () => { epgOffsetMinutes = 0; return of({ _id: 'xtream-1', + title: 'Xtream Playlist', + importDate: '2026-01-01T00:00:00.000Z', + lastUsage: '2026-01-01T00:00:00.000Z', + count: 0, + autoRefresh: false, serverUrl: 'https://xtream.example.com', username: 'user', password: 'pass', - } satisfies Partial); + } satisfies Playlist); }); xtreamApi.getFullEpg.mockResolvedValue([ listing('Really on air', -75, 60), diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index f975d7715..ca634b2c0 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -33,7 +33,11 @@ describe('StreamResolverService', () => { ensureToken: jest.Mock; makeAuthenticatedRequest: jest.Mock; }; - let epgBridge: Partial; + let epgBridge: { + -readonly [ + K in keyof EpgRuntimeBridgeService + ]?: EpgRuntimeBridgeService[K]; + }; beforeEach(() => { playlistsService = { diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 78f03e4c7..0fa311c04 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -710,7 +710,7 @@ export class StreamResolverService { // epg_channel_id column but is not declared on the TS interface. const epgKey = ( item as unknown as Record - ).epgChannelId?.trim(); + )['epgChannelId']?.trim(); if (this.supportsProgramLookup && epgKey) { const uploaded = await this.epgBridge .getChannelPrograms(epgKey) @@ -995,7 +995,7 @@ export class StreamResolverService { string, string | undefined | null > - ).epgChannelId?.trim(); + )['epgChannelId']?.trim(); if (this.supportsProgramLookup && epgChannelKey) { currentItem = await this.findCurrentInXmltv( epgChannelKey, diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts index da7afe700..8a5d2defe 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts @@ -126,7 +126,7 @@ describe('UnifiedFavoritesDataService', () => { dbRemoveRecentItemsBatch: jest.fn(), }; Object.defineProperty(window, 'electron', { - value: electronApi as Window['electron'], + value: electronApi as unknown as Window['electron'], configurable: true, }); @@ -177,11 +177,17 @@ describe('UnifiedFavoritesDataService', () => { { _id: 'm3u-1', title: 'M3U List', + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, favorites: ['https://example.com/2.m3u8', 'channel-1'], }, { _id: 'stalker-1', title: 'Stalker List', + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, macAddress: '00:11:22:33:44:55', favorites: stalkerFavorites, }, diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts index 98573a237..e8b87fae2 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts @@ -45,6 +45,9 @@ describe('UnifiedRecentDataService', () => { const playlistMeta = { _id: 'm3u-1', title: 'M3U List', + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, recentlyViewed: [ { source: 'm3u', @@ -225,7 +228,7 @@ describe('UnifiedRecentDataService', () => { expect(playlistsService.addM3uRecentlyViewed).toHaveBeenCalledWith( 'm3u-1', - expect.objectContaining({ + expect.objectContaining>({ source: 'm3u', url: 'https://example.com/1.m3u8', channel_id: 'channel-1', diff --git a/libs/portal/shared/data-access/src/lib/source-health.service.spec.ts b/libs/portal/shared/data-access/src/lib/source-health.service.spec.ts index 0354a1385..a4df78413 100644 --- a/libs/portal/shared/data-access/src/lib/source-health.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/source-health.service.spec.ts @@ -13,6 +13,10 @@ const active: SourceHealthResult = { }; const playlist = (id: string, host = id): PlaylistMeta => ({ _id: id, + title: id, + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, url: `https://${host}.test/list`, }); diff --git a/libs/portal/shared/data-access/tsconfig.spec.json b/libs/portal/shared/data-access/tsconfig.spec.json index 6aa6499b9..e5fb73520 100644 --- a/libs/portal/shared/data-access/tsconfig.spec.json +++ b/libs/portal/shared/data-access/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.ts b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.ts index 49b091ad4..9cfdec6b3 100644 --- a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.ts +++ b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.ts @@ -49,7 +49,8 @@ export class CategoryViewComponent { container.querySelectorAll('[data-category-id]') ) as HTMLElement[]; const selected = candidates.find( - (el) => el.dataset.categoryId === String(selectedCategory) + (el) => + el.dataset['categoryId'] === String(selectedCategory) ); if (!selected) { return; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index c868ee6dd..7892ed0f2 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -208,7 +208,7 @@ describe('UnifiedCollectionPageComponent', () => { params: {}, }, paramMap: workspaceParamMap$.asObservable(), - } as ActivatedRoute; + } as unknown as ActivatedRoute; route = { snapshot: { @@ -222,7 +222,7 @@ describe('UnifiedCollectionPageComponent', () => { paramMap: routeParamMap$.asObservable(), queryParamMap: routeQueryParamMap$.asObservable(), pathFromRoot: [], - } as ActivatedRoute & { + } as unknown as ActivatedRoute & { snapshot: { paramMap: ReturnType; queryParamMap: ReturnType; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index 1ef25911a..571559183 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -596,6 +596,7 @@ describe('UnifiedLiveTabComponent', () => { code: 'network-error', player: 'videojs', source: 'hls', + sourceUrl: 'https://example.com/xtream.m3u8', container: '', mimeType: '', videoCodecs: [], diff --git a/libs/portal/shared/ui/tsconfig.spec.json b/libs/portal/shared/ui/tsconfig.spec.json index 6aa6499b9..e5fb73520 100644 --- a/libs/portal/shared/ui/tsconfig.spec.json +++ b/libs/portal/shared/ui/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/portal/shared/util/src/lib/live-panels-controller.spec.ts b/libs/portal/shared/util/src/lib/live-panels-controller.spec.ts index 540842890..182cbeb91 100644 --- a/libs/portal/shared/util/src/lib/live-panels-controller.spec.ts +++ b/libs/portal/shared/util/src/lib/live-panels-controller.spec.ts @@ -33,7 +33,7 @@ describe('LivePanelsController', () => { async function settle(): Promise { TestBed.flushEffects(); - await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(() => resolve())); } beforeEach(() => { diff --git a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts index a4881be07..52059e9aa 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts @@ -4,7 +4,15 @@ import { runInInjectionContext, } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { + ActivatedRoute, + ActivatedRouteSnapshot, + convertToParamMap, +} from '@angular/router'; + +type WritableSnapshot = { + -readonly [K in keyof ActivatedRouteSnapshot]: ActivatedRouteSnapshot[K]; +}; import { BehaviorSubject } from 'rxjs'; import { extractPortalPlaylistId, @@ -36,7 +44,7 @@ describe('portal-route.utils', () => { layout: 'workspace', }, }, - } as ActivatedRoute; + } as unknown as ActivatedRoute; const childRoute = { snapshot: { data: {}, @@ -257,10 +265,11 @@ describe('portal-route.utils', () => { expect(playlistId()).toBe('playlist-1'); - route.snapshot.paramMap = convertToParamMap({ + const snapshot = route.snapshot as WritableSnapshot; + snapshot.paramMap = convertToParamMap({ id: 'playlist-2', }); - route.snapshot.params = { + snapshot.params = { id: 'playlist-2', }; paramMap$.next( @@ -296,7 +305,7 @@ describe('portal-route.utils', () => { }, }, paramMap: parentParamMap$.asObservable(), - } as ActivatedRoute; + } as unknown as ActivatedRoute; const route = { snapshot: { data: {}, @@ -316,10 +325,11 @@ describe('portal-route.utils', () => { expect(playlistId()).toBe('playlist-1'); - (parentRoute.snapshot as ActivatedRoute['snapshot']).paramMap = - convertToParamMap({ + (parentRoute.snapshot as WritableSnapshot).paramMap = convertToParamMap( + { id: 'playlist-2', - }); + } + ); (parentRoute.snapshot as ActivatedRoute['snapshot']).params = { id: 'playlist-2', }; diff --git a/libs/portal/shared/util/src/lib/vod-watched-toggle.spec.ts b/libs/portal/shared/util/src/lib/vod-watched-toggle.spec.ts index 47af81167..b3044d4fd 100644 --- a/libs/portal/shared/util/src/lib/vod-watched-toggle.spec.ts +++ b/libs/portal/shared/util/src/lib/vod-watched-toggle.spec.ts @@ -105,7 +105,9 @@ describe('createVodWatchedToggle', () => { applyPosition: (next) => position.set(next), playingNow, notify: (kind) => feedback.push(kind), - onPersisted: (playlistId) => persisted.push(playlistId), + onPersisted: (playlistId) => { + persisted.push(playlistId); + }, logger, }); const target = (stillCurrent = () => true) => ({ diff --git a/libs/portal/shared/util/tsconfig.spec.json b/libs/portal/shared/util/tsconfig.spec.json index 6aa6499b9..e5fb73520 100644 --- a/libs/portal/shared/util/tsconfig.spec.json +++ b/libs/portal/shared/util/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.spec.ts index 197b1f837..e840a82ce 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.spec.ts @@ -1,4 +1,7 @@ -import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + PlaylistMeta, + PlaylistUpdateState, +} from '@iptvnator/shared/interfaces'; import { STALKER_MAG_USER_AGENT, STALKER_STREAM_USER_AGENT, @@ -16,8 +19,8 @@ function createPlaylist(overrides: Partial = {}): PlaylistMeta { url: '', importDate: '', filePath: '', - updateDate: '', - updateState: '', + updateDate: 0, + updateState: PlaylistUpdateState.NOT_UPDATED, position: 0, autoRefresh: false, favorites: [], diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts index 63ca6fd44..ca82f2e2c 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts @@ -25,7 +25,7 @@ const MISCLASSIFIED = { portalUrl: 'http://ministra.example/server/load.php', macAddress: '00:1A:79:AA:BB:CC', isFullStalkerPortal: false, -} as PlaylistMeta; +} as PlaylistMeta & { portalUrl: string }; async function flushMicrotasks(): Promise { for (let i = 0; i < 4; i += 1) { diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts index c0cf534ee..371ff8a53 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -470,9 +470,9 @@ describe('StalkerSessionService identity payloads', () => { expect(payload.params).not.toHaveProperty('device_id2'); expect(payload.params).not.toHaveProperty('signature'); expect(payload.params).not.toHaveProperty('signature2'); - expect(JSON.parse(String(payload.params.metrics))).not.toHaveProperty( - 'sn' - ); + expect( + JSON.parse(String(payload.params['metrics'])) + ).not.toHaveProperty('sn'); }); it('sends provided SN, device IDs, and signatures exactly in get_profile', async () => { @@ -505,7 +505,7 @@ describe('StalkerSessionService identity payloads', () => { signature2: 'SIGNATURE-2', }) ); - expect(JSON.parse(String(payload.params.metrics))).toEqual( + expect(JSON.parse(String(payload.params['metrics']))).toEqual( expect.objectContaining({ sn: 'CUSTOMSN123', }) diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts index c41277099..f99e7f9ad 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts @@ -112,7 +112,9 @@ describe('stalker-vod.utils regressions', () => { cmd: '/media/file_42.mpg', }) ); - expect(state.vodDetailsItem?.data.id).toBe('42'); + expect(state.vodDetailsItem?.data).toEqual( + expect.objectContaining({ id: '42' }) + ); }); it('favorite toggle uses completion callback path without delayed state update', () => { diff --git a/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts index 5dadcf0e3..f4868a7e8 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts @@ -8,8 +8,10 @@ import { DataService, PlaylistsService } from '@iptvnator/services'; import { StalkerSessionService } from './stalker-session.service'; import { StalkerStore } from './stalker.store'; +type StalkerStoreMember = keyof InstanceType; + describe('StalkerStore API compatibility smoke', () => { - let store: StalkerStore; + let store: InstanceType; beforeEach(() => { TestBed.configureTestingModule({ @@ -25,7 +27,6 @@ describe('StalkerStore API compatibility smoke', () => { { provide: StalkerSessionService, useValue: { - ensureToken: jest.fn(), makeAuthenticatedRequest: jest.fn(), ensureToken: jest.fn().mockResolvedValue({ token: null, @@ -75,7 +76,7 @@ describe('StalkerStore API compatibility smoke', () => { }); it('exposes compatibility state signals', () => { - const expectedSignals = [ + const expectedSignals: StalkerStoreMember[] = [ 'selectedContentType', 'selectedCategoryId', 'selectedVodId', @@ -102,7 +103,7 @@ describe('StalkerStore API compatibility smoke', () => { }); it('exposes compatibility computed selectors', () => { - const expectedComputed = [ + const expectedComputed: StalkerStoreMember[] = [ // getTotalPages was removed with catalog pagination — the grid // appends portal pages and pages have no UI representation left. 'hasMoreContent', @@ -126,7 +127,7 @@ describe('StalkerStore API compatibility smoke', () => { }); it('exposes compatibility methods and internal resources', () => { - const expectedMethods = [ + const expectedMethods: StalkerStoreMember[] = [ 'setSelectedContentType', 'setSelectedCategory', 'setSelectedSerialId', diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index 78ada9b2e..ac1a4e27d 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -34,6 +34,7 @@ const PLAYLIST = { } as PlaylistMeta; const TestContentStore = signalStore( + { protectedState: false }, withState({ currentPlaylist: undefined as PlaylistMeta | undefined, selectedContentType: 'vod' as 'vod' | 'series' | 'itv' | 'radio', @@ -58,6 +59,11 @@ const TestContentStore = signalStore( withStalkerContent() ); +/** Request payload the content feature sends through `sendIpcEvent`. */ +interface StalkerIpcRequest { + params?: { action?: string; p?: number; search?: string }; +} + function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -136,7 +142,10 @@ function createItvCacheMock( describe('withStalkerContent failure states', () => { let store: InstanceType; let dataService: { - sendIpcEvent: jest.Mock, unknown[]>; + sendIpcEvent: jest.Mock< + Promise, + [event: string, request: StalkerIpcRequest] + >; }; let parentalLock: { active: jest.Mock; @@ -151,7 +160,9 @@ describe('withStalkerContent failure states', () => { parentalLock = { active: jest.fn(() => false), version: signal(0), - lockedStalkerIds: jest.fn(() => []), + lockedStalkerIds: jest.fn( + (_playlistId: string, _type: string): string[] => [] + ), }; TestBed.configureTestingModule({ @@ -187,11 +198,10 @@ describe('withStalkerContent failure states', () => { it('keeps ITV pages unfiltered while independent local searches change', async () => { const pending = createDeferred>(); - dataService.sendIpcEvent.mockImplementation( - (_event, request: { params: { action: string } }) => - request.params.action === 'get_genres' - ? Promise.resolve({ js: [] }) - : pending.promise + dataService.sendIpcEvent.mockImplementation((_event, request) => + request.params?.action === 'get_genres' + ? Promise.resolve({ js: [] }) + : pending.promise ); store.setSelectedContentType('itv'); store.setCategories('itv', [ @@ -203,15 +213,10 @@ describe('withStalkerContent failure states', () => { await flushResources(); const orderedCalls = () => dataService.sendIpcEvent.mock.calls.filter( - (call) => - (call[1] as { params: { action: string } }).params - .action === 'get_ordered_list' + (call) => call[1].params?.action === 'get_ordered_list' ); await waitForCondition(() => orderedCalls().length > 0); - expect( - (orderedCalls()[0][1] as { params: { search?: string } }).params - .search - ).toBeUndefined(); + expect(orderedCalls()[0][1].params?.search).toBeUndefined(); patchState(store, { searchPhrase: 'changed' }); await flushResources(); pending.resolve(createContentResponse('Panel match')); @@ -964,7 +969,10 @@ describe('withStalkerContent full ITV channel list cache', () => { let store: InstanceType; let dataService: { - sendIpcEvent: jest.Mock, unknown[]>; + sendIpcEvent: jest.Mock< + Promise, + [event: string, request: StalkerIpcRequest] + >; }; let itvCache: ReturnType; @@ -1195,19 +1203,18 @@ describe('withStalkerContent full ITV channel list cache', () => { it('keeps censored pagination after a delayed cache replays the current page', async () => { setup(null); - dataService.sendIpcEvent.mockImplementation( - (_event, payload: { params: { p: number } }) => - Promise.resolve({ - js: { - data: [ - { - id: String(payload.params.p), - name: `Hidden ${payload.params.p}`, - }, - ], - total_items: 3, - }, - }) + dataService.sendIpcEvent.mockImplementation((_event, payload) => + Promise.resolve({ + js: { + data: [ + { + id: String(payload.params?.p), + name: `Hidden ${payload.params?.p}`, + }, + ], + total_items: 3, + }, + }) ); enterItvCategory('1099'); await waitForCondition(() => store.itvChannels().length === 1); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.spec.ts index 0577300c9..846485442 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.spec.ts @@ -179,7 +179,7 @@ describe('withStalkerFavorites', () => { playlist: { _id: 'portal-1', favorites: [], - } as PlaylistMeta, + } as Partial as PlaylistMeta, }) ); expect(snackBar.open).toHaveBeenCalledWith( diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.spec.ts index 159c2001e..67a8bc8e6 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { signalStore } from '@ngrx/signals'; import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services'; -import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { ElectronBridgeApi, PlaylistMeta } from '@iptvnator/shared/interfaces'; import { StalkerPortalRepairService } from '../../stalker-portal-repair.service'; import { StalkerSessionService } from '../../stalker-session.service'; import { withStalkerPortal } from './with-stalker-portal.feature'; @@ -48,7 +48,7 @@ describe('withStalkerPortal', () => { value: { dbCreatePlaylist, dbGetPlaylist, - } as Window['electron'], + } satisfies Partial, configurable: true, }); @@ -138,5 +138,4 @@ describe('withStalkerPortal', () => { expect(dbGetPlaylist).not.toHaveBeenCalled(); expect(dbCreatePlaylist).not.toHaveBeenCalled(); }); - }); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.spec.ts index 2e4ad1908..d50284107 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.spec.ts @@ -174,7 +174,7 @@ describe('withStalkerRecent', () => { playlist: { _id: 'portal-1', recentlyViewed: [], - } as PlaylistMeta, + } as Partial as PlaylistMeta, }) ); expect(onComplete).toHaveBeenCalled(); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts index a52344a34..1e46dffbc 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts @@ -30,6 +30,7 @@ const PLAYLIST = { } as PlaylistMeta; const TestSeriesStore = signalStore( + { protectedState: false }, withState({ currentPlaylist: undefined as PlaylistMeta | undefined, }), diff --git a/libs/portal/stalker/data-access/tsconfig.spec.json b/libs/portal/stalker/data-access/tsconfig.spec.json index 6aa6499b9..e5fb73520 100644 --- a/libs/portal/stalker/data-access/tsconfig.spec.json +++ b/libs/portal/stalker/data-access/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts index e25f1c68b..79fcdca71 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts @@ -61,7 +61,7 @@ export class StalkerActorRouteComponent { initialValue: this.route.snapshot.params, }); private readonly personId = computed(() => - Number(this.routeParams().personId) + Number(this.routeParams()['personId']) ); readonly profile = signal(null); diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts index 083c99f03..2c72e4b5b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts @@ -411,16 +411,7 @@ describe('StalkerCollectionDetailComponent', () => { }); it('plays regular VOD collection details inline for embedded players instead of using the legacy play wrapper', async () => { - const sourceItem = { - id: '1701', - title: 'Collection Movie', - category_id: 'vod', - cmd: '/media/file_1701.mpg', - info: { - name: 'Collection Movie', - movie_image: 'movie.jpg', - }, - }; + const sourceItem = createSourceItem(); const playback: ResolvedPortalPlayback = { streamUrl: 'https://streams.example.test/movie.mp4', title: 'Collection Movie', @@ -488,7 +479,10 @@ describe('StalkerCollectionDetailComponent', () => { fixture.componentRef.setInput('item', itemB); await settleDetail(fixture); - pendingA.resolve({ streamUrl: 'https://streams.test/a.mp4' }); + pendingA.resolve({ + streamUrl: 'https://streams.test/a.mp4', + title: 'Movie A', + }); await settleDetail(fixture); expect(fixture.componentInstance.inlinePlayback()).toBeNull(); @@ -504,11 +498,15 @@ describe('StalkerCollectionDetailComponent', () => { fixture.componentInstance.onVodPlay( createStalkerVodItem(itemB.stalkerItem as never, itemB.playlistId) ); - pendingB.resolve({ streamUrl: 'https://streams.test/b.mp4' }); + pendingB.resolve({ + streamUrl: 'https://streams.test/b.mp4', + title: 'Movie B', + }); await settleDetail(fixture); expect(fixture.componentInstance.inlinePlayback()).toEqual({ streamUrl: 'https://streams.test/b.mp4', + title: 'Movie B', }); }); @@ -570,23 +568,21 @@ describe('StalkerCollectionDetailComponent', () => { stalkerItem: { ...(item.stalkerItem as object) }, }); await settleDetail(fixture); - pending.resolve({ streamUrl: 'https://streams.test/a.mp4' }); + pending.resolve({ + streamUrl: 'https://streams.test/a.mp4', + title: 'Movie A', + }); await settleDetail(fixture); expect(fixture.componentInstance.inlinePlayback()).toEqual({ streamUrl: 'https://streams.test/a.mp4', + title: 'Movie A', }); expect(snackBar.open).not.toHaveBeenCalled(); }); it('marks a collection movie watched under its owning playlist', async () => { - const sourceItem = { - id: '1701', - title: 'Collection Movie', - category_id: 'vod', - cmd: '/media/file_1701.mpg', - info: { name: 'Collection Movie', movie_image: 'movie.jpg' }, - }; + const sourceItem = createSourceItem(); fixture.componentRef.setInput( 'item', buildCollectionItem({ @@ -627,13 +623,7 @@ describe('StalkerCollectionDetailComponent', () => { }); it('blocks the watched toggle while a collection Play is still resolving', async () => { - const sourceItem = { - id: '1701', - title: 'Collection Movie', - category_id: 'vod', - cmd: '/media/file_1701.mpg', - info: { name: 'Collection Movie', movie_image: 'movie.jpg' }, - }; + const sourceItem = createSourceItem(); let resolve!: (value: ResolvedPortalPlayback) => void; stalkerStore.resolveVodPlayback.mockReturnValueOnce( new Promise((resolvePromise) => { @@ -675,13 +665,7 @@ describe('StalkerCollectionDetailComponent', () => { }); it('does not carry a pending collection start over to the next item', async () => { - const sourceItem = { - id: '1701', - title: 'Collection Movie', - category_id: 'vod', - cmd: '/media/file_1701.mpg', - info: { name: 'Collection Movie', movie_image: 'movie.jpg' }, - }; + const sourceItem = createSourceItem(); let resolve!: (value: ResolvedPortalPlayback) => void; stalkerStore.resolveVodPlayback.mockReturnValueOnce( new Promise((resolvePromise) => { @@ -729,13 +713,7 @@ describe('StalkerCollectionDetailComponent', () => { }); it('mirrors an external player position into the collection row', async () => { - const sourceItem = { - id: '1701', - title: 'Collection Movie', - category_id: 'vod', - cmd: '/media/file_1701.mpg', - info: { name: 'Collection Movie', movie_image: 'movie.jpg' }, - }; + const sourceItem = createSourceItem(); playbackPositions.getPlaybackPosition.mockResolvedValueOnce({ playlistId: 'stalker-1', contentXtreamId: 1701, @@ -787,13 +765,7 @@ describe('StalkerCollectionDetailComponent', () => { resolveRead = resolve; }) ); - const sourceItem = { - id: '1701', - title: 'Collection Movie', - category_id: 'vod', - cmd: '/media/file_1701.mpg', - info: { name: 'Collection Movie', movie_image: 'movie.jpg' }, - }; + const sourceItem = createSourceItem(); fixture.componentRef.setInput( 'item', buildCollectionItem({ @@ -834,9 +806,6 @@ describe('StalkerCollectionDetailComponent', () => { }); it('does not load VOD playback position when the playlist id is missing', async () => { - const playlistsService = TestBed.inject(PlaylistsService) as { - getPlaylistById: jest.Mock; - }; playlistsService.getPlaylistById.mockReturnValue( of({ ...playlist, @@ -947,6 +916,27 @@ function buildVodSource(id: string, title: string) { }; } +/** Regular (non-series) Stalker VOD item as the collection grid hands it over. */ +function createSourceItem() { + return { + id: '1701', + title: 'Collection Movie', + category_id: 'vod', + cmd: '/media/file_1701.mpg', + info: { + name: 'Collection Movie', + movie_image: 'movie.jpg', + description: '', + actors: '', + director: '', + releasedate: '', + genre: '', + rating_imdb: '', + rating_kinopoisk: '', + }, + }; +} + function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts index eab5d0668..48a0a4efb 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts @@ -51,7 +51,21 @@ class StubStalkerSeriesViewComponent { } const VOD_ITEM = createStalkerVodItem( - { id: '42', cmd: '/media/42', info: { name: 'Movie' } }, + { + id: '42', + cmd: '/media/42', + info: { + name: 'Movie', + movie_image: '', + description: '', + actors: '', + director: '', + releasedate: '', + genre: '', + rating_imdb: '', + rating_kinopoisk: '', + }, + }, 'stalker-1' ); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open.spec.ts index 8f36491ca..b9d26e3df 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-auto-open.spec.ts @@ -18,21 +18,21 @@ const channel = ( describe('StalkerLiveAutoOpen', () => { let autoOpen: StalkerLiveAutoOpen; let events: Subject; + const selectedCategoryId = signal(null); + const searchPhrase = signal(''); const store = { currentPlaylist: signal<{ _id?: string } | null>({ _id: 'pl-3' }), selectedContentType: signal('itv'), - selectedCategoryId: signal(null), + selectedCategoryId, itvChannelsCategory: signal(null), - searchPhrase: signal(''), + searchPhrase, itvFullChannelList: signal([]), itvFullListActive: signal(false), itvFullListUnsupported: signal(false), preloadItvChannels: jest.fn(() => Promise.resolve()), - setSearchPhrase: jest.fn((phrase: string) => - store.searchPhrase.set(phrase) - ), + setSearchPhrase: jest.fn((phrase: string) => searchPhrase.set(phrase)), setSelectedCategory: jest.fn((category: string | null) => - store.selectedCategoryId.set(category) + selectedCategoryId.set(category) ), setPage: jest.fn(), }; diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.spec.ts index bdb1844f4..5e97d2cf8 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.spec.ts @@ -22,25 +22,26 @@ describe('StalkerLiveNavigation', () => { const third = channel('3', 'sports'); const rows = signal([first, second]); const loading = signal(false); + const selectedCategoryId = signal('news'); + const searchPhrase = signal(''); + const page = signal(0); const store = { currentPlaylist: signal({ _id: 'source-a' }), selectedContentType: signal('itv'), - selectedCategoryId: signal('news'), - searchPhrase: signal(''), + selectedCategoryId, + searchPhrase, selectedItem: signal(null), - page: signal(0), + page, hasMoreChannels: signal(false), getCategoryResource: signal([ { category_id: 'news' }, { category_id: 'sports' }, ]), - setSearchPhrase: jest.fn((query: string) => - store.searchPhrase.set(query) - ), + setSearchPhrase: jest.fn((query: string) => searchPhrase.set(query)), setSelectedCategory: jest.fn((category: string) => - store.selectedCategoryId.set(category) + selectedCategoryId.set(category) ), - setPage: jest.fn((page: number) => store.page.set(page)), + setPage: jest.fn((next: number) => page.set(next)), }; const router = { url: '/workspace/stalker/source-a/itv?q=sport&keep=1', diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 56e792460..6c7e6509c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -4,10 +4,10 @@ import { By } from '@angular/platform-browser'; import { MatSnackBar } from '@angular/material/snack-bar'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui'; +import type { PlaybackFallbackRequest } from '@iptvnator/playback/util'; import { LIVE_EPG_PANEL_STATE_STORAGE_KEY, PORTAL_PLAYER, - ResizableDirective, } from '@iptvnator/portal/shared/util'; import { ACTIVE_EPG_FALLBACK_SIZE, @@ -16,7 +16,10 @@ import { import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg'; import { AudioPlayerComponent } from '@iptvnator/ui/playback'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { ChannelListItemComponent } from '@iptvnator/ui/components'; +import { + ChannelListItemComponent, + ResizableDirective, +} from '@iptvnator/ui/components'; import { MockPipe } from 'ng-mocks'; import { of } from 'rxjs'; import { @@ -26,6 +29,7 @@ import { SettingsStore, } from '@iptvnator/services'; import { + ElectronBridgeApi, EpgItem, EpgProgram, ResolvedPortalPlayback, @@ -43,6 +47,11 @@ import { StubWebPlayerViewComponent, } from './stalker-live-stream-layout.spec-stubs'; +/** Installs an intentionally partial Electron bridge double. */ +function installElectronBridge(bridge: Partial): void { + window.electron = bridge as ElectronBridgeApi; +} + describe('StalkerLiveStreamLayoutComponent', () => { let fixture: ComponentFixture; let component: StalkerLiveStreamLayoutComponent; @@ -159,7 +168,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { fetchChannelEpg: jest.fn(), ensureBulkItvEpg: jest.fn(), applyMappedItvEpg: jest.fn().mockResolvedValue(undefined), - hasItvEpgMappingOverride: jest.fn(() => false), + hasItvEpgMappingOverride: jest.fn((_id: string | number) => false), clearBulkItvEpgCache: jest.fn(() => { bulkItvEpgByChannel.set({}); bulkItvEpgLoaded.set(false); @@ -191,13 +200,13 @@ describe('StalkerLiveStreamLayoutComponent', () => { // 'list' into siblings. settingsStore.resolvedEpgViewMode.set('timeline'); playlist.set({ _id: 'playlist-1', title: 'Demo Stalker' }); - window.electron = { + installElectronBridge({ platform: 'darwin', setUserAgent: jest.fn().mockResolvedValue(true), updateRemoteControlStatus: jest.fn(), onChannelChange: jest.fn(() => jest.fn()), onRemoteControlCommand: jest.fn(() => jest.fn()), - } as typeof window.electron; + }); fetchChannelEpg = stalkerStore.fetchChannelEpg; ensureBulkItvEpg = stalkerStore.ensureBulkItvEpg; @@ -444,11 +453,6 @@ describe('StalkerLiveStreamLayoutComponent', () => { title: 'Alpha TV', isLive: true, headers: { Authorization: 'Bearer token' }, - contentInfo: { - playlistId: 'playlist-1', - contentXtreamId: 10001, - contentType: 'live', - }, }; const request: PlaybackFallbackRequest = { player: 'vlc', @@ -458,6 +462,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { code: 'network-error', player: 'html5', source: 'hls', + sourceUrl: playback.streamUrl, container: '', mimeType: '', videoCodecs: [], @@ -609,9 +614,9 @@ describe('StalkerLiveStreamLayoutComponent', () => { it('does not publish remote-control status when the bridge is incomplete', () => { fixture.destroy(); const updateRemoteControlStatus = jest.fn(); - window.electron = { + installElectronBridge({ updateRemoteControlStatus, - } as typeof window.electron; + }); fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent); component = fixture.componentInstance; diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts index a48dc077b..ec7844f4a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts @@ -197,9 +197,13 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => { expect(selectedItem()).toBe(channels[0]); const currentPlayback = { streamUrl: 'https://two.example/live.m3u8', + title: 'Channel two', }; current.resolve(currentPlayback); - stale.resolve({ streamUrl: 'https://stale.example/live.m3u8' }); + stale.resolve({ + streamUrl: 'https://stale.example/live.m3u8', + title: 'Channel one', + }); await Promise.all([staleSelection, currentSelection]); expect(selectedItem()).toBe(channels[1]); expect(component.isSelectedChannel(channels[1])).toBe(true); @@ -224,10 +228,16 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => { const requestA = component.playChannel(shared); playlist.set({ _id: 'playlist-b', title: 'Portal B' }); const requestB = component.playChannel(shared); - const playbackB = { streamUrl: 'https://b.example/shared.m3u8' }; + const playbackB = { + streamUrl: 'https://b.example/shared.m3u8', + title: 'Shared channel (Portal B)', + }; sourceB.resolve(playbackB); - sourceA.resolve({ streamUrl: 'https://a.example/shared.m3u8' }); + sourceA.resolve({ + streamUrl: 'https://a.example/shared.m3u8', + title: 'Shared channel (Portal A)', + }); await Promise.all([requestA, requestB]); expect(resolveItvPlayback).toHaveBeenCalledTimes(2); @@ -274,7 +284,10 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => { const request = component.playChannel(channels[0]); selectedContentType.set('radio'); - pending.resolve({ streamUrl: 'https://stale.example/itv.m3u8' }); + pending.resolve({ + streamUrl: 'https://stale.example/itv.m3u8', + title: 'Channel one', + }); await request; expect(component.activePlayback()).toBeNull(); @@ -312,7 +325,10 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => { resolveItvPlayback.mockReturnValueOnce(pending.promise); const request = component.playChannel(channels[0]); store.setSelectedItem(channels[1]); - pending.resolve({ streamUrl: 'https://stale.example/live.m3u8' }); + pending.resolve({ + streamUrl: 'https://stale.example/live.m3u8', + title: 'Channel one', + }); await request; expect(selectedItem()).toBe(channels[1]); expect(component.activePlayback()).toBeNull(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts index e46f0ea72..ea8028e87 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts @@ -11,7 +11,7 @@ import { RuntimeCapabilitiesService, SettingsStore, } from '@iptvnator/services'; -import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { ElectronBridgeApi, EpgProgram } from '@iptvnator/shared/interfaces'; import { TranslateService } from '@ngx-translate/core'; import { of } from 'rxjs'; import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component'; @@ -22,6 +22,11 @@ import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.c * which sits at the max-lines test budget; the template is overridden to * empty because these behaviors live entirely in constructor effects. */ +/** Installs an intentionally partial Electron bridge double. */ +function installElectronBridge(bridge: Partial): void { + window.electron = bridge as ElectronBridgeApi; +} + describe('StalkerLiveStreamLayoutComponent remote status', () => { let fixture: ComponentFixture; const originalElectron = window.electron; @@ -147,13 +152,13 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => { itvChannels.set(originalItvChannels); stalkerStore.selectedCategoryId.set('1001'); stalkerStore.setSelectedItem.mockReset(); - window.electron = { + installElectronBridge({ platform: 'darwin', setUserAgent: jest.fn().mockResolvedValue(true), updateRemoteControlStatus, onChannelChange: jest.fn(() => jest.fn()), onRemoteControlCommand: jest.fn(() => jest.fn()), - } as typeof window.electron; + }); selectedContentType.set('itv'); selectedItem.set(itvChannels()[0]); @@ -179,13 +184,16 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => { // Mirrors the real capability check: every // remote-control bridge method must be present. get supportsRemoteControl() { - const bridge = window.electron as - Record | undefined; - return [ - 'updateRemoteControlStatus', - 'onChannelChange', - 'onRemoteControlCommand', - ].every( + const bridge: + Partial | undefined = + window.electron; + return ( + [ + 'updateRemoteControlStatus', + 'onChannelChange', + 'onRemoteControlCommand', + ] as const + ).every( (method) => typeof bridge?.[method] === 'function' ); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.spec-stubs.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.spec-stubs.ts index 52b6d2e05..a5cacb3e5 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.spec-stubs.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.spec-stubs.ts @@ -117,8 +117,14 @@ export class StubPortalEmptyStateComponent { readonly message = input(''); } +/** Accepts the layout's resizable bindings without touching storage or the DOM. */ @Directive({ selector: '[appResizable]', standalone: true, }) -export class StubResizableDirective {} +export class StubResizableDirective { + readonly minWidth = input(200); + readonly maxWidth = input(600); + readonly defaultWidth = input(400); + readonly storageKey = input(''); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts index ea51992c5..9bc96671b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts @@ -348,7 +348,9 @@ describe('StalkerSearchComponent result paging', () => { parentalLock = { active: jest.fn(() => false), version: signal(0), - lockedStalkerIds: jest.fn(() => []), + lockedStalkerIds: jest.fn( + (_playlistId: string, _type: string): string[] => [] + ), }; stalkerStoreMock = { selectedItem: signal(null), diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.spec.ts index 8efbd13cc..602997dc0 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.spec.ts @@ -31,7 +31,7 @@ const ITEM = { rating_imdb: '8.4', rating_kinopoisk: '8.0', tmdb_id: 88001, - tmdb_status: 'returning-series', + tmdb_status: 'returning', }, } as const; @@ -210,7 +210,7 @@ describe('createStalkerSeriesDownloadAdapter', () => { year: 2025, genres: ['Drama', 'Mystery'], rating: 8.4, - status: 'returning-series', + status: 'returning', posterUrl: SERIES_POSTER_URL, tmdbId: 88001, providerCategoryId: '18', diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.spec.ts index 46708c1be..a74f89568 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.spec.ts @@ -12,10 +12,12 @@ import { } from './stalker-series-resume'; function episode( - overrides: Partial & { id: number } + overrides: Partial> & { id: number } ): StalkerMappedEpisode { + const { id, ...rest } = overrides; return { - title: `Episode ${overrides.episode_num ?? 1}`, + id: String(id), + title: `Episode ${rest.episode_num ?? 1}`, container_extension: 'mpg', custom_sid: 'vod-series', added: '', @@ -23,7 +25,7 @@ function episode( episode_num: 1, direct_source: '', info: {}, - ...overrides, + ...rest, } as StalkerMappedEpisode; } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.playback-session-key.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.playback-session-key.spec.ts index 98985aa76..8c404e522 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.playback-session-key.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.playback-session-key.spec.ts @@ -330,9 +330,10 @@ describe('StalkerSeriesViewComponent playback session key', () => { pending.resolve({ streamUrl: 'https://stale.example/old-command.mpg', + title: 'Stale command', contentInfo: { playlistId: 'transport-playlist', - contentXtreamId: episodeA.id, + contentXtreamId: Number(episodeA.id), contentType: 'episode', }, }); @@ -676,9 +677,10 @@ describe('StalkerSeriesViewComponent playback session key', () => { setLazySeries('replacement-parent', 'replacement-provider-episode'); pending.resolve({ streamUrl: 'https://stale.example/old-episode.mpg', + title: 'Stale episode', contentInfo: { playlistId: 'transport-playlist', - contentXtreamId: episode.id, + contentXtreamId: Number(episode.id), contentType: 'episode', }, }); @@ -709,18 +711,20 @@ describe('StalkerSeriesViewComponent playback session key', () => { fixture.componentInstance.onEpisodeClicked(newEpisode); const newestPlayback = { streamUrl: 'https://new.example/episode.mpg', + title: 'Newest episode', contentInfo: { playlistId: 'transport-playlist', - contentXtreamId: newEpisode.id, + contentXtreamId: Number(newEpisode.id), contentType: 'episode' as const, }, }; newRequest.resolve(newestPlayback); oldRequest.resolve({ streamUrl: 'https://old.example/episode.mpg', + title: 'Old episode', contentInfo: { playlistId: 'transport-playlist', - contentXtreamId: oldEpisode.id, + contentXtreamId: Number(oldEpisode.id), contentType: 'episode', }, }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts index 152bcb820..d8d497917 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts @@ -6,7 +6,10 @@ import type { PortalPlaybackPositions, PortalPlayer, } from '@iptvnator/portal/shared/util'; -import type { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import type { + PlaybackPositionData, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; import { StalkerVodPlaybackController } from './stalker-vod-playback-controller'; interface Deferred { @@ -119,11 +122,14 @@ describe('StalkerVodPlaybackController', () => { it('does not mount playback that resolves after the detail closes', async () => { const { controller, inlinePlayback } = createController(); - const pending = createDeferred<{ streamUrl: string }>(); + const pending = createDeferred(); const playback = controller.startVodPlayback(() => pending.promise); controller.closeInlinePlayer(); - pending.resolve({ streamUrl: 'https://stale.example/movie.mpg' }); + pending.resolve({ + streamUrl: 'https://stale.example/movie.mpg', + title: 'Stale movie', + }); await playback; expect(inlinePlayback()).toBeNull(); @@ -131,14 +137,20 @@ describe('StalkerVodPlaybackController', () => { it('keeps the newest VOD request when resolutions finish out of order', async () => { const { controller, inlinePlayback } = createController(); - const older = createDeferred<{ streamUrl: string }>(); - const newer = createDeferred<{ streamUrl: string }>(); + const older = createDeferred(); + const newer = createDeferred(); const olderRequest = controller.startVodPlayback(() => older.promise); const newerRequest = controller.startVodPlayback(() => newer.promise); - const newestPlayback = { streamUrl: 'https://new.example/movie.mpg' }; + const newestPlayback = { + streamUrl: 'https://new.example/movie.mpg', + title: 'Newest movie', + }; newer.resolve(newestPlayback); - older.resolve({ streamUrl: 'https://old.example/movie.mpg' }); + older.resolve({ + streamUrl: 'https://old.example/movie.mpg', + title: 'Old movie', + }); await Promise.all([olderRequest, newerRequest]); expect(inlinePlayback()).toBe(newestPlayback); diff --git a/libs/portal/stalker/feature/tsconfig.spec.json b/libs/portal/stalker/feature/tsconfig.spec.json index 6aa6499b9..e5fb73520 100644 --- a/libs/portal/stalker/feature/tsconfig.spec.json +++ b/libs/portal/stalker/feature/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts index f968bf8c4..36dccdc3e 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -1,6 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; import { PwaXtreamDataSource } from './pwa-xtream-data-source'; +import type { XtreamContentItem } from './xtream-data-source.interface'; import { XtreamApiService, XtreamCredentials, @@ -41,7 +42,9 @@ describe('PwaXtreamDataSource', () => { }; parentalLock = { active: jest.fn(() => false), - lockedXtreamIds: jest.fn(() => []), + lockedXtreamIds: jest.fn( + (_playlistId: string, _type: string): number[] => [] + ), withholdsEverything: jest.fn(() => false), }; @@ -363,17 +366,17 @@ describe('PwaXtreamDataSource', () => { 'playlist-1', credentials, 'live' - )) as Array>; + )) as XtreamContentItem[]; const vod = (await dataSource.getContent( 'playlist-1', credentials, 'movie' - )) as Array>; + )) as XtreamContentItem[]; const series = (await dataSource.getContent( 'playlist-1', credentials, 'series' - )) as Array>; + )) as XtreamContentItem[]; expect(live[0]).toEqual( expect.objectContaining({ @@ -481,7 +484,7 @@ describe('PwaXtreamDataSource', () => { 'playlist-1', credentials, 'movie' - )) as Array>; + )) as XtreamContentItem[]; expect(content).toEqual([ expect.objectContaining({ @@ -606,7 +609,7 @@ describe('PwaXtreamDataSource', () => { 'playlist-1', credentials, 'movie' - )) as Array>; + )) as XtreamContentItem[]; expect(content[0]).toEqual( expect.objectContaining({ diff --git a/libs/portal/xtream/data-access/src/lib/services/epg-queue-invalidation.spec.ts b/libs/portal/xtream/data-access/src/lib/services/epg-queue-invalidation.spec.ts index 2a486ffcd..43e334cd0 100644 --- a/libs/portal/xtream/data-access/src/lib/services/epg-queue-invalidation.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/epg-queue-invalidation.spec.ts @@ -22,7 +22,7 @@ describe('EpgQueueService invalidation', () => { type ServicePrivates = { fetchEpg: ( - credentials: typeof credentials, + portalCredentials: typeof credentials, streamId: number ) => Promise; shouldFetch: (streamId: number) => boolean; @@ -42,7 +42,9 @@ describe('EpgQueueService invalidation', () => { provide: XtreamXmltvFallbackService, useValue: { getProgramsForChannel: jest.fn().mockResolvedValue([]), - getCurrentProgramsBatch: jest.fn().mockResolvedValue({}), + getCurrentProgramsBatch: jest + .fn() + .mockResolvedValue({}), }, }, { diff --git a/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.spec.ts index 4a06b59e8..09242f964 100644 --- a/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.spec.ts @@ -25,7 +25,7 @@ describe('EpgQueueService', () => { type ServicePrivates = { fetchEpg: ( - credentials: typeof credentials, + portalCredentials: typeof credentials, streamId: number ) => Promise; shouldFetch: (streamId: number) => boolean; diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts index e150e13a2..8b3a24880 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts @@ -223,7 +223,7 @@ describe('XtreamUrlService', () => { .mockResolvedValueOnce({ status: 206 }); window.electron = { xtreamProbeUrl, - } as typeof window.electron; + } as unknown as typeof window.electron; const firstUrl = await service.resolveCatchupUrl( 'playlist-1', @@ -263,7 +263,7 @@ describe('XtreamUrlService', () => { })); window.electron = { xtreamProbeUrl, - } as typeof window.electron; + } as unknown as typeof window.electron; const initialUrl = await service.resolveCatchupUrl( 'playlist-format-refresh', @@ -306,7 +306,7 @@ describe('XtreamUrlService', () => { })); window.electron = { xtreamProbeUrl, - } as typeof window.electron; + } as unknown as typeof window.electron; const catchupUrl = await service.resolveCatchupUrl( 'playlist-hls', @@ -345,7 +345,7 @@ describe('XtreamUrlService', () => { })); window.electron = { xtreamProbeUrl, - } as typeof window.electron; + } as unknown as typeof window.electron; const catchupUrl = await service.resolveCatchupUrl( 'playlist-hls-only', diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-xmltv-fallback.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-xmltv-fallback.service.spec.ts index f5dcef41c..03819df28 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-xmltv-fallback.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-xmltv-fallback.service.spec.ts @@ -31,7 +31,7 @@ describe('XtreamXmltvFallbackService', () => { getChannelPrograms: jest.fn(), getCurrentProgramsBatch: jest.fn(), }; - (window as { electron?: Bridge }).electron = bridge; + (window as { electron?: unknown }).electron = bridge; }); afterEach(() => { diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts index 6dd3ec5c5..af41bf71a 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts @@ -1,4 +1,5 @@ import { TestBed } from '@angular/core/testing'; +import type { RendererPerformancePhaseEvent } from '@iptvnator/shared/logging'; import { PortalStatusType } from '../../xtream-state'; import { createAbortError, @@ -250,7 +251,10 @@ describe('withContent import state', () => { type: ContentType, onProgress?: (count: number) => void, onTotal?: (total: number) => void, - options?: { onEvent?: (event: any) => void } + options?: { + onEvent?: (event: any) => void; + operationId?: string; + } ) => { optionsByType.set(type, options); onTotal?.(totals[type]); @@ -1279,7 +1283,10 @@ describe('withContent import state', () => { type: ContentType, onProgress?: (count: number) => void, onTotal?: (total: number) => void, - options?: { onEvent?: (event: any) => void } + options?: { + onEvent?: (event: any) => void; + operationId?: string; + } ) => { optionsByType.set(type, options); onTotal?.(5); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts index f56a199b7..c4a7d0056 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts @@ -16,7 +16,10 @@ import { XtreamPlaylistData, } from '../data-sources/xtream-data-source.interface'; import { XtreamApiService } from '../services/xtream-api.service'; -import { resolveXtreamVodPlaybackSource } from '../services/xtream-vod-playback-source'; +import { + resolveXtreamVodPlaybackSource, + type XtreamVodPlaybackItem, +} from '../services/xtream-vod-playback-source'; import { XtreamUrlService } from '../services/xtream-url.service'; import { XtreamXmltvFallbackService } from '../services/xtream-xmltv-fallback.service'; import { XtreamStore } from './xtream.store'; @@ -135,7 +138,11 @@ describe('XtreamStore VOD details recovery', () => { container_extension: null, }) ); - expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toBeNull(); + expect( + resolveXtreamVodPlaybackSource( + store.selectedItem() as XtreamVodPlaybackItem | null + ) + ).toBeNull(); expect(store.isLoadingDetails()).toBe(false); const publishedSelection = store.selectedItem(); @@ -152,11 +159,16 @@ describe('XtreamStore VOD details recovery', () => { } as XtreamVodStream); await waitForCondition( () => - resolveXtreamVodPlaybackSource(store.selectedItem()) - ?.containerExtension === 'mkv' + resolveXtreamVodPlaybackSource( + store.selectedItem() as XtreamVodPlaybackItem | null + )?.containerExtension === 'mkv' ); - expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toEqual({ + expect( + resolveXtreamVodPlaybackSource( + store.selectedItem() as XtreamVodPlaybackItem | null + ) + ).toEqual({ streamId: 42, containerExtension: 'mkv', }); @@ -197,8 +209,9 @@ describe('XtreamStore VOD details recovery', () => { }); await waitForCondition( () => - resolveXtreamVodPlaybackSource(store.selectedItem()) - ?.streamId === 99 + resolveXtreamVodPlaybackSource( + store.selectedItem() as XtreamVodPlaybackItem | null + )?.streamId === 99 ); firstCatalogItem.resolve({ @@ -208,7 +221,11 @@ describe('XtreamStore VOD details recovery', () => { await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toEqual({ + expect( + resolveXtreamVodPlaybackSource( + store.selectedItem() as XtreamVodPlaybackItem | null + ) + ).toEqual({ streamId: 99, containerExtension: 'mp4', }); @@ -303,8 +320,9 @@ describe('XtreamStore VOD details recovery', () => { }); await waitForCondition( () => - resolveXtreamVodPlaybackSource(store.selectedItem()) - ?.streamId === 99 + resolveXtreamVodPlaybackSource( + store.selectedItem() as XtreamVodPlaybackItem | null + )?.streamId === 99 ); firstDetails.resolve({ @@ -315,7 +333,11 @@ describe('XtreamStore VOD details recovery', () => { await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toEqual({ + expect( + resolveXtreamVodPlaybackSource( + store.selectedItem() as XtreamVodPlaybackItem | null + ) + ).toEqual({ streamId: 99, containerExtension: 'mp4', }); diff --git a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts index 16e2972bf..523a07790 100644 --- a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts @@ -12,7 +12,10 @@ jest.mock('@iptvnator/portal/shared/util', () => ({ }), })); -const TestFavoritesStore = signalStore(withFavorites()); +const TestFavoritesStore = signalStore( + { protectedState: false }, + withFavorites() +); describe('withFavorites', () => { const originalElectron = window.electron; @@ -196,9 +199,9 @@ describe('withFavorites', () => { await expect( store.toggleFavorite('not-a-number', 'playlist-1', 'movie') ).resolves.toBe(false); - await expect(store.toggleFavorite(0, 'playlist-1', 'movie')).resolves.toBe( - false - ); + await expect( + store.toggleFavorite(0, 'playlist-1', 'movie') + ).resolves.toBe(false); await expect(store.toggleFavorite(290, '', 'movie')).resolves.toBe( false ); diff --git a/libs/portal/xtream/data-access/tsconfig.spec.json b/libs/portal/xtream/data-access/tsconfig.spec.json index 6aa6499b9..e5fb73520 100644 --- a/libs/portal/xtream/data-access/tsconfig.spec.json +++ b/libs/portal/xtream/data-access/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.spec.ts b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.spec.ts index da4ff61ff..9bbcef602 100644 --- a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.spec.ts @@ -140,13 +140,24 @@ describe('AccountInfoComponent', () => { user_info: { active_cons: '0', allowed_output_formats: [], + auth: 1, + created_at: '1700000000', exp_date: '0', + is_trial: '0', max_connections: '0', + message: '', + password: 'dialog-pass', status: 'active', username: 'dialog-user', }, server_info: { + https_port: '443', + port: '80', + rtmp_port: '1935', server_protocol: 'http', + time_now: '2024-01-01 00:00:00', + timestamp_now: 1704067200, + timezone: 'UTC', url: 'dialog.example.test', }, }); diff --git a/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts b/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts index 1cc02c73c..42e58f7f8 100644 --- a/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts @@ -68,7 +68,7 @@ export class XtreamActorRouteComponent { initialValue: this.route.snapshot.params, }); private readonly personId = computed(() => - Number(this.routeParams().personId) + Number(this.routeParams()['personId']) ); readonly profile = signal(null); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-stubs.spec-data.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-stubs.spec-data.ts index 8da6ec9fa..70fea25b5 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-stubs.spec-data.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-stubs.spec-data.ts @@ -1,7 +1,6 @@ import { ChangeDetectionStrategy, Component, - Directive, input, output, signal, @@ -101,12 +100,6 @@ export class StubEpgTimelineComponent { readonly collapsedChange = output(); } -@Directive({ - selector: '[appResizable]', - standalone: true, -}) -export class StubResizableDirective {} - export const sampleChannel = { xtream_id: 101, name: 'Channel 101', diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index c20032af8..d871f27eb 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -16,7 +16,6 @@ import { liveSidebarStateStorageKey, LiveLayoutSidebarStateService, PORTAL_PLAYER, - ResizableDirective, } from '@iptvnator/portal/shared/util'; import { FavoritesService, @@ -50,7 +49,6 @@ import { StubEpgTimelineComponent, StubGridListComponent, StubPortalChannelsListComponent, - StubResizableDirective, StubWebPlayerViewComponent, } from './live-stream-layout-stubs.spec-data'; @@ -94,7 +92,9 @@ describe('LiveStreamLayoutComponent', () => { currentPlaylist, liveStreams, isContentInitialized, - selectItemsFromSelectedCategory: jest.fn(() => [sampleChannel]), + selectItemsFromSelectedCategory: jest.fn(() => [ + sampleChannel, + ]), constructStreamUrl: jest.fn(() => 'https://example.com/live.ts'), openPlayer: jest.fn(), setSelectedItem: jest.fn(), @@ -141,7 +141,7 @@ describe('LiveStreamLayoutComponent', () => { updateRemoteControlStatus: jest.fn(), onChannelChange: jest.fn(() => jest.fn()), onRemoteControlCommand: jest.fn(() => jest.fn()), - } as typeof window.electron; + } as unknown as typeof window.electron; routerEvents = new Subject(); router = { events: routerEvents, navigate: jest.fn() }; @@ -253,7 +253,6 @@ describe('LiveStreamLayoutComponent', () => { EpgTimelineComponent, GridListComponent, PortalChannelsListComponent, - ResizableDirective, TranslatePipe, WebPlayerViewComponent, ], @@ -263,7 +262,6 @@ describe('LiveStreamLayoutComponent', () => { StubEpgTimelineComponent, StubGridListComponent, StubPortalChannelsListComponent, - StubResizableDirective, MockPipe( TranslatePipe, (value: string | null | undefined) => value ?? '' @@ -571,17 +569,14 @@ describe('LiveStreamLayoutComponent', () => { title: 'Channel 101', isLive: true, headers: { Authorization: 'Bearer token' }, - contentInfo: { - playlistId: 'playlist-1', - contentXtreamId: 101, - contentType: 'live', - }, }; + // Partial double: the diagnostic that triggered the fallback is not + // needed to verify that the playback and player are forwarded as-is. component.handleExternalFallbackRequest({ player: 'mpv', playback, trackLaunch: jest.fn(), - } as PlaybackFallbackRequest); + } as unknown as PlaybackFallbackRequest); const [forwardedPlayback, forwardedPlayer] = portalPlayer.openExternalPlayback.mock.calls[0]; @@ -718,7 +713,7 @@ describe('LiveStreamLayoutComponent', () => { const updateRemoteControlStatus = jest.fn(); window.electron = { updateRemoteControlStatus, - } as typeof window.electron; + } as unknown as typeof window.electron; fixture = TestBed.createComponent(LiveStreamLayoutComponent); component = fixture.componentInstance; diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.parental-lock.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.parental-lock.spec.ts index 69d0f19ab..2c10375fc 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.parental-lock.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.parental-lock.spec.ts @@ -8,7 +8,6 @@ import { Subject, of } from 'rxjs'; import { LiveLayoutSidebarStateService, PORTAL_PLAYER, - ResizableDirective, } from '@iptvnator/portal/shared/util'; import { FavoritesService, @@ -37,7 +36,6 @@ import { StubEpgTimelineComponent, StubGridListComponent, StubPortalChannelsListComponent, - StubResizableDirective, StubWebPlayerViewComponent, } from './live-stream-layout-stubs.spec-data'; @@ -53,7 +51,9 @@ describe('LiveStreamLayoutComponent parental lock', () => { const parentalLock = { version: signal(0), active: signal(false), - isXtreamCategoryLocked: jest.fn(() => false), + isXtreamCategoryLocked: jest.fn( + (_playlistId: string, _type: string, _xtreamId: number) => false + ), }; const databaseService = { getAllXtreamCategories: jest.fn(async () => [] as unknown[]), @@ -164,7 +164,6 @@ describe('LiveStreamLayoutComponent parental lock', () => { EpgTimelineComponent, GridListComponent, PortalChannelsListComponent, - ResizableDirective, TranslatePipe, WebPlayerViewComponent, ], @@ -174,7 +173,6 @@ describe('LiveStreamLayoutComponent parental lock', () => { StubEpgTimelineComponent, StubGridListComponent, StubPortalChannelsListComponent, - StubResizableDirective, MockPipe( TranslatePipe, (value: string | null | undefined) => value ?? '' diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts index cfcc8307f..cfba09dc5 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts @@ -2,7 +2,6 @@ import { EpgArchiveDownloadService } from '@iptvnator/ui/epg'; import { EpgArchiveCopyService } from '@iptvnator/ui/epg'; import { Component, - Directive, input, output, signal, @@ -19,7 +18,6 @@ import { LiveLayoutSidebarStateService, liveSidebarStateStorageKey, PORTAL_PLAYER, - ResizableDirective, } from '@iptvnator/portal/shared/util'; import { ChannelListHiddenStateComponent, @@ -94,9 +92,6 @@ class StubGridListComponent { }) class StubPassiveComponent {} -@Directive({ selector: '[appResizable]', standalone: true }) -class StubResizableDirective {} - describe('LiveStreamLayoutComponent sidebar levels', () => { let fixture: ComponentFixture; let service: LiveLayoutSidebarStateService; @@ -222,7 +217,6 @@ describe('LiveStreamLayoutComponent sidebar levels', () => { EpgTimelineComponent, GridListComponent, PortalChannelsListComponent, - ResizableDirective, TranslatePipe, WebPlayerViewComponent, ], @@ -233,7 +227,6 @@ describe('LiveStreamLayoutComponent sidebar levels', () => { StubGridListComponent, StubPassiveComponent, StubPortalChannelsListComponent, - StubResizableDirective, MockPipe(TranslatePipe, (value: string) => value), ], }, @@ -325,7 +318,7 @@ describe('LiveStreamLayoutComponent sidebar levels', () => { service.hideCategories('portal'); fixture.detectChanges(); - await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(() => resolve())); expect(document.activeElement).toBe( query('[data-test-id="live-show-categories"]') @@ -341,7 +334,7 @@ describe('LiveStreamLayoutComponent sidebar levels', () => { service.hideCategories('portal'); fixture.detectChanges(); - await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(() => resolve())); expect(document.activeElement).toBe(sort); }); @@ -353,13 +346,13 @@ describe('LiveStreamLayoutComponent sidebar levels', () => { service.collapse('portal'); fixture.detectChanges(); - await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(() => resolve())); expect(document.activeElement).toBe(query('.sidebar-restore')); // The handle is removed with the expand, so focus is lost again. service.expand('portal'); fixture.detectChanges(); - await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(() => resolve())); expect(document.activeElement).toBe( query('[data-test-id="live-show-categories"]') ); @@ -374,7 +367,7 @@ describe('LiveStreamLayoutComponent sidebar levels', () => { // The category button the user activated is inert now: focus lost. selectedCategoryId.set(1); fixture.detectChanges(); - await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(() => resolve())); expect(document.activeElement).toBe( query('[data-test-id="live-show-categories"]') diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.ts index bbbb843e7..39fec9045 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.ts @@ -79,7 +79,7 @@ export class XtreamLiveChannelNavigationService { this.store .getCategoriesBySelectedType() .filter( - (category) => !('hidden' in category && category.hidden) + (category) => !('hidden' in category && category['hidden']) ) .map((category) => Number(category.category_id ?? category.id)) ); diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts index 31c3fcc5a..0b08aacc2 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts @@ -1,5 +1,6 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { MatDialogRef } from '@angular/material/dialog'; import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; import { of } from 'rxjs'; import { DatabaseService } from '@iptvnator/services'; @@ -16,6 +17,10 @@ jest.mock('@iptvnator/portal/shared/ui', () => ({ SearchLayoutComponent: class {}, })); +/** The component is opened as a route, not a dialog: `@Optional()` resolves nothing. */ +const NO_DIALOG_REF = + undefined as unknown as MatDialogRef; + const DEFAULT_SEARCH_FILTERS: SearchFilters = { live: true, movie: true, @@ -122,7 +127,7 @@ describe('SearchResultsComponent initialQuery contract', () => { isGlobalSearch: true, initialQuery: 'matrix', }, - undefined + NO_DIALOG_REF ) ); @@ -138,7 +143,7 @@ describe('SearchResultsComponent initialQuery contract', () => { { isGlobalSearch: true, }, - undefined + NO_DIALOG_REF ) ); @@ -151,7 +156,7 @@ describe('SearchResultsComponent initialQuery contract', () => { store.searchTerm.set('matrix'); const component = TestBed.runInInjectionContext( - () => new SearchResultsComponent(null, undefined) + () => new SearchResultsComponent(null, NO_DIALOG_REF) ); TestBed.flushEffects(); @@ -166,7 +171,7 @@ describe('SearchResultsComponent initialQuery contract', () => { { isGlobalSearch: true, }, - undefined + NO_DIALOG_REF ) ); @@ -191,7 +196,7 @@ describe('SearchResultsComponent initialQuery contract', () => { { isGlobalSearch: true, }, - undefined + NO_DIALOG_REF ) ); const channel = { @@ -249,7 +254,7 @@ describe('SearchResultsComponent initialQuery contract', () => { const freshResults = [ createSearchItem({ xtream_id: 777, title: 'Fresh result' }), ]; - const databaseService = TestBed.inject(DatabaseService) as { + const databaseService = TestBed.inject(DatabaseService) as unknown as { globalSearchContent: jest.Mock; }; databaseService.globalSearchContent @@ -262,7 +267,7 @@ describe('SearchResultsComponent initialQuery contract', () => { { isGlobalSearch: true, }, - undefined + NO_DIALOG_REF ) ); const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; @@ -296,7 +301,7 @@ describe('SearchResultsComponent initialQuery contract', () => { xtream_id: 102, }), ]; - const databaseService = TestBed.inject(DatabaseService) as { + const databaseService = TestBed.inject(DatabaseService) as unknown as { globalSearchContent: jest.Mock; }; databaseService.globalSearchContent @@ -309,7 +314,7 @@ describe('SearchResultsComponent initialQuery contract', () => { { isGlobalSearch: true, }, - undefined + NO_DIALOG_REF ) ); const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; @@ -349,7 +354,7 @@ describe('SearchResultsComponent initialQuery contract', () => { it('clears the load-more indicator when a new global search supersedes pagination', async () => { const appendSearch = createDeferred(); const freshSearch = createDeferred(); - const databaseService = TestBed.inject(DatabaseService) as { + const databaseService = TestBed.inject(DatabaseService) as unknown as { globalSearchContent: jest.Mock; }; databaseService.globalSearchContent @@ -362,7 +367,7 @@ describe('SearchResultsComponent initialQuery contract', () => { { isGlobalSearch: true, }, - undefined + NO_DIALOG_REF ) ); const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; @@ -398,7 +403,7 @@ describe('SearchResultsComponent initialQuery contract', () => { xtream_id: 2, }), ]; - const databaseService = TestBed.inject(DatabaseService) as { + const databaseService = TestBed.inject(DatabaseService) as unknown as { globalSearchContent: jest.Mock; }; databaseService.globalSearchContent.mockResolvedValueOnce(nextPage); @@ -409,7 +414,7 @@ describe('SearchResultsComponent initialQuery contract', () => { { isGlobalSearch: true, }, - undefined + NO_DIALOG_REF ) ); const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; @@ -454,7 +459,7 @@ describe('SearchResultsComponent initialQuery contract', () => { xtream_id: index + 1, }) ); - const databaseService = TestBed.inject(DatabaseService) as { + const databaseService = TestBed.inject(DatabaseService) as unknown as { globalSearchContent: jest.Mock; }; databaseService.globalSearchContent.mockResolvedValueOnce(firstPage); @@ -465,7 +470,7 @@ describe('SearchResultsComponent initialQuery contract', () => { { isGlobalSearch: true, }, - undefined + NO_DIALOG_REF ) ); const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; @@ -519,7 +524,7 @@ describe('SearchResultsComponent in-portal result window', () => { it('windows the full result set, reveals further chunks, and resets on a new result set', () => { const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; const component = TestBed.runInInjectionContext( - () => new SearchResultsComponent(null, undefined) + () => new SearchResultsComponent(null, NO_DIALOG_REF) ); const items = Array.from({ length: 130 }, (_, index) => createSearchItem({ @@ -558,7 +563,7 @@ describe('SearchResultsComponent in-portal result window', () => { it('changes the reset identity on filter-only transitions', () => { const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; const component = TestBed.runInInjectionContext( - () => new SearchResultsComponent(null, undefined) + () => new SearchResultsComponent(null, NO_DIALOG_REF) ); const initialIdentity = component.searchResetIdentity(); @@ -571,9 +576,7 @@ describe('SearchResultsComponent in-portal result window', () => { const filteredIdentity = component.searchResetIdentity(); try { component.toggleExcludeHidden(true); - expect(component.searchResetIdentity()).not.toBe( - filteredIdentity - ); + expect(component.searchResetIdentity()).not.toBe(filteredIdentity); } finally { // toggleExcludeHidden persists; do not leak into other tests. localStorage.removeItem('xtream-search-exclude-hidden'); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index 07078021d..2fc7c4932 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -209,7 +209,7 @@ export class SerialDetailsPlaybackService { return; } - this.addToRecentlyViewed(this.route.snapshot.params.serialId); + this.addToRecentlyViewed(this.route.snapshot.params['serialId']); const streamUrl = this.xtreamStore.constructEpisodeStreamUrl(episode); const contentInfo: PlayerContentInfo = { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 365f26f8c..57d33a8ce 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -173,7 +173,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { readonly playbackSessionKey = computed(() => createSerialPlaybackSessionKey( this.xtreamStore.currentPlaylist()?.id, - this.routeParams().serialId, + this.routeParams()['serialId'], this.playback.inlinePlaybackSessionEpisodeState() ) ); @@ -389,7 +389,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { } this.xtreamStore.toggleFavorite( - this.route.snapshot.params.serialId, + this.route.snapshot.params['serialId'], playlist.id, 'series', this.selectedItem()?.info?.backdrop_path?.[0] diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts index 51416363a..574e03817 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts @@ -109,6 +109,7 @@ describe('VodDetailsPlaybackService — external playback handoff', () => { vodId: routeVodId, vodInfo: signal(null), activeSource, + supersedePendingSwitch: jest.fn(), }); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts index 8d5cd1bb6..6ff66dd1c 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts @@ -185,7 +185,7 @@ describe('VodDetailsRouteComponent — playback actions', () => { component.multiSource, 'markRouteSourceActive' ); - const beginPlayback = jest.spyOn(component.msUi, 'beginPlayback'); + const beginPlayback = jest.spyOn(component['msUi'], 'beginPlayback'); await component.playVod({ movie_data: { @@ -325,7 +325,9 @@ describe('VodDetailsRouteComponent — playback actions', () => { it('guards Restart and provider actions before external IPC settles', async () => { currentPlaylist.set({ id: 'playlist-1' }); - stubs.openResolvedPlayback.mockReturnValue(new Promise(() => undefined)); + stubs.openResolvedPlayback.mockReturnValue( + new Promise(() => undefined) + ); const component = fixture.componentInstance; const item = { movie_data: { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index b591d1f6f..2d17c5b04 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -171,7 +171,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { readonly isFavorite = this.xtreamStore.isFavorite; readonly isWatched = this.watched.isWatched; readonly canToggleWatched = this.watched.canToggle; - readonly selectedVodId = computed(() => Number(this.routeParams().vodId)); + readonly selectedVodId = computed(() => + Number(this.routeParams()['vodId']) + ); readonly playbackSessionKey = computed(() => { const sourceId = this.xtreamStore.currentPlaylist()?.id; const contentId = this.selectedVodId(); @@ -206,7 +208,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { : []; }); readonly selectedCategory = computed | null>(() => { - const categoryId = this.routeParams().categoryId; + const categoryId = this.routeParams()['categoryId']; if (!categoryId) { return null; } @@ -648,7 +650,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { } this.xtreamStore.toggleFavorite( - this.route.snapshot.params.vodId, + this.route.snapshot.params['vodId'], playlist.id, 'movie', this.selectedVodInfo()?.backdrop_path?.[0] diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts index 7e8783ba4..d6b0c1f83 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts @@ -1,3 +1,4 @@ +import type { VodSourceCandidate } from '@iptvnator/shared/interfaces'; import { ALT_THREE, ALT_TWO, diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts index f90795d17..11140efca 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts @@ -1,4 +1,5 @@ import { TestBed } from '@angular/core/testing'; +import type { VodSourceCandidate } from '@iptvnator/shared/interfaces'; import { ALT_THREE, ALT_TWO, diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts index c89e306ea..9226b9865 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts @@ -437,7 +437,10 @@ describe('VodMultiSourceHostService', () => { const alts = [2, 3, 4, 5, 6].map(alternative); await loadMovie(alts); - const gates = new Map>(); + const gates = new Map< + string, + ReturnType> + >(); resolver.resolve.mockImplementation((candidate) => { const gate = createDeferred(); gates.set((candidate as VodSourceCandidate).id, gate); @@ -468,7 +471,10 @@ describe('VodMultiSourceHostService', () => { const alts = [2, 3, 4, 5, 6, 7].map(alternative); await loadMovie(alts); - const gates = new Map>(); + const gates = new Map< + string, + ReturnType> + >(); resolver.resolve.mockImplementation((candidate) => { const gate = createDeferred(); gates.set((candidate as VodSourceCandidate).id, gate); diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts index fb4525532..ce7790828 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts @@ -41,7 +41,7 @@ const XTREAM_PLAYLIST_WITH_NULL_OPTIONALS = { origin: null, referrer: null, userAgent: null, -} as XtreamPlaylistData; +} as unknown as XtreamPlaylistData; type CachedScope = 'live' | 'vod' | 'series' | 'search' | 'recently-added'; async function flushEffects(): Promise { @@ -51,9 +51,7 @@ async function flushEffects(): Promise { } function getXtreamSectionFromUrl(url: string): string | null { - const match = url.match( - /^\/workspace\/xtreams\/[^/]+\/([^/?]+)(?:[/?]|$)/ - ); + const match = url.match(/^\/workspace\/xtreams\/[^/]+\/([^/?]+)(?:[/?]|$)/); return match?.[1] ?? null; } @@ -81,14 +79,18 @@ describe('XtreamWorkspaceRouteSession', () => { const selectedCategoryId = signal(null); const isContentInitialized = signal(false); const contentLoadStateByType = signal< - Record<'live' | 'vod' | 'series', 'idle' | 'loading' | 'ready' | 'error'> + Record< + 'live' | 'vod' | 'series', + 'idle' | 'loading' | 'ready' | 'error' + > >({ live: 'idle', vod: 'idle', series: 'idle', }); - const contentInitBlockReason = - signal(null); + const contentInitBlockReason = signal( + null + ); let hasUsableOfflineCache = false; const playlistContext = { @@ -147,8 +149,9 @@ describe('XtreamWorkspaceRouteSession', () => { series: 'ready', }); }), - hydrateCachedContent: jest.fn().mockImplementation( - async (scope?: CachedScope | null) => { + hydrateCachedContent: jest + .fn() + .mockImplementation(async (scope?: CachedScope | null) => { isContentInitialized.set(true); contentInitBlockReason.set(null); if (scope === 'live' || scope === 'vod' || scope === 'series') { @@ -163,30 +166,25 @@ describe('XtreamWorkspaceRouteSession', () => { series: 'ready', }); } + }), + prepareContentLoading: jest.fn((scope?: CachedScope | null) => { + isContentInitialized.set(false); + if (scope === 'live' || scope === 'vod' || scope === 'series') { + contentLoadStateByType.update((state) => ({ + ...state, + [scope]: 'loading', + })); + } else { + contentLoadStateByType.set({ + live: 'loading', + vod: 'loading', + series: 'loading', + }); } - ), - prepareContentLoading: jest.fn( - (scope?: CachedScope | null) => { - isContentInitialized.set(false); - if (scope === 'live' || scope === 'vod' || scope === 'series') { - contentLoadStateByType.update((state) => ({ - ...state, - [scope]: 'loading', - })); - } else { - contentLoadStateByType.set({ - live: 'loading', - vod: 'loading', - series: 'loading', - }); - } - } - ), - setSelectedContentType: jest.fn( - (type: 'live' | 'vod' | 'series') => { - selectedContentType.set(type); - } - ), + }), + setSelectedContentType: jest.fn((type: 'live' | 'vod' | 'series') => { + selectedContentType.set(type); + }), setSelectedCategory: jest.fn((categoryId: number | null) => { selectedCategoryId.set(categoryId); }), @@ -278,7 +276,9 @@ describe('XtreamWorkspaceRouteSession', () => { await flushEffects(); expect(xtreamStore.checkPortalStatus).toHaveBeenCalled(); - expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith(null); + expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith( + null + ); expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('vod'); expect(xtreamStore.prepareContentLoading).toHaveBeenCalledWith('vod'); expect(xtreamStore.initializeContent).toHaveBeenCalled(); @@ -450,9 +450,7 @@ describe('XtreamWorkspaceRouteSession', () => { xtreamStore.setSelectedCategory.mockClear(); router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/202`; - routerEvents.next( - new NavigationEnd(1, router.url, router.url) - ); + routerEvents.next(new NavigationEnd(1, router.url, router.url)); await flushEffects(); expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('vod'); @@ -531,9 +529,7 @@ describe('XtreamWorkspaceRouteSession', () => { expect(xtreamStore.hasUsableOfflineCache).toHaveBeenCalledWith( 'series' ); - expect(xtreamStore.hydrateCachedContent).toHaveBeenCalledWith( - 'series' - ); + expect(xtreamStore.hydrateCachedContent).toHaveBeenCalledWith('series'); expect(contentLoadStateByType().series).toBe('ready'); xtreamStore.hasUsableOfflineCache.mockClear(); @@ -635,9 +631,7 @@ describe('XtreamWorkspaceRouteSession', () => { expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith( null ); - expect(xtreamStore.hydrateCachedContent).toHaveBeenCalledWith( - 'search' - ); + expect(xtreamStore.hydrateCachedContent).toHaveBeenCalledWith('search'); expect(xtreamStore.initializeContent).not.toHaveBeenCalled(); }); diff --git a/libs/portal/xtream/feature/tsconfig.spec.json b/libs/portal/xtream/feature/tsconfig.spec.json index 6aa6499b9..e5fb73520 100644 --- a/libs/portal/xtream/feature/tsconfig.spec.json +++ b/libs/portal/xtream/feature/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/services/src/material-snack-bar.d.ts b/libs/services/src/material-snack-bar.d.ts deleted file mode 100644 index f67502bb1..000000000 --- a/libs/services/src/material-snack-bar.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -declare module '@angular/material/snack-bar' { - export class MatSnackBar { - open( - message: string, - action?: string, - config?: { duration?: number } - ): void; - } -} diff --git a/libs/ui/components/tsconfig.spec.json b/libs/ui/components/tsconfig.spec.json index 7f31bb031..71dd0db03 100644 --- a/libs/ui/components/tsconfig.spec.json +++ b/libs/ui/components/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts index cc0c2aa37..61350d2ce 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts @@ -21,10 +21,10 @@ describe('EpgGuideKeyboardController', () => { beforeEach(() => { host = { rowCount: jest.fn(() => 5), - blockCount: jest.fn(() => 3), + blockCount: jest.fn((_row: number) => 3), activeRow: jest.fn(() => 2), isBlocked: jest.fn(() => false), - isOwnedTarget: jest.fn(() => true), + isOwnedTarget: jest.fn((_target: EventTarget | null) => true), play: jest.fn(), details: jest.fn(), jumpNow: jest.fn(), diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts index 0eaa4833d..3eab4e372 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts @@ -5,7 +5,11 @@ import { By } from '@angular/platform-browser'; import { SettingsStore } from '@iptvnator/services'; import { EpgProgram } from '@iptvnator/shared/interfaces'; import { TranslateService } from '@ngx-translate/core'; -import { of, Subject } from 'rxjs'; +import { Observable, of, Subject } from 'rxjs'; +import type { + EpgItemDialogAction, + EpgItemDialogData, +} from '../epg-item-description/epg-item-description.component'; import { EpgProgrammeDialogService } from '../epg-programme-dialog.service'; import { EpgGuideComponent } from './epg-guide.component'; import { @@ -65,7 +69,11 @@ describe('EpgGuideComponent', () => { const searchHits = signal([]); const activate = jest.fn(); const setScope = jest.fn(); - const dialogOpen = jest.fn(() => of(undefined)); + const dialogOpen = jest.fn( + ( + _data: EpgItemDialogData + ): Observable => of(undefined) + ); beforeEach(() => { localStorage.clear(); diff --git a/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.spec.ts b/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.spec.ts index 924e1898a..e350baee7 100644 --- a/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.spec.ts @@ -7,10 +7,15 @@ import { } from '@iptvnator/epg/data-access'; import { EpgSourceStatusComponent } from './epg-source-status.component'; +/** Writable double for the bridge's read-only capability getters. */ +type EpgBridgeStub = { + -readonly [K in keyof EpgRuntimeBridgeService]?: EpgRuntimeBridgeService[K]; +}; + describe('EpgSourceStatusComponent', () => { let fixture: ComponentFixture; let component: EpgSourceStatusComponent; - let epgBridge: Partial; + let epgBridge: EpgBridgeStub; const imports = signal([]); beforeEach(async () => { diff --git a/libs/ui/pipes/tsconfig.spec.json b/libs/ui/pipes/tsconfig.spec.json index 7f31bb031..71dd0db03 100644 --- a/libs/ui/pipes/tsconfig.spec.json +++ b/libs/ui/pipes/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.captions.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.captions.spec.ts index 0e769e077..467facfe7 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.captions.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.captions.spec.ts @@ -63,7 +63,8 @@ describe('ArtPlayerSourceSession caption preference without shared controls', () const { session, player, video } = startHls(() => false); video.dispatchEvent(new Event('playing')); - session.customType['m3u8']?.( + session.customType['m3u8']?.call( + player, video, 'https://example.test/second.m3u8', player @@ -90,7 +91,8 @@ describe('ArtPlayerSourceSession caption preference without shared controls', () showCaptions, }); session.attach(player); - session.customType['m3u8']?.( + session.customType['m3u8']?.call( + player, video, 'https://example.test/live.m3u8', player diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts index 102282f24..8f71024cf 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts @@ -40,7 +40,8 @@ describe('ArtPlayerSourceSession DASH (mpd custom type)', () => { }); session.attach(player); - session.customType['mpd']?.( + session.customType['mpd']?.call( + player, video, 'https://example.test/live.mpd', player @@ -68,13 +69,15 @@ describe('ArtPlayerSourceSession DASH (mpd custom type)', () => { }); session.attach(player); - session.customType['mpd']?.( + session.customType['mpd']?.call( + player, video, 'https://example.test/live.mpd', player ); await flushShakaMicrotasks(); - session.customType['m3u8']?.( + session.customType['m3u8']?.call( + player, video, 'https://example.test/live.m3u8', player @@ -115,7 +118,8 @@ describe('ArtPlayerSourceSession DASH (mpd custom type)', () => { }); session.attach(player); - session.customType['mpd']?.( + session.customType['mpd']?.call( + player, video, 'https://example.test/failing.mpd', player diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts index 4d81991e3..e583b4372 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts @@ -29,7 +29,8 @@ describe('ArtPlayerSourceSession', () => { session.attach(player); detach.mockClear(); - session.customType['m3u8']?.( + session.customType['m3u8']?.call( + player, video, 'https://example.test/live.m3u8', player @@ -77,7 +78,8 @@ describe('ArtPlayerSourceSession', () => { const attach = jest.spyOn(adapter, 'attach'); session.attach(player); - session.customType['m3u8']?.( + session.customType['m3u8']?.call( + player, video, 'https://example.test/live.m3u8', player @@ -105,7 +107,8 @@ describe('ArtPlayerSourceSession', () => { emitPlaybackIssue: (issue) => emitted.push(issue), }); session.attach(player); - session.customType['m3u8']?.( + session.customType['m3u8']?.call( + player, video, 'https://example.test/live.m3u8', player @@ -145,7 +148,8 @@ describe('ArtPlayerSourceSession', () => { emitPlaybackIssue: (issue) => emitted.push(issue), }); session.attach(player); - session.customType['m3u8']?.( + session.customType['m3u8']?.call( + player, video, 'https://example.test/live.m3u8', player @@ -187,7 +191,8 @@ describe('ArtPlayerSourceSession', () => { }); session.attach(player); - session.customType['ts']?.( + session.customType['ts']?.call( + player, video, 'https://example.test/movie.ts', player @@ -218,7 +223,8 @@ describe('ArtPlayerSourceSession', () => { }); session.attach(player); - session.customType['ts']?.( + session.customType['ts']?.call( + player, video, 'https://example.test/movie.ts', player @@ -239,7 +245,8 @@ describe('ArtPlayerSourceSession', () => { }); session.attach(player); - session.customType['m3u8']?.( + session.customType['m3u8']?.call( + player, video, 'https://example.test/live.m3u8', player @@ -254,7 +261,8 @@ describe('ArtPlayerSourceSession', () => { error: new Error('unsupported codec'), }); - session.customType['ts']?.( + session.customType['ts']?.call( + player, video, 'https://example.test/live.ts', player @@ -300,7 +308,8 @@ describe('ArtPlayerSourceSession', () => { }); const secret = 'art-hls-secret-sentinel'; session.attach(player); - session.customType['m3u8']?.( + session.customType['m3u8']?.call( + player, video, 'https://example.test/live.m3u8', player @@ -354,12 +363,14 @@ describe('ArtPlayerSourceSession', () => { session.attach(player); session.destroy(); - session.customType['m3u8']?.( + session.customType['m3u8']?.call( + player, video, 'https://example.test/stale.m3u8', player ); - session.customType['ts']?.( + session.customType['ts']?.call( + player, video, 'https://example.test/stale.ts', player diff --git a/libs/ui/playback/src/lib/art-player/art-player-video-session.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-video-session.spec.ts index da0897083..dcee640f3 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-video-session.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-video-session.spec.ts @@ -128,7 +128,7 @@ describe('ArtPlayerVideoSession', () => { }); function createSession( - player: MockArtplayer, + player: MockArtplayer & Artplayer, persistSharedVolume: boolean ): ArtPlayerVideoSession { return new ArtPlayerVideoSession({ diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts index fcc3f7031..8eda23410 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts @@ -210,8 +210,7 @@ describe('EmbeddedMpvCommandRunner', () => { it('ignores a command reply after the active session is replaced', async () => { let resolveCommand: - | ((value: EmbeddedMpvSession | null) => void) - | null = null; + ((value: EmbeddedMpvSession | null) => void) | undefined; electron.seekEmbeddedMpv.mockImplementationOnce( () => new Promise((resolve) => { @@ -239,8 +238,7 @@ describe('EmbeddedMpvCommandRunner', () => { 'keeps a newer same-session snapshot when a %s command reply settles', async (_, replyUpdatedAt) => { let resolveCommand: - | ((value: EmbeddedMpvSession | null) => void) - | null = null; + ((value: EmbeddedMpvSession | null) => void) | undefined; electron.startEmbeddedMpvRecording.mockImplementationOnce( () => new Promise((resolve) => { @@ -302,7 +300,7 @@ describe('EmbeddedMpvCommandRunner', () => { }); it('does not start recording after the session changes during folder lookup', async () => { - let resolveFolder: ((folder: string) => void) | null = null; + let resolveFolder: ((folder: string) => void) | undefined; electron.getEmbeddedMpvDefaultRecordingFolder.mockImplementationOnce( () => new Promise((resolve) => { diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.command-order.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.command-order.spec.ts index f9786f23c..ce1aeeb02 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.command-order.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.command-order.spec.ts @@ -239,7 +239,7 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => { const successMessage = operation === 'stop' ? `Saved to ${targetPath}` : null; const failureMessage = `Failed to ${operation} recording`; - const setRecording = (recording: RecordingState) => + const setRecording = (recording: NonNullable) => controller.session.set(session({ recording })); const originalCommand = operation === 'start' diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.dock-panels.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.dock-panels.spec.ts index b7d38ed10..0e9bbca0f 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.dock-panels.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.dock-panels.spec.ts @@ -25,7 +25,7 @@ class DockPanelsHostComponent { contentInfo: { playlistId: 'playlist-1', contentXtreamId: 42, - contentType: 'movie', + contentType: 'vod', }, }; } @@ -284,20 +284,13 @@ describe('EmbeddedMpvPlayerComponent dock panels', () => { }); it('blocks seek and volume arrow shortcuts while a chip panel is open', () => { - const seekBy = jest - .spyOn(controller, 'seekBy') - .mockResolvedValue(true); + const seekBy = jest.spyOn(controller, 'seekBy').mockResolvedValue(true); const volumeBefore = player.volume(); query('[data-embedded-mpv-menu-button="audio"]').nativeElement.click(); fixture.detectChanges(); - for (const key of [ - 'ArrowLeft', - 'ArrowRight', - 'ArrowUp', - 'ArrowDown', - ]) { + for (const key of ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) { document.dispatchEvent( new KeyboardEvent('keydown', { key, diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts index 40e965406..b189b5737 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-controls.spec.ts @@ -18,7 +18,7 @@ const PLAYBACK: ResolvedPortalPlayback = { contentInfo: { playlistId: 'playlist-1', contentXtreamId: 42, - contentType: 'movie', + contentType: 'vod', }, }; @@ -251,9 +251,7 @@ describe('EmbeddedMpvPlayerComponent shared controls host', () => { fullscreenElement = target; document.dispatchEvent(new Event('fullscreenchange')); }); - (target as HTMLElement & { requestFullscreen: jest.Mock })[ - 'requestFullscreen' - ] = requestFullscreen; + target.requestFullscreen = requestFullscreen; const exitFullscreenDescriptor = Object.getOwnPropertyDescriptor( document, 'exitFullscreen' diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-interactions.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-interactions.spec.ts index eb02c9517..2540b1c61 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-interactions.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.shared-interactions.spec.ts @@ -16,7 +16,7 @@ const PLAYBACK: ResolvedPortalPlayback = { contentInfo: { playlistId: 'playlist-1', contentXtreamId: 42, - contentType: 'movie', + contentType: 'vod', }, }; diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts index c6ed2d88a..2a3ee9e6a 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts @@ -182,8 +182,7 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => { }); it('disposes a session whose creation resolves only after teardown', async () => { - let resolveCreate: ((session: EmbeddedMpvSession) => void) | null = - null; + let resolveCreate: ((session: EmbeddedMpvSession) => void) | undefined; electron.createEmbeddedMpvSession.mockImplementationOnce( () => new Promise((resolve) => { @@ -198,7 +197,7 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => { 0.5 ); await waitFor( - () => resolveCreate !== null, + () => resolveCreate !== undefined, 'startup to reach createEmbeddedMpvSession' ); teardown(); @@ -218,7 +217,7 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => { it('does not continue frame setup when teardown happens during playback load', async () => { const frameCopySupport = createSupport('frame-copy'); - let resolveLoad: (() => void) | null = null; + let resolveLoad: (() => void) | undefined; electron.getEmbeddedMpvSupport.mockResolvedValueOnce(frameCopySupport); electron.prepareEmbeddedMpv.mockResolvedValueOnce(frameCopySupport); electron.loadEmbeddedMpvPlayback.mockImplementationOnce( @@ -258,7 +257,7 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => { it('does not schedule bounds after teardown during frame view attachment', async () => { const frameCopySupport = createSupport('frame-copy'); - let resolveAttach: ((attached: boolean) => void) | null = null; + let resolveAttach: ((attached: boolean) => void) | undefined; electron.getEmbeddedMpvSupport.mockResolvedValueOnce(frameCopySupport); electron.prepareEmbeddedMpv.mockResolvedValueOnce(frameCopySupport); electron.attachEmbeddedMpvFrameView.mockImplementationOnce( diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts index a678d7374..7ef5883c7 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts @@ -206,7 +206,7 @@ describe('EmbeddedMpvSessionController', () => { }); it('ignores a late startup rejection after teardown so it cannot clobber a newer session', async () => { - let rejectPrepare: ((error: Error) => void) | null = null; + let rejectPrepare: ((error: Error) => void) | undefined; electron.prepareEmbeddedMpv.mockImplementationOnce( () => new Promise((_resolve, reject) => { @@ -221,7 +221,7 @@ describe('EmbeddedMpvSessionController', () => { 0.5 ); await waitFor( - () => rejectPrepare !== null, + () => rejectPrepare !== undefined, 'startup to reach prepareEmbeddedMpv' ); teardown(); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.spec-fixtures.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.spec-fixtures.ts index e161c9b46..525192a4d 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.spec-fixtures.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.spec-fixtures.ts @@ -1,4 +1,3 @@ -import { jest } from '@jest/globals'; import Hls from 'hls.js'; import { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter'; import { HtmlVideoPlayerControlsBridge } from './html-video-player-controls.bridge'; diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index 436c7629d..ad116f7eb 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -368,6 +368,10 @@ describe('HtmlVideoPlayerComponent', () => { if (issue) issues.push(issue); }); const secret = 'html-hls-secret-sentinel'; + const networkDetails = new XMLHttpRequest(); + Object.defineProperty(networkDetails, 'responseText', { + value: secret, + }); ( component as unknown as { @@ -385,7 +389,7 @@ describe('HtmlVideoPlayerComponent', () => { text: secret, data: { body: secret }, }, - networkDetails: { responseText: secret }, + networkDetails, }); expect(issues[0]).toEqual( diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts index 630fd7f0e..7116e8c09 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts @@ -2,7 +2,11 @@ import { DEFAULT_PLAYER_CAPABILITIES, createEmptyControlsState, } from './player-controls-defaults'; -import { ControlsMenuState } from './controls-menu-state'; +import { ControlsMenu, ControlsMenuState } from './controls-menu-state'; +import type { + PlayerControlsCapabilities, + PlayerControlsState, +} from './player-controls.model'; describe('ControlsMenuState', () => { it('keeps only one menu open at a time', () => { @@ -94,7 +98,13 @@ describe('ControlsMenuState', () => { expect(menus.anyOpen()).toBe(false); }); - it.each([ + it.each< + [ + ControlsMenu, + Partial, + Partial, + ] + >([ ['volume', { volume: false }, {}], ['audio', {}, { audioTracks: [] }], ['subtitle', {}, { subtitleTracks: [] }], @@ -102,7 +112,7 @@ describe('ControlsMenuState', () => { ['speed', { playbackSpeed: false }, {}], ['aspect', { aspectRatio: false }, {}], ['stats', { streamStats: false }, {}], - ] as const)( + ])( 'maps runtime controller state to %s menu availability', (menu, capabilityOverrides, stateOverrides) => { const menus = new ControlsMenuState(); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts index cccbd8b1b..7b0aab109 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts @@ -30,6 +30,7 @@ function createFakeController() { addExternalSubtitleFile: jest.fn(), setSubtitleDelay: jest.fn(), setSubtitleStyle: jest.fn(), + setQualityLevel: jest.fn(), setPlaybackSpeed: jest.fn(), setAspectRatio: jest.fn(), toggleRecording: jest.fn(), @@ -211,9 +212,7 @@ describe('PlayerControlsComponent subtitle settings', () => { }); ( - style.querySelector( - '[aria-label="Yellow"]' - ) as HTMLButtonElement + style.querySelector('[aria-label="Yellow"]') as HTMLButtonElement ).click(); expect(fake.commands.setSubtitleStyle).toHaveBeenLastCalledWith({ sizePercent: 100, diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts index f5a18032d..1c9553d9f 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts @@ -278,9 +278,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => { fullscreenElement = target; document.dispatchEvent(new Event('fullscreenchange')); }); - (target as HTMLElement & { requestFullscreen: jest.Mock })[ - 'requestFullscreen' - ] = requestTargetFullscreen; + target.requestFullscreen = requestTargetFullscreen; fixture.componentRef.setInput('fullscreenTarget', target); fixture.detectChanges(); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts index cfd0ee8e3..f34f6ecb7 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts @@ -145,11 +145,6 @@ describe('PortalInlinePlayerComponent', () => { title: 'Header-bearing live stream', isLive: true, headers: { Authorization: 'Bearer token' }, - contentInfo: { - playlistId: 'playlist-1', - contentXtreamId: 42, - contentType: 'live', - }, }; const request: PlaybackFallbackRequest = { player: 'mpv', @@ -158,11 +153,13 @@ describe('PortalInlinePlayerComponent', () => { code: 'network-error', player: 'videojs', source: 'hls', + sourceUrl: playback.streamUrl, container: '', mimeType: '', videoCodecs: [], audioCodecs: [], }, + trackLaunch: jest.fn(), }; let forwarded: PlaybackFallbackRequest | undefined; component.externalFallbackRequested.subscribe( diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.captions.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.captions.spec.ts index 3d955f59d..b85330080 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.captions.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.captions.spec.ts @@ -233,7 +233,7 @@ function createHarness() { const harness = { tracks, currentVideo: document.createElement('video'), - ready: () => undefined, + ready: (): void => undefined, emit(event: string) { for (const listener of listeners.get(event) ?? []) { listener(); diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.reset.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.reset.spec.ts index 2f5051436..215b4857e 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.reset.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.reset.spec.ts @@ -378,7 +378,7 @@ function createVideoJsPlayerHarness() { currentVideo: document.createElement('video'), paused: true, pauseCompletesImmediately: true, - ready: () => undefined, + ready: (): void => undefined, mpegTsPlayers: [] as ReturnType[], pause: jest.fn(() => { if (harness.pauseCompletesImmediately) { diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.shared-controls.spec.ts index 379fa5c04..8d9b9b70b 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.shared-controls.spec.ts @@ -255,7 +255,7 @@ function createVideoJsPlayerHarness() { currentVideo: document.createElement('video'), paused: true, pauseCompletesImmediately: true, - ready: () => undefined, + ready: (): void => undefined, mpegTsPlayers: [] as ReturnType[], pause: jest.fn(() => { if (harness.pauseCompletesImmediately) { diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts index cd840fd6f..9d38fc2f1 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts @@ -445,7 +445,7 @@ function createPlayerHarness() { vhsActive: false, paused: true, pauseCompletesImmediately: true, - ready: () => undefined, + ready: (): void => undefined, play: jest.fn(() => Promise.resolve()), pause: jest.fn(() => { if (harness.pauseCompletesImmediately) { diff --git a/libs/ui/remote-control/tsconfig.spec.json b/libs/ui/remote-control/tsconfig.spec.json index 7b3e56bde..6f7650a0a 100644 --- a/libs/ui/remote-control/tsconfig.spec.json +++ b/libs/ui/remote-control/tsconfig.spec.json @@ -2,12 +2,12 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index e6a2bf911..51dc5212c 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -593,11 +593,17 @@ describe('DashboardDataService', () => { { _id: 'xtream-1', title: 'Xtream One', + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, serverUrl: 'https://one.example.com', }, { _id: 'xtream-2', title: 'Xtream Two', + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, serverUrl: 'https://two.example.com', }, ]); @@ -661,11 +667,17 @@ describe('DashboardDataService', () => { { _id: 'xtream-1', title: 'Xtream Playlist', + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, serverUrl: 'https://xtream.example.com', }, { _id: 'stalker-1', title: 'Stalker Playlist', + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, serverUrl: 'https://stalker.example.com', macAddress: '00:11:22:33:44:55', }, @@ -778,6 +790,7 @@ describe('DashboardDataService', () => { playlist_id: 'xtream-1', playlist_name: 'Xtream Playlist', source: 'xtream', + added_at: '2026-01-01T00:00:00.000Z', } satisfies DashboardFavoriteItem; await service.removeGlobalFavorite(item); @@ -1570,11 +1583,17 @@ describe('DashboardDataService', () => { { _id: 'xtream-1', title: 'Xtream One', + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, serverUrl: 'https://one.example.com', }, { _id: 'xtream-2', title: 'Xtream Two', + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, serverUrl: 'https://two.example.com', }, ]); @@ -1608,11 +1627,17 @@ describe('DashboardDataService', () => { { _id: 'xtream-1', title: 'Xtream Playlist', + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, serverUrl: 'https://xtream.example.com', }, { _id: 'stalker-1', title: 'Stalker Playlist', + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, serverUrl: 'https://stalker.example.com', macAddress: '00:11:22:33:44:55', }, diff --git a/libs/workspace/dashboard/data-access/tsconfig.spec.json b/libs/workspace/dashboard/data-access/tsconfig.spec.json index 4bee0750e..9e8c0aaf0 100644 --- a/libs/workspace/dashboard/data-access/tsconfig.spec.json +++ b/libs/workspace/dashboard/data-access/tsconfig.spec.json @@ -3,13 +3,13 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", - "module": "commonjs", - "moduleResolution": "node10", + "module": "preserve", + "moduleResolution": "bundler", "types": ["jest", "node"] }, + "files": ["src/test-setup.ts", "../../../../global.d.ts"], "include": [ "jest.config.ts", - "src/test-setup.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts" diff --git a/libs/workspace/dashboard/feature/tsconfig.spec.json b/libs/workspace/dashboard/feature/tsconfig.spec.json index 4bee0750e..9e8c0aaf0 100644 --- a/libs/workspace/dashboard/feature/tsconfig.spec.json +++ b/libs/workspace/dashboard/feature/tsconfig.spec.json @@ -3,13 +3,13 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", - "module": "commonjs", - "moduleResolution": "node10", + "module": "preserve", + "moduleResolution": "bundler", "types": ["jest", "node"] }, + "files": ["src/test-setup.ts", "../../../../global.d.ts"], "include": [ "jest.config.ts", - "src/test-setup.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts" diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts index 3da7f0131..0dbb06f36 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts @@ -254,8 +254,9 @@ export class GlobalFavoritesService { } catch { continue; } + if (!playlist) continue; - const favorites = Array.isArray(playlist?.favorites) + const favorites = Array.isArray(playlist.favorites) ? playlist.favorites.filter(isStalkerFavoriteItem) : []; @@ -271,6 +272,7 @@ export class GlobalFavoritesService { if (!isLive) continue; const streamId = fav.stream_id ?? fav.id; + if (streamId === undefined) continue; results.push({ uid: buildFavoriteUid('stalker', meta._id, streamId), name: diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.spec.ts index 76759d62a..ce24103bd 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.spec.ts @@ -12,8 +12,16 @@ describe('WorkspaceCategoryLockActionService', () => { requestUnlock: jest.fn(async () => true), lockedXtreamIds: jest.fn(() => [7]), lockedStalkerIds: jest.fn(() => ['9']), - setXtreamLocks: jest.fn(async () => true), - setStalkerLocks: jest.fn(async () => true), + setXtreamLocks: jest.fn( + async ( + ..._args: Parameters + ) => true + ), + setStalkerLocks: jest.fn( + async ( + ..._args: Parameters + ) => true + ), }; let service: WorkspaceCategoryLockActionService; diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts index 61269041e..b9d1a58bd 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts @@ -57,7 +57,9 @@ function getCategoryLabels( fixture: ComponentFixture ): string[] { return Array.from( - fixture.nativeElement.querySelectorAll('.category-item .nav-item-label') + (fixture.nativeElement as HTMLElement).querySelectorAll( + '.category-item .nav-item-label' + ) ).map((element: Element) => element.textContent?.trim() ?? ''); } @@ -266,7 +268,9 @@ describe('WorkspaceContextPanelComponent', () => { fixture.detectChanges(); const countTexts = Array.from( - fixture.nativeElement.querySelectorAll('.item-count') + (fixture.nativeElement as HTMLElement).querySelectorAll( + '.item-count' + ) ).map((element: Element) => element.textContent?.trim()); const categoryButtons = Array.from( fixture.nativeElement.querySelectorAll('.category-item') diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts index 5aaeaba65..26baa55d6 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts @@ -1,16 +1,30 @@ import { TestBed } from '@angular/core/testing'; -import { MatDialog } from '@angular/material/dialog'; -import { Subject } from 'rxjs'; +import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; +import { Observable, Subject } from 'rxjs'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { WorkspaceKeyboardShortcutsDialogData } from './workspace-keyboard-shortcuts-dialog.component'; import { WorkspaceKeyboardShortcutsService } from './workspace-keyboard-shortcuts.service'; import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util'; describe('WorkspaceKeyboardShortcutsService', () => { let afterClosed$: Subject; - let dialog: { open: jest.Mock }; + let dialog: { + open: jest.Mock< + { afterClosed: () => Observable }, + [unknown, MatDialogConfig] + >; + }; let runtime: { isElectron: boolean }; let service: WorkspaceKeyboardShortcutsService; + function openedDialogData(): WorkspaceKeyboardShortcutsDialogData { + const data = dialog.open.mock.calls[0][1].data; + if (!data) { + throw new Error('Expected the shortcuts dialog to receive data'); + } + return data; + } + beforeEach(() => { afterClosed$ = new Subject(); dialog = { @@ -309,7 +323,7 @@ describe('WorkspaceKeyboardShortcutsService', () => { service.openShortcutsDialog(); - const dialogData = dialog.open.mock.calls[0][1].data; + const dialogData = openedDialogData(); const commandPaletteShortcut = dialogData.groups .flatMap((group) => group.items) .find((item) => item.id === 'open-command-palette'); @@ -323,7 +337,7 @@ describe('WorkspaceKeyboardShortcutsService', () => { it('includes Electron-only shortcuts when runtime supports Electron', () => { service.openShortcutsDialog(); - const dialogData = dialog.open.mock.calls[0][1].data; + const dialogData = openedDialogData(); const itemIds = dialogData.groups.flatMap((group) => group.items.map((item) => item.id) ); @@ -336,7 +350,7 @@ describe('WorkspaceKeyboardShortcutsService', () => { service.openShortcutsDialog(); - const dialogData = dialog.open.mock.calls[0][1].data; + const dialogData = openedDialogData(); const itemIds = dialogData.groups.flatMap((group) => group.items.map((item) => item.id) ); diff --git a/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts index e3ea56261..6fd6a4a32 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts @@ -2,7 +2,7 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; -import { of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { WorkspaceCommandContribution, WorkspaceViewCommandService, @@ -58,7 +58,7 @@ describe('WorkspacePlayerCommandsContributor', () => { >; } | undefined; - let translate: { instant: jest.Mock; onLangChange: ReturnType }; + let translate: { instant: jest.Mock; onLangChange: Observable }; function bootstrap(options: { supportsManagedExternalPlayers: boolean; diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.spec.ts index 6b42f37fb..4a5f4393e 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.spec.ts @@ -279,7 +279,9 @@ describe('WorkspaceShellContextSidebarComponent', () => { fixture.detectChanges(); liveSidebarService.expand('portal'); fixture.detectChanges(); - await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => + queueMicrotask(() => resolve()) + ); // The panel offered no control (categories not loaded), so the // rail itself takes focus. @@ -296,7 +298,9 @@ describe('WorkspaceShellContextSidebarComponent', () => { fixture.detectChanges(); liveSidebarService.expand('portal'); fixture.detectChanges(); - await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => + queueMicrotask(() => resolve()) + ); expect(document.activeElement).toBe(control); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.spec.ts index 96a014ce2..7bbcdcae2 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.spec.ts @@ -253,7 +253,9 @@ describe('WorkspaceShellHeaderComponent', () => { fixture.detectChanges(); const chips = Array.from( - fixture.nativeElement.querySelectorAll('.search-chip') + (fixture.nativeElement as HTMLElement).querySelectorAll( + '.search-chip' + ) ).map((element: Element) => element.textContent?.trim()); expect(chips).toEqual(['Movies / All Items', 'Loaded channels only']); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index 68d514628..d3e7d55f5 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -4,7 +4,7 @@ import { MatDialog } from '@angular/material/dialog'; import { NavigationEnd, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { PlaylistRefreshActionService, type XtreamRefreshPreparationState, @@ -105,6 +105,13 @@ describe('WorkspaceShellFacade', () => { serverUrl?: string; macAddress?: string; }; + const createPlaylistMeta = (_id: string, title: string): PlaylistMeta => ({ + _id, + title, + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, + }); let facade: WorkspaceShellFacade; let searchSync: WorkspaceShellSearchSyncService; @@ -123,7 +130,7 @@ describe('WorkspaceShellFacade', () => { let navigationTrigger: 'imperative' | 'popstate'; let router: { url: string; - events: ReturnType; + events: Observable; navigate: jest.Mock; navigateByUrl: jest.Mock; parseUrl: jest.Mock; @@ -173,15 +180,29 @@ describe('WorkspaceShellFacade', () => { supportsDownloads: true, }; - activePlaylistSignal = signal({ - _id: 'pl-1', + activePlaylistSignal = signal({ + ...createPlaylistMeta('pl-1', 'Playlist A'), serverUrl: 'http://example.com', - title: 'Playlist A', - recentlyViewed: ['recent-1'], + recentlyViewed: [ + { + source: 'm3u', + id: 'recent-1', + url: 'https://example.com/recent-1.m3u8', + title: 'Recent 1', + category_id: 'live', + added_at: '2026-01-01T00:00:00.000Z', + }, + ], }); - playlistsSignal = signal([ - { _id: 'pl-1', serverUrl: 'http://example.com' }, - { _id: 'pl-2', macAddress: '00:11:22:33' }, + playlistsSignal = signal([ + { + ...createPlaylistMeta('pl-1', 'Playlist A'), + serverUrl: 'http://example.com', + }, + { + ...createPlaylistMeta('pl-2', 'Playlist B'), + macAddress: '00:11:22:33', + }, ]); downloadsActiveCountSignal = signal(0); refreshPreparationSignal = signal( @@ -345,7 +366,7 @@ describe('WorkspaceShellFacade', () => { 'WORKSPACE.SHELL.XTREAM_IMPORT_PROGRESS' && params ) { - return `${params.type} imported: ${params.current} / ${params.total}`; + return `${params['type']} imported: ${params['current']} / ${params['total']}`; } if ( @@ -353,7 +374,7 @@ describe('WorkspaceShellFacade', () => { 'WORKSPACE.SHELL.XTREAM_REFRESH_PROGRESS' && params ) { - return `Local records processed: ${params.current} / ${params.total}`; + return `Local records processed: ${params['current']} / ${params['total']}`; } return key; diff --git a/libs/workspace/shell/feature/tsconfig.spec.json b/libs/workspace/shell/feature/tsconfig.spec.json index 6aa6499b9..e5fb73520 100644 --- a/libs/workspace/shell/feature/tsconfig.spec.json +++ b/libs/workspace/shell/feature/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/workspace/shell/util/tsconfig.spec.json b/libs/workspace/shell/util/tsconfig.spec.json index 6aa6499b9..e5fb73520 100644 --- a/libs/workspace/shell/util/tsconfig.spec.json +++ b/libs/workspace/shell/util/tsconfig.spec.json @@ -3,12 +3,12 @@ "compilerOptions": { "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/package.json b/package.json index 4a5da016e..c5c2fb931 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,8 @@ "typecheck:web": "tsc -p apps/web/tsconfig.app.json --noEmit", "typecheck:backend": "tsc -p apps/electron-backend/tsconfig.app.json --noEmit", "typecheck:ci": "pnpm run typecheck:web && pnpm run typecheck:backend", + "typecheck:spec": "node tools/typecheck/spec-typecheck.mjs", + "typecheck:spec:test": "node --test tools/typecheck/spec-typecheck.test.mjs", "verify:package-layout": "node tools/packaging/verify-electron-package-layout.mjs", "embedded-mpv:build-native:homebrew": "IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 nx run electron-backend:build-embedded-mpv", "embedded-mpv:build-runtime": "node tools/embedded-mpv/build-macos-runtime.mjs", diff --git a/tools/typecheck/spec-typecheck.mjs b/tools/typecheck/spec-typecheck.mjs new file mode 100644 index 000000000..e720607f4 --- /dev/null +++ b/tools/typecheck/spec-typecheck.mjs @@ -0,0 +1,328 @@ +#!/usr/bin/env node + +/** + * Type-checks every Jest spec program in the workspace. + * + * ts-jest runs with `isolatedModules`, so it transpiles each file on its own + * and never reports type errors: a spec that no longer matches the code it + * exercises still passes as long as it runs. This gate runs `tsc --noEmit` + * over every `tsconfig.spec.json` (the same configs Jest hands to ts-jest) + * with a small concurrency pool and fails when any program reports an error. + * + * Usage: node tools/typecheck/spec-typecheck.mjs [--concurrency=N] [filter...] + * A positional filter keeps only configs whose path contains it. Run + * `pnpm run typecheck:spec` from the workspace root. + */ +import { spawn } from 'node:child_process'; +import { readdirSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const SPEC_TSCONFIG = 'tsconfig.spec.json'; +const SEARCH_ROOTS = ['apps', 'libs', 'tools']; +const SKIPPED_DIRECTORIES = new Set([ + 'node_modules', + 'dist', + 'coverage', + 'tmp', + '.nx', + '.angular', +]); +const TSC_ERROR = /(?:^|\s)error TS\d+:/; + +/** + * Finds every `tsconfig.spec.json` under the search roots, sorted so the + * output and the pool order stay stable between runs. + */ +export function discoverSpecTsconfigs(workspaceRoot, roots = SEARCH_ROOTS) { + const found = []; + + function walk(directory) { + let entries; + try { + entries = readdirSync(directory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORIES.has(entry.name)) { + walk(path.join(directory, entry.name)); + } + } else if (entry.isFile() && entry.name === SPEC_TSCONFIG) { + found.push( + path + .relative( + workspaceRoot, + path.join(directory, entry.name) + ) + .split(path.sep) + .join('/') + ); + } + } + } + + for (const root of roots) { + walk(path.join(workspaceRoot, root)); + } + + return found.sort(); +} + +/** Keeps the configs whose path contains at least one of the filters. */ +export function filterConfigs(configs, filters) { + if (filters.length === 0) return configs; + return configs.filter((config) => + filters.some((filter) => config.includes(filter)) + ); +} + +/** + * Splits `tsc --pretty false` output into error lines and the rest. Continuation + * lines (indented detail of a multi-line diagnostic) stay with the errors so + * the printed block is complete, but only lines carrying a TS code count. + */ +export function parseTscOutput(output) { + const lines = output.split(/\r?\n/); + const errors = []; + let errorCount = 0; + let inError = false; + + for (const line of lines) { + if (TSC_ERROR.test(line)) { + errorCount += 1; + inError = true; + errors.push(line); + } else if (inError && /^\s+\S/.test(line)) { + errors.push(line); + } else { + inError = false; + } + } + + return { errorCount, errors }; +} + +/** + * Concurrency defaults to the core count minus one, capped at four: each tsc + * process is single-threaded but allocates hundreds of megabytes for the + * Angular type graph, so more slots mostly trade memory for no wall-clock gain. + */ +export function resolveConcurrency({ requested, cpuCount }) { + if (Number.isInteger(requested) && requested > 0) return requested; + return Math.max(1, Math.min(4, cpuCount - 1)); +} + +/** + * Runs every task with at most `concurrency` in flight and resolves with all + * results in task order. Unlike the coverage runner this does not fail fast: + * a type-check gate is only useful when it reports every failing program. + */ +export async function runAll(tasks, { concurrency, onSettled }) { + const results = new Array(tasks.length); + let nextIndex = 0; + + async function worker() { + while (nextIndex < tasks.length) { + const index = nextIndex++; + const task = tasks[index]; + const startedAt = Date.now(); + let outcome; + try { + outcome = await task.run(); + } catch (error) { + outcome = { errorCount: 1, errors: [String(error)], status: 1 }; + } + const settled = { + name: task.name, + ...outcome, + durationMs: Date.now() - startedAt, + }; + results[index] = settled; + onSettled?.(settled); + } + } + + const workers = []; + for (let slot = 0; slot < Math.max(1, concurrency); slot += 1) { + workers.push(worker()); + } + await Promise.all(workers); + + return results; +} + +export function formatDuration(ms) { + const seconds = Math.round(ms / 1000); + const minutes = Math.floor(seconds / 60); + return minutes > 0 + ? `${minutes}m ${String(seconds % 60).padStart(2, '0')}s` + : `${seconds}s`; +} + +/** Summary table: failing programs first, then the slowest ones. */ +export function formatSummary(results, totalMs) { + const failed = results.filter((result) => result.errorCount > 0); + const lines = []; + const sorted = [...results].sort( + (a, b) => + b.errorCount - a.errorCount || + b.durationMs - a.durationMs || + a.name.localeCompare(b.name) + ); + const width = Math.max(...results.map((result) => result.name.length), 8); + lines.push(`${'Program'.padEnd(width)} Errors Duration`); + for (const result of sorted) { + lines.push( + `${result.name.padEnd(width)} ${String(result.errorCount).padStart(6)} ${formatDuration(result.durationMs)}` + ); + } + const totalErrors = failed.reduce( + (sum, result) => sum + result.errorCount, + 0 + ); + lines.push(''); + lines.push( + failed.length === 0 + ? `All ${results.length} spec programs type-check (${formatDuration(totalMs)}).` + : `${failed.length} of ${results.length} spec programs failed with ${totalErrors} error(s) (${formatDuration(totalMs)}).` + ); + return lines.join('\n'); +} + +function parsePositiveInteger(raw, what) { + if (!/^\d+$/.test(raw.trim()) || Number.parseInt(raw, 10) < 1) { + throw new Error( + `${what} expects a positive integer, received "${raw}".` + ); + } + return Number.parseInt(raw, 10); +} + +export function parseArgs(argv, env) { + let concurrency; + const filters = []; + for (const argument of argv) { + if (argument.startsWith('--concurrency=')) { + concurrency = parsePositiveInteger( + argument.slice('--concurrency='.length), + '--concurrency=' + ); + } else if (argument.startsWith('--')) { + throw new Error(`Unknown option "${argument}".`); + } else { + filters.push(argument); + } + } + const fromEnv = env.SPEC_TYPECHECK_CONCURRENCY; + if (concurrency === undefined && fromEnv !== undefined && fromEnv !== '') { + concurrency = parsePositiveInteger( + fromEnv, + 'SPEC_TYPECHECK_CONCURRENCY' + ); + } + return { concurrency, filters }; +} + +function runTsc(workspaceRoot, config) { + return new Promise((resolve, reject) => { + const tscBin = path.join( + workspaceRoot, + 'node_modules', + 'typescript', + 'bin', + 'tsc' + ); + const child = spawn( + process.execPath, + [tscBin, '-p', config, '--noEmit', '--pretty', 'false'], + { cwd: workspaceRoot, stdio: ['ignore', 'pipe', 'pipe'] } + ); + let output = ''; + child.stdout.on('data', (chunk) => { + output += chunk; + }); + child.stderr.on('data', (chunk) => { + output += chunk; + }); + child.once('error', reject); + child.once('close', (status, signal) => { + const parsed = parseTscOutput(output); + if (parsed.errorCount === 0 && (status !== 0 || signal)) { + parsed.errorCount = 1; + parsed.errors = [ + `tsc exited with ${signal ?? `status ${status}`} without reporting a diagnostic:`, + output.trim(), + ]; + } + resolve({ ...parsed, status: status ?? 1 }); + }); + }); +} + +export async function main(argv = process.argv.slice(2), env = process.env) { + const workspaceRoot = process.cwd(); + const { concurrency: requested, filters } = parseArgs(argv, env); + const configs = filterConfigs( + discoverSpecTsconfigs(workspaceRoot), + filters + ); + if (configs.length === 0) { + console.error('No tsconfig.spec.json matched.'); + return 1; + } + + const concurrency = resolveConcurrency({ + requested, + cpuCount: os.availableParallelism?.() ?? os.cpus().length, + }); + console.log( + `Type-checking ${configs.length} spec program(s), ${concurrency} at a time.` + ); + + const startedAt = Date.now(); + const results = await runAll( + configs.map((config) => ({ + name: config, + run: () => runTsc(workspaceRoot, config), + })), + { + concurrency, + onSettled(result) { + if (result.errorCount === 0) { + console.log( + `ok ${result.name} (${formatDuration(result.durationMs)})` + ); + return; + } + console.log( + `FAIL ${result.name}: ${result.errorCount} error(s) (${formatDuration(result.durationMs)})` + ); + for (const line of result.errors) { + console.log(` ${line}`); + } + }, + } + ); + + console.log(''); + console.log(formatSummary(results, Date.now() - startedAt)); + return results.some((result) => result.errorCount > 0) ? 1 : 0; +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main() + .then((status) => { + process.exitCode = status; + }) + .catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/tools/typecheck/spec-typecheck.test.mjs b/tools/typecheck/spec-typecheck.test.mjs new file mode 100644 index 000000000..87107189a --- /dev/null +++ b/tools/typecheck/spec-typecheck.test.mjs @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { after, before, test } from 'node:test'; + +import { + discoverSpecTsconfigs, + filterConfigs, + formatSummary, + parseArgs, + parseTscOutput, + resolveConcurrency, + runAll, +} from './spec-typecheck.mjs'; + +let workDir; +before(async () => { + workDir = await mkdtemp(path.join(os.tmpdir(), 'spec-typecheck-')); +}); +after(async () => { + await rm(workDir, { recursive: true, force: true }); +}); + +test('discovers spec tsconfigs under the search roots and skips build output', async () => { + const dirs = [ + 'apps/web', + 'libs/a/b', + 'libs/a/node_modules/dep', + 'libs/dist/thing', + 'tools/x', + 'other/y', + ]; + for (const dir of dirs) { + await mkdir(path.join(workDir, dir), { recursive: true }); + await writeFile(path.join(workDir, dir, 'tsconfig.spec.json'), '{}'); + } + await writeFile(path.join(workDir, 'libs/a/b/tsconfig.json'), '{}'); + + assert.deepEqual(discoverSpecTsconfigs(workDir), [ + 'apps/web/tsconfig.spec.json', + 'libs/a/b/tsconfig.spec.json', + 'tools/x/tsconfig.spec.json', + ]); + assert.deepEqual(discoverSpecTsconfigs(workDir, ['missing']), []); +}); + +test('filters configs by path substring', () => { + const configs = [ + 'apps/web/tsconfig.spec.json', + 'libs/epg/tsconfig.spec.json', + ]; + assert.deepEqual(filterConfigs(configs, []), configs); + assert.deepEqual(filterConfigs(configs, ['epg']), [ + 'libs/epg/tsconfig.spec.json', + ]); + assert.deepEqual(filterConfigs(configs, ['nope']), []); +}); + +test('counts tsc diagnostics and keeps their continuation lines', () => { + const output = [ + 'libs/x/a.spec.ts(3,5): error TS2322: Type A is not assignable to type B.', + " Property 'c' is missing in type 'A'.", + 'libs/x/b.spec.ts(9,1): error TS2304: Cannot find name Foo.', + 'error TS6059: File is not under rootDir.', + '', + ].join('\n'); + const parsed = parseTscOutput(output); + assert.equal(parsed.errorCount, 3); + assert.equal(parsed.errors.length, 4); + assert.deepEqual(parseTscOutput(''), { errorCount: 0, errors: [] }); +}); + +test('caps the default concurrency and honours an explicit request', () => { + assert.equal(resolveConcurrency({ cpuCount: 2 }), 1); + assert.equal(resolveConcurrency({ cpuCount: 12 }), 4); + assert.equal(resolveConcurrency({ requested: 7, cpuCount: 2 }), 7); +}); + +test('parses concurrency from flags or the environment and rejects junk', () => { + assert.deepEqual(parseArgs(['--concurrency=2', 'epg'], {}), { + concurrency: 2, + filters: ['epg'], + }); + assert.deepEqual(parseArgs([], { SPEC_TYPECHECK_CONCURRENCY: '3' }), { + concurrency: 3, + filters: [], + }); + assert.throws( + () => parseArgs(['--concurrency=zero'], {}), + /positive integer/ + ); + assert.throws(() => parseArgs(['--verbose'], {}), /Unknown option/); +}); + +test('runs every task even after a failure and keeps task order', async () => { + const order = []; + const tasks = ['a', 'b', 'c', 'd'].map((name, index) => ({ + name, + async run() { + order.push(name); + await new Promise((resolve) => + setTimeout(resolve, 5 * (4 - index)) + ); + return { errorCount: name === 'a' ? 2 : 0, errors: [], status: 0 }; + }, + })); + const settled = []; + const results = await runAll(tasks, { + concurrency: 2, + onSettled: (result) => settled.push(result.name), + }); + + assert.deepEqual(order, ['a', 'b', 'c', 'd']); + assert.deepEqual( + results.map((result) => [result.name, result.errorCount]), + [ + ['a', 2], + ['b', 0], + ['c', 0], + ['d', 0], + ] + ); + assert.equal(settled.length, 4); + assert.ok(results.every((result) => result.durationMs >= 0)); +}); + +test('turns a thrown task into a failing result', async () => { + const results = await runAll( + [ + { + name: 'boom', + run: () => Promise.reject(new Error('spawn failed')), + }, + ], + { concurrency: 1 } + ); + assert.equal(results[0].errorCount, 1); + assert.match(results[0].errors[0], /spawn failed/); +}); + +test('summarises failures first and reports the totals', () => { + const summary = formatSummary( + [ + { + name: 'apps/web/tsconfig.spec.json', + errorCount: 0, + durationMs: 12_000, + }, + { + name: 'libs/epg/tsconfig.spec.json', + errorCount: 3, + durationMs: 4_000, + }, + ], + 70_000 + ); + const lines = summary.split('\n'); + assert.match(lines[1], /^libs\/epg\/tsconfig\.spec\.json\s+3\s+4s$/); + assert.match(lines[2], /^apps\/web\/tsconfig\.spec\.json\s+0\s+12s$/); + assert.match( + summary, + /1 of 2 spec programs failed with 3 error\(s\) \(1m 10s\)/ + ); + + const clean = formatSummary( + [{ name: 'a', errorCount: 0, durationMs: 100 }], + 100 + ); + assert.match(clean, /All 1 spec programs type-check \(0s\)/); +});