mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Settings live in the renderer's IndexedDB, and two failure modes made them look saved while nothing reached disk. A second app instance sharing the same userData directory cannot take the Chromium storage lock, so its renderer reads defaults and every write is dropped. The app now holds a single-instance lock and focuses the running window instead of starting a rival copy. The lock is requested after the userData override so E2E runs with their own data dir keep independent locks, and after Squirrel event handling. IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1 opts out for local CDP debugging. updateSettings() patches in-memory state before persisting and the submit path had no rejection handler, so a failed write produced an unhandled rejection and no user-visible feedback. SettingsStore now records which half of the round trip failed, and the settings page surfaces it through a dismissible error snackbar; the dialog stays open on failure so the save can be retried. Two follow-ups from review, both wider than the report: - a second launch now re-creates the main window when the lock owner has none left, so closing the last window on macOS no longer leaves a second launch quitting silently with nothing on screen - App.onMainWindowCreated() re-runs window-owned bindings for every rebuilt window, so the downloads broadcaster stops holding a destroyed window. This also fixes the same bug on the pre-existing dock `activate` path. Closes #1156 Closes #102
129 lines
4.5 KiB
TypeScript
129 lines
4.5 KiB
TypeScript
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
|
import { MatDialog } from '@angular/material/dialog';
|
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
|
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
|
import { SettingsStore } from '../services/settings-store.service';
|
|
import { SettingsComponent } from './settings.component';
|
|
import {
|
|
configureSettingsComponentTestBed,
|
|
createElectronStub,
|
|
createEpgBridgeStub,
|
|
MatSnackBarStub,
|
|
MockSettingsStore,
|
|
stubSettingsSideEffects,
|
|
} from './test-stubs/settings-test-harness.stub';
|
|
|
|
/** Matches the snackbar config used by `SettingsSnackbarService.error`. */
|
|
const ERROR_SNACKBAR_CONFIG = expect.objectContaining({
|
|
panelClass: ['settings-snackbar', 'settings-snackbar--error'],
|
|
});
|
|
|
|
/**
|
|
* Assertions target the MatDialog the component actually calls: spying on the
|
|
* one returned by `TestBed.inject(MatDialog)` does not observe it.
|
|
*/
|
|
interface SettingsComponentPrivateTestApi {
|
|
matDialog: MatDialog;
|
|
}
|
|
|
|
/**
|
|
* Settings live in the renderer's IndexedDB, and both halves of the round trip
|
|
* can fail while the UI keeps looking healthy: a failed read shows defaults as
|
|
* if they were saved, and a failed write is applied in memory so it survives
|
|
* until the next restart. These cover the user-visible reporting of both.
|
|
*/
|
|
describe('SettingsComponent storage failures', () => {
|
|
let component: SettingsComponent;
|
|
let fixture: ComponentFixture<SettingsComponent>;
|
|
let settingsStore: MockSettingsStore;
|
|
let snackBar: MatSnackBarStub;
|
|
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
|
const originalElectron = window.electron;
|
|
const componentMatDialog = (): MatDialog =>
|
|
(component as unknown as SettingsComponentPrivateTestApi).matDialog;
|
|
|
|
beforeEach(waitForAsync(() => {
|
|
epgBridge = createEpgBridgeStub();
|
|
configureSettingsComponentTestBed(epgBridge);
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
window.electron = createElectronStub();
|
|
|
|
fixture = TestBed.createComponent(SettingsComponent);
|
|
settingsStore = TestBed.inject(
|
|
SettingsStore
|
|
) as unknown as MockSettingsStore;
|
|
snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub;
|
|
|
|
component = fixture.componentInstance;
|
|
stubSettingsSideEffects(component);
|
|
fixture.detectChanges();
|
|
});
|
|
|
|
afterEach(() => {
|
|
window.electron = originalElectron;
|
|
});
|
|
|
|
it('warns when the persisted settings could not be read', async () => {
|
|
settingsStore.storageFailure.set('load');
|
|
|
|
await component.ngOnInit();
|
|
|
|
expect(snackBar.open).toHaveBeenCalledWith(
|
|
'SETTINGS.SETTINGS_LOAD_FAILED',
|
|
'CLOSE',
|
|
ERROR_SNACKBAR_CONFIG
|
|
);
|
|
});
|
|
|
|
it('warns and keeps the dialog open when the settings write fails', async () => {
|
|
settingsStore.updateSettings.mockRejectedValue(
|
|
new Error('storage unavailable')
|
|
);
|
|
const closeAll = jest.spyOn(componentMatDialog(), 'closeAll');
|
|
component.isDialog = true;
|
|
|
|
component.onSubmit();
|
|
await fixture.whenStable();
|
|
|
|
expect(snackBar.open).toHaveBeenCalledWith(
|
|
'SETTINGS.SETTINGS_SAVE_FAILED',
|
|
'CLOSE',
|
|
ERROR_SNACKBAR_CONFIG
|
|
);
|
|
expect(closeAll).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('closes the dialog only after the settings write succeeded', async () => {
|
|
settingsStore.updateSettings.mockResolvedValue(undefined);
|
|
const closeAll = jest.spyOn(componentMatDialog(), 'closeAll');
|
|
jest.spyOn(component.epg, 'fetchConfiguredEpg').mockImplementation();
|
|
component.isDialog = true;
|
|
|
|
component.onSubmit();
|
|
// save() resolves a tick after the store write: the callback and
|
|
// the Electron mirror run first, so the close lands on the next
|
|
// turn of the microtask queue.
|
|
await fixture.whenStable();
|
|
await fixture.whenStable();
|
|
|
|
expect(closeAll).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('warns when a section write fails without leaving an unhandled rejection', async () => {
|
|
settingsStore.updateSettings.mockRejectedValue(
|
|
new Error('storage unavailable')
|
|
);
|
|
|
|
component.form.selectCoverSize('large');
|
|
await fixture.whenStable();
|
|
|
|
expect(snackBar.open).toHaveBeenCalledWith(
|
|
'SETTINGS.SETTINGS_SAVE_FAILED',
|
|
'CLOSE',
|
|
ERROR_SNACKBAR_CONFIG
|
|
);
|
|
});
|
|
});
|