ci(test): type-check Jest spec programs and gate it in CI (#1705)

* build(test): make spec tsconfigs resolve what Jest resolves

Lib spec tsconfigs used module: commonjs with node10 resolution, which cannot
see Angular's exports-only secondary entry points, and dropped global.d.ts, so
tsc reported thousands of resolution errors and no window.electron typing.
Switch them to module: preserve with bundler resolution (ts-jest still forces
CommonJS emit outside ESM mode), add global.d.ts to every spec program, type
jest.unstable_mockModule for the ESM workspace, include the ui-epg and
ui-playback specs that jest.web-esm.workspace.ts runs under the web spec
config, and drop the snack-bar stub that shadowed the real Material types.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci(test): gate spec type-checking with typecheck:spec

Add tools/typecheck/spec-typecheck.mjs, which runs tsc --noEmit over every
tsconfig.spec.json with a small pool and fails on any diagnostic, wire it into
the unit-and-typecheck job after typecheck:ci, and document the gate and the
spec tsconfig conventions in the validation map. Also bring the non-Tier-A
spec configs (remote-control-web, ui-remote-control, stalker-mock-server) to
the same conventions so the gate covers the whole workspace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: fix the spec type errors surfaced by typecheck:spec

With the spec programs resolving modules and ambient typings correctly,
tsc reported 432 genuine errors across the Tier A projects: read-only
capability flags assigned on Partial<> doubles, signal-store values used as
types, fixtures missing required fields, index-signature property access,
partial bridge doubles cast through incompatible shapes, and deferred
resolvers narrowed to never. Type the doubles instead of casting to any:
writable mapped types for capability flags, InstanceType<typeof StalkerStore>,
typed jest.fn signatures, protectedState: false on test signal stores, and
completed fixtures. Production changes are limited to bracket access for
index-signature properties under the libs' noPropertyAccessFromIndexSignature
setting and two narrowing guards in the global favorites loader.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(playback): use the ESM setup's jest global in the controls fixtures

The fixture imported jest from @jest/globals, which is not a direct
dependency. Jest provides that module at runtime, so tests passed, but on a
clean pnpm install tsc cannot resolve it and typecheck:spec failed in CI.
The ESM test setup already installs import.meta.jest as the global, typed
by @types/jest, as the other ESM specs use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: type the parental lock doubles merged since the gate was written

The parental lock feature (#1601) and the Stalker actor route landed on master
with spec doubles declared as zero-argument jest.fn()s that the tests then
drive with the real arguments, plus a copy of the ResizableDirective override
imported from a library that does not export it. Give the doubles the lock
service's real signatures, drop the dead override as in the sibling layout
specs, use bracket access for the actor route's personId param, and keep the
Stalker layout spec within the 1200-line limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-09-27 20:54:27 +02:00
1 parent 69056487bc
commit 650da4a1d3
158 files changed
+1517 -632

No files matched your search

+3
View File
@@ -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
+3 -3
View File
@@ -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",
@@ -1,6 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
+6 -1
View File
@@ -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<RuntimeCapabilitiesService>;
let epgBridge: Partial<EpgRuntimeBridgeService>;
let epgBridge: EpgBridgeStub;
beforeEach(waitForAsync(() => {
runtimeCapabilities = {
+4 -8
View File
@@ -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(
@@ -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 () => {
@@ -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();
@@ -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<string | null>('xtream-1'),
selectedCategoryId: signal<number | null>(null),
selectedItem: signal<{ category_id?: number } | null>(null),
reloadCategories: jest.fn(async () => undefined),
reloadCategories: jest.fn(
async (_shouldPublish?: () => boolean): Promise<void> => undefined
),
reloadCachedContent: jest.fn(async () => undefined),
refreshSearchResults: jest.fn(async () => undefined),
withholdCatalog: jest.fn(),
@@ -1,7 +1,7 @@
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
type ElectronWindow = Window & {
electron?: { setPlaybackKeepAwake?: jest.Mock };
type ElectronWindow = {
electron?: Pick<typeof window.electron, 'setPlaybackKeepAwake'>;
};
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) => {
@@ -78,7 +78,8 @@ describe('Service: Settings', () => {
));
describe('Test theme switch', () => {
let spyOnAdd, spyOnRemove;
let spyOnAdd: jest.SpyInstance<void, [...tokens: string[]]>;
let spyOnRemove: jest.SpyInstance<void, [...tokens: string[]]>;
beforeEach(() => {
spyOnAdd = jest.spyOn(document.body.classList, 'add');
spyOnRemove = jest.spyOn(document.body.classList, 'remove');
@@ -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',
@@ -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(
@@ -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<EpgRuntimeBridgeService>;
let epgBridge: EpgBridgeStub;
let epgService: EpgService;
let dialogService: DialogService;
let snackBar: MatSnackBarStub;
@@ -173,7 +173,9 @@ describe('SettingsPlaybackSectionComponent', () => {
fixture.componentRef.setInput('form', form);
fixture.detectChanges();
const checkbox = fixture.nativeElement.querySelector<HTMLInputElement>(
const checkbox = (
fixture.nativeElement as HTMLElement
).querySelector<HTMLInputElement>(
'[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<HTMLInputElement>(
const checkbox = (
fixture.nativeElement as HTMLElement
).querySelector<HTMLInputElement>(
'[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<HTMLTextAreaElement>(
'#mpvPlayerArguments'
)?.placeholder
(
fixture.nativeElement as HTMLElement
).querySelector<HTMLTextAreaElement>('#mpvPlayerArguments')
?.placeholder
).toBe(MPV_ARGUMENTS_PLACEHOLDER);
});
@@ -369,9 +374,10 @@ describe('SettingsPlaybackSectionComponent', () => {
'SETTINGS.VLC_PLAYER_ARGUMENTS_LABEL'
);
expect(
fixture.nativeElement.querySelector<HTMLTextAreaElement>(
'#vlcPlayerArguments'
)?.placeholder
(
fixture.nativeElement as HTMLElement
).querySelector<HTMLTextAreaElement>('#vlcPlayerArguments')
?.placeholder
).toBe(VLC_ARGUMENTS_PLACEHOLDER);
});
@@ -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<string, number>) =>
(key: string | string[], params?: Record<string, number>) =>
key === 'SETTINGS.REMOVE_ALL_PROGRESS'
? `${params?.current}/${params?.total}`
? `${params?.['current']}/${params?.['total']}`
: key
);
});
@@ -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<boolean>((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<boolean>((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<void>((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<boolean>((resolve) => {
@@ -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'],
@@ -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<SettingsComponent>;
let router: Router;
let mockStore: MockStore;
let epgBridge: Partial<EpgRuntimeBridgeService>;
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');
@@ -109,7 +109,7 @@ export const DEFAULT_SETTINGS = {
vlcReuseInstance: false,
remoteControl: false,
remoteControlPort: 8765,
epgUrl: [],
epgUrl: [] as string[],
recordingFolder: '',
embeddedMpvFrameCopy: false,
embeddedMpvExtraOptions: '',
+12
View File
@@ -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<T = unknown>(
moduleName: string,
moduleFactory: () => T | Promise<T>,
options?: { virtual?: boolean }
): typeof jest;
}
+15 -2
View File
@@ -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"
]
}
+28 -4
View File
@@ -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<T>`,
`InstanceType<typeof SomeStore>` 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:
+1 -1
View File
@@ -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) |
@@ -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<T> = { -readonly [K in keyof T]?: T[K] };
describe('EpgProgressService', () => {
let epgBridge: Partial<EpgRuntimeBridgeService>;
let epgBridge: WritablePartial<EpgRuntimeBridgeService>;
let sources: { waitForReconciliation: jest.Mock };
let settingsStore: {
getSettings: jest.Mock;
@@ -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<T> = { -readonly [K in keyof T]?: T[K] };
describe('EpgRuntimeBridgeService', () => {
let service: EpgRuntimeBridgeService;
let runtimeCapabilities: Partial<RuntimeCapabilitiesService>;
let runtimeCapabilities: WritablePartial<RuntimeCapabilitiesService>;
const originalElectron = window.electron;
beforeEach(() => {
@@ -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<T> = { -readonly [K in keyof T]?: T[K] };
describe('EpgService', () => {
let service: EpgService;
let epgBridge: Partial<EpgRuntimeBridgeService>;
let epgBridge: WritablePartial<EpgRuntimeBridgeService>;
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<typeof window.electron> 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<string, unknown>) => void
(programs: Record<string, EpgProgram | null>) => 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<string, unknown>) => void) | undefined;
((programs: Record<string, EpgProgram | null>) => 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,
},
});
+3 -3
View File
@@ -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",
@@ -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,
},
})
);
@@ -158,7 +158,7 @@ describe('HLS playback evidence', () => {
networkDetails: {
responseURL: `https://provider.example/xhr?token=${secret}`,
responseText: secret,
},
} satisfies Partial<XMLHttpRequest> as XMLHttpRequest,
})
);
const serialized = JSON.stringify(evidence);
@@ -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<string, jest.Mock>)[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',
@@ -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",
@@ -11,7 +11,10 @@ import { M3uEpgGuideSourceService } from './m3u-epg-guide-source.service';
function makeChannel(
id: string,
overrides: Partial<Channel> & { tvgId?: string; group?: string } = {}
overrides: Omit<Partial<Channel>, 'group'> & {
tvgId?: string;
group?: string;
} = {}
): Channel {
return {
id,
@@ -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<string, unknown> | undefined;
return [
'updateRemoteControlStatus',
'onChannelChange',
'onRemoteControlCommand',
].every(
const bridge:
Partial<typeof window.electron> | 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();
@@ -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);
});
@@ -29,7 +29,6 @@ function createPlaylist(
overrides: Partial<PlaylistMeta> & { _id: string }
): PlaylistMeta {
return {
_id: overrides._id,
title: overrides.title ?? overrides.filename ?? overrides._id,
filename: overrides.filename,
count: overrides.count ?? 0,
@@ -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<typeof window.electron> as typeof window.electron;
createComponent();
await component.exportPlaylist();
@@ -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);
});
+3 -3
View File
@@ -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",
@@ -22,11 +22,11 @@ function createPlaylist(
overrides: Partial<PlaylistMeta> & { _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',
},
});
});
});
+3 -3
View File
@@ -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",
@@ -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",
@@ -381,10 +381,10 @@ describe('DownloadLibraryNavigationService', () => {
);
});
it.each([
it.each<readonly [string, Partial<StalkerPortalItem>]>([
['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));
@@ -510,9 +510,9 @@ describe('DownloadQueueComponent', () => {
expect(row(3).querySelector('.download-queue__progress')).toBeNull();
expect(
Array.from(
fixture.nativeElement.querySelectorAll<HTMLElement>(
'.download-queue__progress'
)
(
fixture.nativeElement as HTMLElement
).querySelectorAll<HTMLElement>('.download-queue__progress')
).map((bar) => bar.getAttribute('aria-label'))
).toEqual([
'Download progress for Download 1',
@@ -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<HTMLElement>(
const attention = (
fixture.nativeElement as HTMLElement
).querySelector<HTMLElement>(
'[data-test-id="downloads-attention-section"]'
);
const recover = attention?.querySelector<HTMLButtonElement>(
@@ -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<typeof signal<ReadonlySet<number>>>;
readonly run: jest.Mock<Promise<DownloadActionResult>, [unknown]>;
readonly run: jest.Mock<
Promise<DownloadActionResult>,
[DownloadItemAction]
>;
readonly showActionError: jest.Mock<void, []>;
}
@@ -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",
@@ -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<Playlist>)
} 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<Playlist>)
} 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<Playlist>);
} satisfies Playlist);
});
xtreamApi.getFullEpg.mockResolvedValue([
listing('Really on air', -75, 60),
@@ -33,7 +33,11 @@ describe('StreamResolverService', () => {
ensureToken: jest.Mock;
makeAuthenticatedRequest: jest.Mock;
};
let epgBridge: Partial<EpgRuntimeBridgeService>;
let epgBridge: {
-readonly [
K in keyof EpgRuntimeBridgeService
]?: EpgRuntimeBridgeService[K];
};
beforeEach(() => {
playlistsService = {
@@ -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<string, string | undefined | null>
).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,
@@ -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,
},
@@ -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<M3uRecentlyViewedItem>({
expect.objectContaining<Partial<M3uRecentlyViewedItem>>({
source: 'm3u',
url: 'https://example.com/1.m3u8',
channel_id: 'channel-1',
@@ -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`,
});
@@ -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",
@@ -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;
@@ -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<typeof convertToParamMap>;
queryParamMap: ReturnType<typeof convertToParamMap>;
@@ -596,6 +596,7 @@ describe('UnifiedLiveTabComponent', () => {
code: 'network-error',
player: 'videojs',
source: 'hls',
sourceUrl: 'https://example.com/xtream.m3u8',
container: '',
mimeType: '',
videoCodecs: [],
+3 -3
View File
@@ -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",
@@ -33,7 +33,7 @@ describe('LivePanelsController', () => {
async function settle(): Promise<void> {
TestBed.flushEffects();
await new Promise((resolve) => queueMicrotask(resolve));
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
}
beforeEach(() => {
@@ -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',
};
@@ -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) => ({
+3 -3
View File
@@ -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",
@@ -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> = {}): PlaylistMeta {
url: '',
importDate: '',
filePath: '',
updateDate: '',
updateState: '',
updateDate: 0,
updateState: PlaylistUpdateState.NOT_UPDATED,
position: 0,
autoRefresh: false,
favorites: [],
@@ -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<void> {
for (let i = 0; i < 4; i += 1) {
@@ -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',
})
@@ -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', () => {
@@ -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<typeof StalkerStore>;
describe('StalkerStore API compatibility smoke', () => {
let store: StalkerStore;
let store: InstanceType<typeof StalkerStore>;
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',
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -136,7 +142,10 @@ function createItvCacheMock(
describe('withStalkerContent failure states', () => {
let store: InstanceType<typeof TestContentStore>;
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
sendIpcEvent: jest.Mock<
Promise<unknown>,
[event: string, request: StalkerIpcRequest]
>;
};
let parentalLock: {
active: jest.Mock<boolean, []>;
@@ -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<ReturnType<typeof createContentResponse>>();
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<typeof TestContentStore>;
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
sendIpcEvent: jest.Mock<
Promise<unknown>,
[event: string, request: StalkerIpcRequest]
>;
};
let itvCache: ReturnType<typeof createItvCacheMock>;
@@ -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);
@@ -179,7 +179,7 @@ describe('withStalkerFavorites', () => {
playlist: {
_id: 'portal-1',
favorites: [],
} as PlaylistMeta,
} as Partial<PlaylistMeta> as PlaylistMeta,
})
);
expect(snackBar.open).toHaveBeenCalledWith(
@@ -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<ElectronBridgeApi>,
configurable: true,
});
@@ -138,5 +138,4 @@ describe('withStalkerPortal', () => {
expect(dbGetPlaylist).not.toHaveBeenCalled();
expect(dbCreatePlaylist).not.toHaveBeenCalled();
});
});
@@ -174,7 +174,7 @@ describe('withStalkerRecent', () => {
playlist: {
_id: 'portal-1',
recentlyViewed: [],
} as PlaylistMeta,
} as Partial<PlaylistMeta> as PlaylistMeta,
})
);
expect(onComplete).toHaveBeenCalled();
@@ -30,6 +30,7 @@ const PLAYLIST = {
} as PlaylistMeta;
const TestSeriesStore = signalStore(
{ protectedState: false },
withState({
currentPlaylist: undefined as PlaylistMeta | undefined,
}),
@@ -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",
@@ -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<ActorProfile | null>(null);
@@ -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<ResolvedPortalPlayback>((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<ResolvedPortalPlayback>((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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -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'
);
@@ -18,21 +18,21 @@ const channel = (
describe('StalkerLiveAutoOpen', () => {
let autoOpen: StalkerLiveAutoOpen;
let events: Subject<unknown>;
const selectedCategoryId = signal<string | null>(null);
const searchPhrase = signal('');
const store = {
currentPlaylist: signal<{ _id?: string } | null>({ _id: 'pl-3' }),
selectedContentType: signal<string | null>('itv'),
selectedCategoryId: signal<string | null>(null),
selectedCategoryId,
itvChannelsCategory: signal<string | null>(null),
searchPhrase: signal(''),
searchPhrase,
itvFullChannelList: signal<StalkerItvChannel[]>([]),
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(),
};
@@ -22,25 +22,26 @@ describe('StalkerLiveNavigation', () => {
const third = channel('3', 'sports');
const rows = signal([first, second]);
const loading = signal(false);
const selectedCategoryId = signal<string | null>('news');
const searchPhrase = signal('');
const page = signal(0);
const store = {
currentPlaylist: signal({ _id: 'source-a' }),
selectedContentType: signal('itv'),
selectedCategoryId: signal<string | null>('news'),
searchPhrase: signal(''),
selectedCategoryId,
searchPhrase,
selectedItem: signal<StalkerItvChannel | null>(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',
@@ -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<ElectronBridgeApi>): void {
window.electron = bridge as ElectronBridgeApi;
}
describe('StalkerLiveStreamLayoutComponent', () => {
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
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;
@@ -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();
@@ -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<ElectronBridgeApi>): void {
window.electron = bridge as ElectronBridgeApi;
}
describe('StalkerLiveStreamLayoutComponent remote status', () => {
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
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<string, unknown> | undefined;
return [
'updateRemoteControlStatus',
'onChannelChange',
'onRemoteControlCommand',
].every(
const bridge:
Partial<ElectronBridgeApi> | undefined =
window.electron;
return (
[
'updateRemoteControlStatus',
'onChannelChange',
'onRemoteControlCommand',
] as const
).every(
(method) =>
typeof bridge?.[method] === 'function'
);
@@ -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<number>(200);
readonly maxWidth = input<number>(600);
readonly defaultWidth = input<number>(400);
readonly storageKey = input<string>('');
}
@@ -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),
@@ -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',
@@ -12,10 +12,12 @@ import {
} from './stalker-series-resume';
function episode(
overrides: Partial<StalkerMappedEpisode> & { id: number }
overrides: Partial<Omit<StalkerMappedEpisode, 'id'>> & { 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;
}
@@ -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',
},
});
@@ -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<T> {
@@ -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<ResolvedPortalPlayback>();
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<ResolvedPortalPlayback>();
const newer = createDeferred<ResolvedPortalPlayback>();
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);
@@ -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",
@@ -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<Record<string, unknown>>;
)) as XtreamContentItem[];
const vod = (await dataSource.getContent(
'playlist-1',
credentials,
'movie'
)) as Array<Record<string, unknown>>;
)) as XtreamContentItem[];
const series = (await dataSource.getContent(
'playlist-1',
credentials,
'series'
)) as Array<Record<string, unknown>>;
)) as XtreamContentItem[];
expect(live[0]).toEqual(
expect.objectContaining({
@@ -481,7 +484,7 @@ describe('PwaXtreamDataSource', () => {
'playlist-1',
credentials,
'movie'
)) as Array<Record<string, unknown>>;
)) as XtreamContentItem[];
expect(content).toEqual([
expect.objectContaining({
@@ -606,7 +609,7 @@ describe('PwaXtreamDataSource', () => {
'playlist-1',
credentials,
'movie'
)) as Array<Record<string, unknown>>;
)) as XtreamContentItem[];
expect(content[0]).toEqual(
expect.objectContaining({
@@ -22,7 +22,7 @@ describe('EpgQueueService invalidation', () => {
type ServicePrivates = {
fetchEpg: (
credentials: typeof credentials,
portalCredentials: typeof credentials,
streamId: number
) => Promise<void>;
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({}),
},
},
{
@@ -25,7 +25,7 @@ describe('EpgQueueService', () => {
type ServicePrivates = {
fetchEpg: (
credentials: typeof credentials,
portalCredentials: typeof credentials,
streamId: number
) => Promise<void>;
shouldFetch: (streamId: number) => boolean;
@@ -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',
@@ -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(() => {
@@ -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);
@@ -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',
});
@@ -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
);
@@ -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",
@@ -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',
},
});
@@ -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<ActorProfile | null>(null);
@@ -1,7 +1,6 @@
import {
ChangeDetectionStrategy,
Component,
Directive,
input,
output,
signal,
@@ -101,12 +100,6 @@ export class StubEpgTimelineComponent {
readonly collapsedChange = output<boolean>();
}
@Directive({
selector: '[appResizable]',
standalone: true,
})
export class StubResizableDirective {}
export const sampleChannel = {
xtream_id: 101,
name: 'Channel 101',
@@ -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<unknown[], []>(() => [
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;
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more