mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
158 files changed
+1517
-632
No files matched your search
@@ -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 = {
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
Vendored
+12
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user