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 -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",
@@ -21,10 +21,10 @@ describe('EpgGuideKeyboardController', () => {
beforeEach(() => {
host = {
rowCount: jest.fn(() => 5),
blockCount: jest.fn(() => 3),
blockCount: jest.fn((_row: number) => 3),
activeRow: jest.fn(() => 2),
isBlocked: jest.fn(() => false),
isOwnedTarget: jest.fn(() => true),
isOwnedTarget: jest.fn((_target: EventTarget | null) => true),
play: jest.fn(),
details: jest.fn(),
jumpNow: jest.fn(),
@@ -5,7 +5,11 @@ import { By } from '@angular/platform-browser';
import { SettingsStore } from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { of, Subject } from 'rxjs';
import { Observable, of, Subject } from 'rxjs';
import type {
EpgItemDialogAction,
EpgItemDialogData,
} from '../epg-item-description/epg-item-description.component';
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
import { EpgGuideComponent } from './epg-guide.component';
import {
@@ -65,7 +69,11 @@ describe('EpgGuideComponent', () => {
const searchHits = signal<EpgGuideSearchHit[]>([]);
const activate = jest.fn();
const setScope = jest.fn();
const dialogOpen = jest.fn(() => of(undefined));
const dialogOpen = jest.fn(
(
_data: EpgItemDialogData
): Observable<EpgItemDialogAction | undefined> => of(undefined)
);
beforeEach(() => {
localStorage.clear();
@@ -7,10 +7,15 @@ import {
} from '@iptvnator/epg/data-access';
import { EpgSourceStatusComponent } from './epg-source-status.component';
/** Writable double for the bridge's read-only capability getters. */
type EpgBridgeStub = {
-readonly [K in keyof EpgRuntimeBridgeService]?: EpgRuntimeBridgeService[K];
};
describe('EpgSourceStatusComponent', () => {
let fixture: ComponentFixture<EpgSourceStatusComponent>;
let component: EpgSourceStatusComponent;
let epgBridge: Partial<EpgRuntimeBridgeService>;
let epgBridge: EpgBridgeStub;
const imports = signal([]);
beforeEach(async () => {
+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",
@@ -63,7 +63,8 @@ describe('ArtPlayerSourceSession caption preference without shared controls', ()
const { session, player, video } = startHls(() => false);
video.dispatchEvent(new Event('playing'));
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/second.m3u8',
player
@@ -90,7 +91,8 @@ describe('ArtPlayerSourceSession caption preference without shared controls', ()
showCaptions,
});
session.attach(player);
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/live.m3u8',
player
@@ -40,7 +40,8 @@ describe('ArtPlayerSourceSession DASH (mpd custom type)', () => {
});
session.attach(player);
session.customType['mpd']?.(
session.customType['mpd']?.call(
player,
video,
'https://example.test/live.mpd',
player
@@ -68,13 +69,15 @@ describe('ArtPlayerSourceSession DASH (mpd custom type)', () => {
});
session.attach(player);
session.customType['mpd']?.(
session.customType['mpd']?.call(
player,
video,
'https://example.test/live.mpd',
player
);
await flushShakaMicrotasks();
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/live.m3u8',
player
@@ -115,7 +118,8 @@ describe('ArtPlayerSourceSession DASH (mpd custom type)', () => {
});
session.attach(player);
session.customType['mpd']?.(
session.customType['mpd']?.call(
player,
video,
'https://example.test/failing.mpd',
player
@@ -29,7 +29,8 @@ describe('ArtPlayerSourceSession', () => {
session.attach(player);
detach.mockClear();
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/live.m3u8',
player
@@ -77,7 +78,8 @@ describe('ArtPlayerSourceSession', () => {
const attach = jest.spyOn(adapter, 'attach');
session.attach(player);
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/live.m3u8',
player
@@ -105,7 +107,8 @@ describe('ArtPlayerSourceSession', () => {
emitPlaybackIssue: (issue) => emitted.push(issue),
});
session.attach(player);
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/live.m3u8',
player
@@ -145,7 +148,8 @@ describe('ArtPlayerSourceSession', () => {
emitPlaybackIssue: (issue) => emitted.push(issue),
});
session.attach(player);
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/live.m3u8',
player
@@ -187,7 +191,8 @@ describe('ArtPlayerSourceSession', () => {
});
session.attach(player);
session.customType['ts']?.(
session.customType['ts']?.call(
player,
video,
'https://example.test/movie.ts',
player
@@ -218,7 +223,8 @@ describe('ArtPlayerSourceSession', () => {
});
session.attach(player);
session.customType['ts']?.(
session.customType['ts']?.call(
player,
video,
'https://example.test/movie.ts',
player
@@ -239,7 +245,8 @@ describe('ArtPlayerSourceSession', () => {
});
session.attach(player);
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/live.m3u8',
player
@@ -254,7 +261,8 @@ describe('ArtPlayerSourceSession', () => {
error: new Error('unsupported codec'),
});
session.customType['ts']?.(
session.customType['ts']?.call(
player,
video,
'https://example.test/live.ts',
player
@@ -300,7 +308,8 @@ describe('ArtPlayerSourceSession', () => {
});
const secret = 'art-hls-secret-sentinel';
session.attach(player);
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/live.m3u8',
player
@@ -354,12 +363,14 @@ describe('ArtPlayerSourceSession', () => {
session.attach(player);
session.destroy();
session.customType['m3u8']?.(
session.customType['m3u8']?.call(
player,
video,
'https://example.test/stale.m3u8',
player
);
session.customType['ts']?.(
session.customType['ts']?.call(
player,
video,
'https://example.test/stale.ts',
player
@@ -128,7 +128,7 @@ describe('ArtPlayerVideoSession', () => {
});
function createSession(
player: MockArtplayer,
player: MockArtplayer & Artplayer,
persistSharedVolume: boolean
): ArtPlayerVideoSession {
return new ArtPlayerVideoSession({
@@ -210,8 +210,7 @@ describe('EmbeddedMpvCommandRunner', () => {
it('ignores a command reply after the active session is replaced', async () => {
let resolveCommand:
| ((value: EmbeddedMpvSession | null) => void)
| null = null;
((value: EmbeddedMpvSession | null) => void) | undefined;
electron.seekEmbeddedMpv.mockImplementationOnce(
() =>
new Promise<EmbeddedMpvSession | null>((resolve) => {
@@ -239,8 +238,7 @@ describe('EmbeddedMpvCommandRunner', () => {
'keeps a newer same-session snapshot when a %s command reply settles',
async (_, replyUpdatedAt) => {
let resolveCommand:
| ((value: EmbeddedMpvSession | null) => void)
| null = null;
((value: EmbeddedMpvSession | null) => void) | undefined;
electron.startEmbeddedMpvRecording.mockImplementationOnce(
() =>
new Promise<EmbeddedMpvSession | null>((resolve) => {
@@ -302,7 +300,7 @@ describe('EmbeddedMpvCommandRunner', () => {
});
it('does not start recording after the session changes during folder lookup', async () => {
let resolveFolder: ((folder: string) => void) | null = null;
let resolveFolder: ((folder: string) => void) | undefined;
electron.getEmbeddedMpvDefaultRecordingFolder.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
@@ -239,7 +239,7 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => {
const successMessage =
operation === 'stop' ? `Saved to ${targetPath}` : null;
const failureMessage = `Failed to ${operation} recording`;
const setRecording = (recording: RecordingState) =>
const setRecording = (recording: NonNullable<RecordingState>) =>
controller.session.set(session({ recording }));
const originalCommand =
operation === 'start'
@@ -25,7 +25,7 @@ class DockPanelsHostComponent {
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'movie',
contentType: 'vod',
},
};
}
@@ -284,20 +284,13 @@ describe('EmbeddedMpvPlayerComponent dock panels', () => {
});
it('blocks seek and volume arrow shortcuts while a chip panel is open', () => {
const seekBy = jest
.spyOn(controller, 'seekBy')
.mockResolvedValue(true);
const seekBy = jest.spyOn(controller, 'seekBy').mockResolvedValue(true);
const volumeBefore = player.volume();
query('[data-embedded-mpv-menu-button="audio"]').nativeElement.click();
fixture.detectChanges();
for (const key of [
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
]) {
for (const key of ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) {
document.dispatchEvent(
new KeyboardEvent('keydown', {
key,
@@ -18,7 +18,7 @@ const PLAYBACK: ResolvedPortalPlayback = {
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'movie',
contentType: 'vod',
},
};
@@ -251,9 +251,7 @@ describe('EmbeddedMpvPlayerComponent shared controls host', () => {
fullscreenElement = target;
document.dispatchEvent(new Event('fullscreenchange'));
});
(target as HTMLElement & { requestFullscreen: jest.Mock })[
'requestFullscreen'
] = requestFullscreen;
target.requestFullscreen = requestFullscreen;
const exitFullscreenDescriptor = Object.getOwnPropertyDescriptor(
document,
'exitFullscreen'
@@ -16,7 +16,7 @@ const PLAYBACK: ResolvedPortalPlayback = {
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'movie',
contentType: 'vod',
},
};
@@ -182,8 +182,7 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
});
it('disposes a session whose creation resolves only after teardown', async () => {
let resolveCreate: ((session: EmbeddedMpvSession) => void) | null =
null;
let resolveCreate: ((session: EmbeddedMpvSession) => void) | undefined;
electron.createEmbeddedMpvSession.mockImplementationOnce(
() =>
new Promise((resolve) => {
@@ -198,7 +197,7 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
0.5
);
await waitFor(
() => resolveCreate !== null,
() => resolveCreate !== undefined,
'startup to reach createEmbeddedMpvSession'
);
teardown();
@@ -218,7 +217,7 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
it('does not continue frame setup when teardown happens during playback load', async () => {
const frameCopySupport = createSupport('frame-copy');
let resolveLoad: (() => void) | null = null;
let resolveLoad: (() => void) | undefined;
electron.getEmbeddedMpvSupport.mockResolvedValueOnce(frameCopySupport);
electron.prepareEmbeddedMpv.mockResolvedValueOnce(frameCopySupport);
electron.loadEmbeddedMpvPlayback.mockImplementationOnce(
@@ -258,7 +257,7 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
it('does not schedule bounds after teardown during frame view attachment', async () => {
const frameCopySupport = createSupport('frame-copy');
let resolveAttach: ((attached: boolean) => void) | null = null;
let resolveAttach: ((attached: boolean) => void) | undefined;
electron.getEmbeddedMpvSupport.mockResolvedValueOnce(frameCopySupport);
electron.prepareEmbeddedMpv.mockResolvedValueOnce(frameCopySupport);
electron.attachEmbeddedMpvFrameView.mockImplementationOnce(
@@ -206,7 +206,7 @@ describe('EmbeddedMpvSessionController', () => {
});
it('ignores a late startup rejection after teardown so it cannot clobber a newer session', async () => {
let rejectPrepare: ((error: Error) => void) | null = null;
let rejectPrepare: ((error: Error) => void) | undefined;
electron.prepareEmbeddedMpv.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
@@ -221,7 +221,7 @@ describe('EmbeddedMpvSessionController', () => {
0.5
);
await waitFor(
() => rejectPrepare !== null,
() => rejectPrepare !== undefined,
'startup to reach prepareEmbeddedMpv'
);
teardown();
@@ -1,4 +1,3 @@
import { jest } from '@jest/globals';
import Hls from 'hls.js';
import { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter';
import { HtmlVideoPlayerControlsBridge } from './html-video-player-controls.bridge';
@@ -368,6 +368,10 @@ describe('HtmlVideoPlayerComponent', () => {
if (issue) issues.push(issue);
});
const secret = 'html-hls-secret-sentinel';
const networkDetails = new XMLHttpRequest();
Object.defineProperty(networkDetails, 'responseText', {
value: secret,
});
(
component as unknown as {
@@ -385,7 +389,7 @@ describe('HtmlVideoPlayerComponent', () => {
text: secret,
data: { body: secret },
},
networkDetails: { responseText: secret },
networkDetails,
});
expect(issues[0]).toEqual(
@@ -2,7 +2,11 @@ import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import { ControlsMenuState } from './controls-menu-state';
import { ControlsMenu, ControlsMenuState } from './controls-menu-state';
import type {
PlayerControlsCapabilities,
PlayerControlsState,
} from './player-controls.model';
describe('ControlsMenuState', () => {
it('keeps only one menu open at a time', () => {
@@ -94,7 +98,13 @@ describe('ControlsMenuState', () => {
expect(menus.anyOpen()).toBe(false);
});
it.each([
it.each<
[
ControlsMenu,
Partial<PlayerControlsCapabilities>,
Partial<PlayerControlsState>,
]
>([
['volume', { volume: false }, {}],
['audio', {}, { audioTracks: [] }],
['subtitle', {}, { subtitleTracks: [] }],
@@ -102,7 +112,7 @@ describe('ControlsMenuState', () => {
['speed', { playbackSpeed: false }, {}],
['aspect', { aspectRatio: false }, {}],
['stats', { streamStats: false }, {}],
] as const)(
])(
'maps runtime controller state to %s menu availability',
(menu, capabilityOverrides, stateOverrides) => {
const menus = new ControlsMenuState();
@@ -30,6 +30,7 @@ function createFakeController() {
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
@@ -211,9 +212,7 @@ describe('PlayerControlsComponent subtitle settings', () => {
});
(
style.querySelector(
'[aria-label="Yellow"]'
) as HTMLButtonElement
style.querySelector('[aria-label="Yellow"]') as HTMLButtonElement
).click();
expect(fake.commands.setSubtitleStyle).toHaveBeenLastCalledWith({
sizePercent: 100,
@@ -278,9 +278,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => {
fullscreenElement = target;
document.dispatchEvent(new Event('fullscreenchange'));
});
(target as HTMLElement & { requestFullscreen: jest.Mock })[
'requestFullscreen'
] = requestTargetFullscreen;
target.requestFullscreen = requestTargetFullscreen;
fixture.componentRef.setInput('fullscreenTarget', target);
fixture.detectChanges();
@@ -145,11 +145,6 @@ describe('PortalInlinePlayerComponent', () => {
title: 'Header-bearing live stream',
isLive: true,
headers: { Authorization: 'Bearer token' },
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'live',
},
};
const request: PlaybackFallbackRequest = {
player: 'mpv',
@@ -158,11 +153,13 @@ describe('PortalInlinePlayerComponent', () => {
code: 'network-error',
player: 'videojs',
source: 'hls',
sourceUrl: playback.streamUrl,
container: '',
mimeType: '',
videoCodecs: [],
audioCodecs: [],
},
trackLaunch: jest.fn(),
};
let forwarded: PlaybackFallbackRequest | undefined;
component.externalFallbackRequested.subscribe(
@@ -233,7 +233,7 @@ function createHarness() {
const harness = {
tracks,
currentVideo: document.createElement('video'),
ready: () => undefined,
ready: (): void => undefined,
emit(event: string) {
for (const listener of listeners.get(event) ?? []) {
listener();
@@ -378,7 +378,7 @@ function createVideoJsPlayerHarness() {
currentVideo: document.createElement('video'),
paused: true,
pauseCompletesImmediately: true,
ready: () => undefined,
ready: (): void => undefined,
mpegTsPlayers: [] as ReturnType<typeof createMpegTsPlayer>[],
pause: jest.fn(() => {
if (harness.pauseCompletesImmediately) {
@@ -255,7 +255,7 @@ function createVideoJsPlayerHarness() {
currentVideo: document.createElement('video'),
paused: true,
pauseCompletesImmediately: true,
ready: () => undefined,
ready: (): void => undefined,
mpegTsPlayers: [] as ReturnType<typeof createMpegTsPlayer>[],
pause: jest.fn(() => {
if (harness.pauseCompletesImmediately) {
@@ -445,7 +445,7 @@ function createPlayerHarness() {
vhsActive: false,
paused: true,
pauseCompletesImmediately: true,
ready: () => undefined,
ready: (): void => undefined,
play: jest.fn(() => Promise.resolve()),
pause: jest.fn(() => {
if (harness.pauseCompletesImmediately) {
+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",