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\)/); +});