mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
Takes Angular Material off the renderer's startup path: renderer.initialBytes 1,596,201 -> 1,344,067 bytes (-252,134, 15.8%) at the branch point; merged with master, where #1928 lands the form-field defaults per component, the branch measures 1,345,807 (-188,797, 12.3% against master's baseline). The bundler keeps each module in one chunk, so one eager import of any export carries the module's whole code; each item removes the last eager importer of one Material module. - EPG progress and update panels: eager host + `@defer (when visible)` view with an `@error` notice; the startup spinner is static markup copied from mat-spinner. - LazyMatDialog: the confirm dialog service, player service, parental lock prompt and shell actions resolve MatDialog on first use. - The theme emits tokens for the 21 imported components only, through a `$material-components` list and meta.apply; tools/nx/check-material-theme-scope.mjs (CI) keeps it in step with the imports in both directions. - No provideAnimations(): Material 22 animates with CSS and reads prefers-reduced-motion itself; apps/electron-backend-e2e/src/material-motion.e2e.ts proves dialogs, selects, menus, snack-bars and tooltips animate in and out and stop under reduced motion. Its renderer had deferred every DOM removal to the end of the tick, so the catalog list now tracks the grid offset from scroll events and the player settings panel decides "focus was inside" in a microtask, both with specs that detach the view first. - The snack-bar stays, and with it button, the icon-button chunk and the CDK overlay (about 190 KB): root services inject it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
249 lines
8.6 KiB
TypeScript
249 lines
8.6 KiB
TypeScript
import {
|
|
ComponentFixture,
|
|
DeferBlockBehavior,
|
|
DeferBlockState,
|
|
TestBed,
|
|
} from '@angular/core/testing';
|
|
import { MatDialog } from '@angular/material/dialog';
|
|
import { By } from '@angular/platform-browser';
|
|
import { TranslateModule } from '@ngx-translate/core';
|
|
import {
|
|
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
|
ElectronBridgeAppUpdateStatus,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import { AppUpdateNotificationCardComponent } from './app-update-notification-card.component';
|
|
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
|
|
import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component';
|
|
|
|
const availableStatus: ElectronBridgeAppUpdateStatus = {
|
|
currentVersion: '0.22.0',
|
|
latestVersion: '0.23.0',
|
|
manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest',
|
|
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
|
|
supportedSelfUpdate: true,
|
|
channel: 'stable',
|
|
installedChannel: 'stable',
|
|
};
|
|
|
|
describe('AppUpdateNotificationPanelComponent', () => {
|
|
let fixture: ComponentFixture<AppUpdateNotificationPanelComponent>;
|
|
const originalElectron = window.electron;
|
|
let statusHandler: ((status: ElectronBridgeAppUpdateStatus) => void) | null;
|
|
|
|
beforeEach(async () => {
|
|
statusHandler = null;
|
|
window.electron = {
|
|
downloadAppUpdate: jest.fn().mockResolvedValue({
|
|
...availableStatus,
|
|
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading,
|
|
}),
|
|
getAppUpdateStatus: jest.fn().mockResolvedValue({
|
|
...availableStatus,
|
|
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
|
|
}),
|
|
onAppUpdateStatusChange: jest.fn((handler) => {
|
|
statusHandler = handler;
|
|
return jest.fn();
|
|
}),
|
|
} as unknown as typeof window.electron;
|
|
|
|
await TestBed.configureTestingModule({
|
|
imports: [
|
|
AppUpdateNotificationPanelComponent,
|
|
TranslateModule.forRoot(),
|
|
],
|
|
providers: [
|
|
{
|
|
provide: MatDialog,
|
|
useValue: { open: jest.fn() },
|
|
},
|
|
],
|
|
}).compileComponents();
|
|
|
|
fixture = TestBed.createComponent(AppUpdateNotificationPanelComponent);
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
});
|
|
|
|
afterEach(() => {
|
|
window.electron = originalElectron;
|
|
});
|
|
|
|
/**
|
|
* Pushes a status and lets the deferred card render: its chunk resolves
|
|
* asynchronously, after which the panel needs one more check.
|
|
*/
|
|
async function showUpdate(
|
|
status: ElectronBridgeAppUpdateStatus
|
|
): Promise<void> {
|
|
statusHandler?.(status);
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
await new Promise((resolve) => setTimeout(resolve));
|
|
fixture.detectChanges();
|
|
}
|
|
|
|
function card(): AppUpdateNotificationCardComponent {
|
|
return fixture.debugElement.query(
|
|
By.directive(AppUpdateNotificationCardComponent)
|
|
).componentInstance as AppUpdateNotificationCardComponent;
|
|
}
|
|
|
|
it('renders no card before the main process reports an update', () => {
|
|
expect(
|
|
fixture.nativeElement.querySelector('app-update-notification-card')
|
|
).toBeNull();
|
|
});
|
|
|
|
it('shows a startup update notification when the main process reports an available update', async () => {
|
|
await showUpdate(availableStatus);
|
|
|
|
expect(
|
|
fixture.nativeElement.querySelector(
|
|
'[data-test-id="app-update-notification"]'
|
|
)
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it('hides the notification when it is dismissed', async () => {
|
|
await showUpdate(availableStatus);
|
|
|
|
(
|
|
fixture.nativeElement.querySelector(
|
|
'.app-update-notification__header button'
|
|
) as HTMLButtonElement
|
|
).click();
|
|
fixture.detectChanges();
|
|
|
|
expect(fixture.componentInstance.dismissedVersion()).toBe('0.23.0');
|
|
expect(
|
|
fixture.nativeElement.querySelector(
|
|
'[data-test-id="app-update-notification"]'
|
|
)
|
|
).toBeNull();
|
|
});
|
|
|
|
it('opens release notes without dismissing the notification', async () => {
|
|
await showUpdate(availableStatus);
|
|
|
|
(
|
|
fixture.nativeElement.querySelector(
|
|
'[data-test-id="app-update-notification-release-notes"]'
|
|
) as HTMLButtonElement
|
|
).click();
|
|
// The dialog component is imported on demand (it pulls in `marked`).
|
|
await fixture.whenStable();
|
|
await new Promise((resolve) => setTimeout(resolve));
|
|
|
|
expect(TestBed.inject(MatDialog).open).toHaveBeenCalledWith(
|
|
AppUpdateReleaseNotesDialogComponent,
|
|
expect.objectContaining({
|
|
data: { initialVersion: '0.23.0' },
|
|
})
|
|
);
|
|
expect(
|
|
fixture.nativeElement.querySelector(
|
|
'[data-test-id="app-update-notification"]'
|
|
)
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it('falls back to the releases page when the release notes dialog fails to load', async () => {
|
|
const open = jest.spyOn(window, 'open').mockReturnValue(null);
|
|
const error = jest
|
|
.spyOn(console, 'error')
|
|
.mockImplementation(() => undefined);
|
|
await showUpdate(availableStatus);
|
|
const notification = card();
|
|
notification.loadReleaseNotesDialog = () =>
|
|
Promise.reject(new Error('chunk failed'));
|
|
|
|
await notification.openReleaseNotes();
|
|
|
|
expect(TestBed.inject(MatDialog).open).not.toHaveBeenCalled();
|
|
expect(open).toHaveBeenCalledWith(
|
|
availableStatus.manualDownloadUrl,
|
|
'_blank',
|
|
'noreferrer'
|
|
);
|
|
expect(error).toHaveBeenCalledTimes(1);
|
|
open.mockRestore();
|
|
error.mockRestore();
|
|
});
|
|
|
|
it('starts downloading the update from the notification action', async () => {
|
|
await showUpdate(availableStatus);
|
|
|
|
(
|
|
fixture.nativeElement.querySelector(
|
|
'[data-test-id="app-update-notification-download"]'
|
|
) as HTMLButtonElement
|
|
).click();
|
|
await fixture.whenStable();
|
|
|
|
expect(window.electron.downloadAppUpdate).toHaveBeenCalledTimes(1);
|
|
// The card reports the updater's reply back to the panel.
|
|
expect(fixture.componentInstance.status()?.status).toBe(
|
|
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading
|
|
);
|
|
});
|
|
|
|
it('tells the user when the card chunk cannot be loaded', async () => {
|
|
fixture.destroy();
|
|
TestBed.resetTestingModule();
|
|
await TestBed.configureTestingModule({
|
|
imports: [
|
|
AppUpdateNotificationPanelComponent,
|
|
TranslateModule.forRoot(),
|
|
],
|
|
providers: [{ provide: MatDialog, useValue: { open: jest.fn() } }],
|
|
deferBlockBehavior: DeferBlockBehavior.Manual,
|
|
}).compileComponents();
|
|
fixture = TestBed.createComponent(AppUpdateNotificationPanelComponent);
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
statusHandler?.(availableStatus);
|
|
fixture.detectChanges();
|
|
|
|
const [block] = await fixture.getDeferBlocks();
|
|
await block.render(DeferBlockState.Error);
|
|
|
|
const notice = fixture.nativeElement.querySelector('[role="alert"]');
|
|
expect(notice?.textContent).toContain(
|
|
'SETTINGS.APP_UPDATE_PANEL_UNAVAILABLE'
|
|
);
|
|
expect(
|
|
fixture.nativeElement.querySelector('app-update-notification-card')
|
|
).toBeNull();
|
|
});
|
|
|
|
it('labels unsupported platform action as a GitHub release link', async () => {
|
|
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
|
|
await showUpdate({
|
|
...availableStatus,
|
|
supportedSelfUpdate: false,
|
|
});
|
|
|
|
const button = fixture.nativeElement.querySelector(
|
|
'[data-test-id="app-update-notification-download"]'
|
|
) as HTMLButtonElement;
|
|
|
|
expect(button.textContent).toContain(
|
|
'SETTINGS.APP_UPDATE_OPEN_RELEASE'
|
|
);
|
|
expect(button.querySelector('mat-icon')?.textContent?.trim()).toBe(
|
|
'open_in_new'
|
|
);
|
|
|
|
button.click();
|
|
|
|
expect(window.electron.downloadAppUpdate).not.toHaveBeenCalled();
|
|
expect(open).toHaveBeenCalledWith(
|
|
availableStatus.manualDownloadUrl,
|
|
'_blank',
|
|
'noreferrer'
|
|
);
|
|
open.mockRestore();
|
|
});
|
|
});
|