From beb4f3d27416ffa1844e1108c48e584107c90a2b Mon Sep 17 00:00:00 2001 From: Altaskur <105789412+altaskur@users.noreply.github.com> Date: Fri, 6 Feb 2026 01:15:58 +0100 Subject: [PATCH 1/8] Actualizar el cuerpo del pull request #44 con un resumen de cambios. --- pull_requests/44/body.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 pull_requests/44/body.md diff --git a/pull_requests/44/body.md b/pull_requests/44/body.md new file mode 100644 index 0000000..5cc6df6 --- /dev/null +++ b/pull_requests/44/body.md @@ -0,0 +1,21 @@ +## Resumen de Cambios + +Esta versión incluye la implementación de un sistema de actualizaciones automáticas y varias mejoras de estabilidad y estilo. + +### ✨ Nuevas Características +- **Sistema de Actualizaciones Automáticas**: Integración de `electron-updater` para gestionar actualizaciones desde GitHub Releases. + - Implementación de `UpdateManager` y manejadores IPC en el proceso principal. + - Creación de `UpdateService` en Angular con señales para gestionar el estado. + - Nueva interfaz de usuario para notificaciones de actualización y ajustes de preferencias. + - Traducciones completas (ES/EN) para todo el sistema de actualizaciones. +- **Mejoras en CI/CD**: Guía de configuración de SonarQube completada y optimización de flujos. + +### 🐛 Errores Solucionados +- **Estilos**: Se corrigió un problema donde los nombres de proyectos largos rompían el diseño de los modales y las tarjetas de la página de inicio (Ref #27). +- **Seguridad**: Se añadieron atributos `noopener` y `noreferrer` a las aperturas de enlaces externos para prevenir ataques de tabnabbing. + +### 📝 Documentación +- Actualización del README con instrucciones de desarrollo más claras y definición del alcance del proyecto. + +### ✅ Pruebas +- Cobertura de pruebas unitarias para el nuevo sistema de actualizaciones en Electron y Angular. \ No newline at end of file From e9f941c3037d19597d8fb3a8e1a12cd72f5fe09b Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Sat, 7 Feb 2026 02:32:21 +0100 Subject: [PATCH 2/8] chore: bump version to 1.0.0-alpha.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 25f129d..f73a40d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-time-tracker", - "version": "1.0.0-alpha.4", + "version": "1.0.0-alpha.5", "author": "altaskur", "license": "GPL-3.0", "type": "module", From 2346cee7d93bbaf17c03ed8b4a2e76d1bcaf448f Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:24:44 +0100 Subject: [PATCH 3/8] style: fix markdown content padding and force push --- electron/src/main/main.ts | 3 +- electron/src/preload/preload.ts | 14 ++ electron/src/services/ipc/index.ts | 16 +- .../src/services/ipc/update-handlers.spec.ts | 131 ++++++++++ electron/src/services/ipc/update-handlers.ts | 8 + electron/src/services/menu/menu-manager.ts | 54 +++-- .../services/update/update.service.spec.ts | 203 ++++++++++++++++ .../src/services/update/update.service.ts | 92 +++++++ package-lock.json | 201 +++++++++------- package.json | 8 +- src/app/app.html | 3 +- src/app/app.routes.ts | 7 + src/app/app.spec.ts | 54 ++++- src/app/app.ts | 7 +- .../update-banner/update-banner.component.ts | 42 ++++ .../open-settings-updates.spec.ts | 161 +++++++++++++ .../open-settings-updates.ts | 188 +++++++++++++++ src/app/pipes/safe-markdown.pipe.ts | 49 ++++ .../electron-navigation.service.spec.ts | 8 +- src/app/services/update.service.spec.ts | 227 ++++++++++++++++++ src/app/services/update.service.ts | 73 ++++++ src/assets/i18n/en.json | 15 +- src/assets/i18n/es.json | 15 +- src/types/electron.d.ts | 26 +- 24 files changed, 1484 insertions(+), 121 deletions(-) create mode 100644 electron/src/services/ipc/update-handlers.spec.ts create mode 100644 electron/src/services/ipc/update-handlers.ts create mode 100644 electron/src/services/update/update.service.spec.ts create mode 100644 electron/src/services/update/update.service.ts create mode 100644 src/app/components/update-banner/update-banner.component.ts create mode 100644 src/app/pages/open-settings-updates/open-settings-updates.spec.ts create mode 100644 src/app/pages/open-settings-updates/open-settings-updates.ts create mode 100644 src/app/pipes/safe-markdown.pipe.ts create mode 100644 src/app/services/update.service.spec.ts create mode 100644 src/app/services/update.service.ts diff --git a/electron/src/main/main.ts b/electron/src/main/main.ts index 430b4d4..033146f 100644 --- a/electron/src/main/main.ts +++ b/electron/src/main/main.ts @@ -3,6 +3,7 @@ import { WindowManager } from './window.js'; import { DatabaseManager } from '../services/database/database.js'; import { setupIpcHandlers } from '../services/ipc/index.js'; import { BackupService } from '../services/backup/index.js'; +import { UpdateService } from '../services/update/update.service.js'; let windowManager: WindowManager | null = null; let dbManager: DatabaseManager | null = null; @@ -29,7 +30,7 @@ const initializeApp = async (): Promise => { }, ); - setupIpcHandlers(dbManager, backupService); + setupIpcHandlers(dbManager, backupService, new UpdateService()); windowManager = new WindowManager(); await windowManager.createMainWindow(); }; diff --git a/electron/src/preload/preload.ts b/electron/src/preload/preload.ts index 49f8a54..ab9e07d 100644 --- a/electron/src/preload/preload.ts +++ b/electron/src/preload/preload.ts @@ -396,6 +396,20 @@ try { // System openExternal: (url: string): Promise => shell.openExternal(url), + + // Updates + checkForUpdates: (): Promise<{ + updateAvailable: boolean; + version: string; + url: string; + releaseNotes?: string; + }> => ipcRenderer.invoke('check-for-updates'), + + // App Info + getVersion: (): Promise => ipcRenderer.invoke('get-version'), + + // Release Info + getReleaseByTag: (tag: string): Promise => ipcRenderer.invoke('get-release-by-tag', tag), }; contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/electron/src/services/ipc/index.ts b/electron/src/services/ipc/index.ts index 26ed1ed..bc11c49 100644 --- a/electron/src/services/ipc/index.ts +++ b/electron/src/services/ipc/index.ts @@ -1,21 +1,27 @@ import { DatabaseManager } from '../database/database.js'; import { BackupService } from '../backup/index.js'; +import { UpdateService } from '../update/update.service.js'; import { setupDatabaseHandlers } from './database-handlers.js'; import { setupThemeHandlers } from './theme-handlers.js'; import { setupLanguageHandlers } from './language-handlers.js'; import { setupBackupHandlers } from './backup-handlers.js'; +import { setupUpdateHandlers } from './update-handlers.js'; /** * Sets up all IPC handlers */ export const setupIpcHandlers = ( - dbManager: DatabaseManager, - backupService?: BackupService, + dbManager: DatabaseManager | null, + backupService: BackupService | null, + updateService: UpdateService, ): void => { - setupDatabaseHandlers(dbManager); - setupThemeHandlers(dbManager); - setupLanguageHandlers(dbManager); + if (dbManager) { + setupDatabaseHandlers(dbManager); + setupThemeHandlers(dbManager); + setupLanguageHandlers(dbManager); + } if (backupService) { setupBackupHandlers(backupService); } + setupUpdateHandlers(updateService); }; diff --git a/electron/src/services/ipc/update-handlers.spec.ts b/electron/src/services/ipc/update-handlers.spec.ts new file mode 100644 index 0000000..74f5dad --- /dev/null +++ b/electron/src/services/ipc/update-handlers.spec.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { ipcMain, app } from 'electron'; +import { setupUpdateHandlers } from './update-handlers.js'; +import { UpdateService } from '../update/update.service.js'; + +// Mock electron modules +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + }, + app: { + getVersion: vi.fn(), + }, +})); + +describe('Update Handlers', () => { + let mockUpdateService: { + checkForUpdates: Mock; + getReleaseByTag: Mock; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockUpdateService = { + checkForUpdates: vi.fn(), + getReleaseByTag: vi.fn(), + }; + }); + + describe('setupUpdateHandlers', () => { + it('should register all IPC handlers', () => { + setupUpdateHandlers(mockUpdateService as unknown as UpdateService); + + expect(ipcMain.handle).toHaveBeenCalledTimes(3); + expect(ipcMain.handle).toHaveBeenCalledWith( + 'check-for-updates', + expect.any(Function) + ); + expect(ipcMain.handle).toHaveBeenCalledWith( + 'get-version', + expect.any(Function) + ); + expect(ipcMain.handle).toHaveBeenCalledWith( + 'get-release-by-tag', + expect.any(Function) + ); + }); + + it('should call updateService.checkForUpdates when check-for-updates is invoked', async () => { + const mockResult = { updateAvailable: true, version: '2.0.0', url: 'https://example.com' }; + mockUpdateService.checkForUpdates.mockResolvedValue(mockResult); + + setupUpdateHandlers(mockUpdateService as unknown as UpdateService); + + const handlers = (ipcMain.handle as Mock).mock.calls; + const checkForUpdatesHandler = handlers.find( + (call) => call[0] === 'check-for-updates' + )?.[1]; + + const result = await checkForUpdatesHandler(); + + expect(mockUpdateService.checkForUpdates).toHaveBeenCalled(); + expect(result).toEqual(mockResult); + }); + + it('should return app version when get-version is invoked', async () => { + (app.getVersion as Mock).mockReturnValue('1.5.0'); + + setupUpdateHandlers(mockUpdateService as unknown as UpdateService); + + const handlers = (ipcMain.handle as Mock).mock.calls; + const getVersionHandler = handlers.find( + (call) => call[0] === 'get-version' + )?.[1]; + + const result = getVersionHandler(); + + expect(app.getVersion).toHaveBeenCalled(); + expect(result).toBe('1.5.0'); + }); + + it('should call updateService.getReleaseByTag when get-release-by-tag is invoked', async () => { + const mockRelease = { body: 'Release notes for v1.2.3' }; + mockUpdateService.getReleaseByTag.mockResolvedValue(mockRelease); + + setupUpdateHandlers(mockUpdateService as unknown as UpdateService); + + const handlers = (ipcMain.handle as Mock).mock.calls; + const getReleaseByTagHandler = handlers.find( + (call) => call[0] === 'get-release-by-tag' + )?.[1]; + + const result = await getReleaseByTagHandler({}, 'v1.2.3'); + + expect(mockUpdateService.getReleaseByTag).toHaveBeenCalledWith('v1.2.3'); + expect(result).toEqual(mockRelease); + }); + + it('should handle checkForUpdates when no update is available', async () => { + const mockResult = { updateAvailable: false, version: '1.0.0', url: '' }; + mockUpdateService.checkForUpdates.mockResolvedValue(mockResult); + + setupUpdateHandlers(mockUpdateService as unknown as UpdateService); + + const handlers = (ipcMain.handle as Mock).mock.calls; + const checkForUpdatesHandler = handlers.find( + (call) => call[0] === 'check-for-updates' + )?.[1]; + + const result = await checkForUpdatesHandler(); + + expect(result.updateAvailable).toBe(false); + }); + + it('should handle getReleaseByTag returning null', async () => { + mockUpdateService.getReleaseByTag.mockResolvedValue(null); + + setupUpdateHandlers(mockUpdateService as unknown as UpdateService); + + const handlers = (ipcMain.handle as Mock).mock.calls; + const getReleaseByTagHandler = handlers.find( + (call) => call[0] === 'get-release-by-tag' + )?.[1]; + + const result = await getReleaseByTagHandler({}, 'v99.99.99'); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/electron/src/services/ipc/update-handlers.ts b/electron/src/services/ipc/update-handlers.ts new file mode 100644 index 0000000..d755583 --- /dev/null +++ b/electron/src/services/ipc/update-handlers.ts @@ -0,0 +1,8 @@ +import { ipcMain, app } from 'electron'; +import { UpdateService } from '../update/update.service.js'; + +export const setupUpdateHandlers = (updateService: UpdateService): void => { + ipcMain.handle('check-for-updates', () => updateService.checkForUpdates()); + ipcMain.handle('get-version', () => app.getVersion()); + ipcMain.handle('get-release-by-tag', (_event, tag: string) => updateService.getReleaseByTag(tag)); +}; diff --git a/electron/src/services/menu/menu-manager.ts b/electron/src/services/menu/menu-manager.ts index eee7519..c11cb95 100644 --- a/electron/src/services/menu/menu-manager.ts +++ b/electron/src/services/menu/menu-manager.ts @@ -53,7 +53,8 @@ const menuTranslations: Record> = { maintenance: 'Mantenimiento', tags: 'Etiquetas', dayTypes: 'Tipos de Día', - taskStatuses: 'Estados de Tarea', + taskStatuses: 'Tipos de Día', + checkForUpdates: 'Buscar Actualizaciones', }, en: { home: 'Home', @@ -92,6 +93,7 @@ const menuTranslations: Record> = { tags: 'Tags', dayTypes: 'Day Types', taskStatuses: 'Task Statuses', + checkForUpdates: 'Check for Updates', }, }; @@ -121,21 +123,21 @@ export class MenuManager { const template: MenuItemConstructorOptions[] = [ ...(isMac ? [ - { - label: app.name, - submenu: [ - { role: 'about' as const }, - { type: 'separator' as const }, - { role: 'services' as const }, - { type: 'separator' as const }, - { role: 'hide' as const }, - { role: 'hideOthers' as const }, - { role: 'unhide' as const }, - { type: 'separator' as const }, - { role: 'quit' as const }, - ], - }, - ] + { + label: app.name, + submenu: [ + { role: 'about' as const }, + { type: 'separator' as const }, + { role: 'services' as const }, + { type: 'separator' as const }, + { role: 'hide' as const }, + { role: 'hideOthers' as const }, + { role: 'unhide' as const }, + { type: 'separator' as const }, + { role: 'quit' as const }, + ], + }, + ] : []), { @@ -172,13 +174,13 @@ export class MenuManager { ...(isMac ? [] : [ - { type: 'separator' as const }, - { - label: this.t('exit'), - accelerator: 'Alt+F4', - role: 'quit' as const, - }, - ]), + { type: 'separator' as const }, + { + label: this.t('exit'), + accelerator: 'Alt+F4', + role: 'quit' as const, + }, + ]), ], }, @@ -287,6 +289,12 @@ export class MenuManager { { label: this.t('help'), submenu: [ + { + label: this.t('checkForUpdates'), + click: (): void => { + this.navigateTo('/settings/updates'); + }, + }, { label: this.t('documentation'), click: (): void => { diff --git a/electron/src/services/update/update.service.spec.ts b/electron/src/services/update/update.service.spec.ts new file mode 100644 index 0000000..8b0cb29 --- /dev/null +++ b/electron/src/services/update/update.service.spec.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { app } from 'electron'; +import { UpdateService } from './update.service.js'; + +// Mock global fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe('UpdateService', () => { + let updateService: UpdateService; + + beforeEach(() => { + vi.clearAllMocks(); + updateService = new UpdateService(); + (app.getVersion as Mock).mockReturnValue('1.0.0'); + }); + + describe('checkForUpdates', () => { + it('should return updateAvailable true when newer version exists', async () => { + const mockRelease = { + tag_name: 'v2.0.0', + html_url: 'https://github.com/altaskur/OpenTimeTracker/releases/tag/v2.0.0', + body: 'Release notes for v2.0.0', + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRelease), + }); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(true); + expect(result.version).toBe('2.0.0'); + expect(result.url).toBe(mockRelease.html_url); + expect(result.releaseNotes).toBe(mockRelease.body); + }); + + it('should return updateAvailable false when current version is latest', async () => { + const mockRelease = { + tag_name: 'v1.0.0', + html_url: 'https://github.com/altaskur/OpenTimeTracker/releases/tag/v1.0.0', + body: 'Current version notes', + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRelease), + }); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(false); + expect(result.version).toBe('1.0.0'); + }); + + it('should return updateAvailable false when current version is newer', async () => { + const mockRelease = { + tag_name: 'v0.9.0', + html_url: 'https://github.com/altaskur/OpenTimeTracker/releases/tag/v0.9.0', + body: 'Old version notes', + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRelease), + }); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(false); + }); + + it('should return updateAvailable false when fetch fails', async () => { + mockFetch.mockResolvedValue({ + ok: false, + statusText: 'Not Found', + }); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(false); + expect(result.version).toBe(''); + expect(result.url).toBe(''); + }); + + it('should return updateAvailable false on network error', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(false); + expect(result.version).toBe(''); + expect(result.url).toBe(''); + }); + + it('should handle version tags without v prefix', async () => { + const mockRelease = { + tag_name: '2.0.0', + html_url: 'https://github.com/altaskur/OpenTimeTracker/releases/tag/2.0.0', + body: 'Notes', + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRelease), + }); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(true); + expect(result.version).toBe('2.0.0'); + }); + }); + + describe('getReleaseByTag', () => { + it('should return release data for valid tag', async () => { + const mockRelease = { + tag_name: 'v1.0.0', + html_url: 'https://github.com/altaskur/OpenTimeTracker/releases/tag/v1.0.0', + body: 'Release notes for v1.0.0', + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRelease), + }); + + const result = await updateService.getReleaseByTag('v1.0.0'); + + expect(result).toEqual(mockRelease); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.github.com/repos/altaskur/OpenTimeTracker/releases/tags/v1.0.0', + expect.objectContaining({ + headers: expect.objectContaining({ + 'User-Agent': 'OpenTimeTracker/1.0.0', + 'Accept': 'application/vnd.github.v3+json', + }), + }) + ); + }); + + it('should return null when release not found', async () => { + mockFetch.mockResolvedValue({ + ok: false, + statusText: 'Not Found', + }); + + const result = await updateService.getReleaseByTag('v99.99.99'); + + expect(result).toBeNull(); + }); + + it('should return null on network error', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const result = await updateService.getReleaseByTag('v1.0.0'); + + expect(result).toBeNull(); + }); + }); + + describe('compareVersions', () => { + // Access private method via prototype for testing + const compareVersions = (v1: string, v2: string): number => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (updateService as any).compareVersions(v1, v2); + }; + + it('should return 1 when first version is greater', () => { + expect(compareVersions('2.0.0', '1.0.0')).toBe(1); + expect(compareVersions('1.1.0', '1.0.0')).toBe(1); + expect(compareVersions('1.0.1', '1.0.0')).toBe(1); + expect(compareVersions('1.0.0', '0.9.9')).toBe(1); + }); + + it('should return -1 when first version is less', () => { + expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); + expect(compareVersions('1.0.0', '1.1.0')).toBe(-1); + expect(compareVersions('1.0.0', '1.0.1')).toBe(-1); + expect(compareVersions('0.9.9', '1.0.0')).toBe(-1); + }); + + it('should return 0 when versions are equal', () => { + expect(compareVersions('1.0.0', '1.0.0')).toBe(0); + expect(compareVersions('2.5.3', '2.5.3')).toBe(0); + }); + + it('should handle versions with different part counts', () => { + expect(compareVersions('1.0.0', '1.0')).toBe(0); + expect(compareVersions('1.0', '1.0.0')).toBe(0); + expect(compareVersions('1.0.1', '1.0')).toBe(1); + expect(compareVersions('1.0', '1.0.1')).toBe(-1); + }); + + it('should handle versions with alpha/beta suffixes by treating non-numeric as NaN', () => { + // '1.0.0-alpha.5' becomes [1, 0, NaN] which compares as 0 when NaN + // So '1.0.0' [1,0,0] compared to '1.0.0-alpha.5' [1,0,NaN] - NaN becomes 0 + // This test documents current behavior, not ideal handling + expect(compareVersions('1.0.0', '1.0.0-alpha')).toBe(0); // NaN treated as 0 + }); + }); +}); diff --git a/electron/src/services/update/update.service.ts b/electron/src/services/update/update.service.ts new file mode 100644 index 0000000..b9ed2d2 --- /dev/null +++ b/electron/src/services/update/update.service.ts @@ -0,0 +1,92 @@ +import { app } from 'electron'; + +export interface UpdateCheckResult { + updateAvailable: boolean; + version: string; + url: string; + releaseNotes?: string; +} + +interface GitHubRelease { + tag_name: string; + html_url: string; + body: string; +} + +export class UpdateService { + private readonly GITHUB_API_URL = 'https://api.github.com/repos/altaskur/OpenTimeTracker/releases/latest'; + + async checkForUpdates(): Promise { + try { + const response = await fetch(this.GITHUB_API_URL, { + headers: { + 'User-Agent': `OpenTimeTracker/${app.getVersion()}`, + 'Accept': 'application/vnd.github.v3+json' + } + }); + + if (!response.ok) { + console.error('Failed to check for updates:', response.statusText); + return { updateAvailable: false, version: '', url: '' }; + } + + const release = await response.json() as GitHubRelease; + const latestVersion = release.tag_name.replace(/^v/, ''); + const currentVersion = app.getVersion(); + + const updateAvailable = this.compareVersions(latestVersion, currentVersion) > 0; + + return { + updateAvailable, + version: latestVersion, + url: release.html_url, + releaseNotes: release.body + }; + } catch (error) { + console.error('Error checking for updates:', error); + return { updateAvailable: false, version: '', url: '' }; + } + } + + async getReleaseByTag(tag: string): Promise { + try { + const url = `https://api.github.com/repos/altaskur/OpenTimeTracker/releases/tags/${tag}`; + const response = await fetch(url, { + headers: { + 'User-Agent': `OpenTimeTracker/${app.getVersion()}`, + 'Accept': 'application/vnd.github.v3+json' + } + }); + + if (!response.ok) { + console.error(`Failed to fetch release ${tag}:`, response.statusText); + return null; + } + + return await response.json() as GitHubRelease; + } catch (error) { + console.error(`Error fetching release ${tag}:`, error); + return null; + } + } + + /* + * Returns: + * 1 if v1 > v2 + * -1 if v1 < v2 + * 0 if v1 === v2 + */ + private compareVersions(v1: string, v2: string): number { + const p1 = v1.split('.').map(Number); + const p2 = v2.split('.').map(Number); + const len = Math.max(p1.length, p2.length); + + for (let i = 0; i < len; i++) { + const n1 = p1[i] || 0; + const n2 = p2[i] || 0; + if (n1 > n2) return 1; + if (n1 < n2) return -1; + } + return 0; + } +} diff --git a/package-lock.json b/package-lock.json index ccd5e09..7ffd08f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-time-tracker", - "version": "1.0.0-alpha.4", + "version": "1.0.0-alpha.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-time-tracker", - "version": "1.0.0-alpha.4", + "version": "1.0.0-alpha.5", "hasInstallScript": true, "license": "GPL-3.0", "dependencies": { @@ -23,7 +23,10 @@ "@primeuix/themes": "^1.2.0", "@prisma/adapter-better-sqlite3": "^7.1.0", "@prisma/client": "^7.1.0", + "@types/dompurify": "^3.0.5", "better-sqlite3": "^12.5.0", + "dompurify": "^3.3.1", + "marked": "^17.0.1", "primeflex": "^4.0.0", "primeicons": "^7.0.0", "primeng": "^20.3.0", @@ -4161,13 +4164,13 @@ } }, "node_modules/@npmcli/git/node_modules/isexe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz", - "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/@npmcli/git/node_modules/lru-cache": { @@ -4333,13 +4336,13 @@ } }, "node_modules/@npmcli/promise-spawn/node_modules/isexe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz", - "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/@npmcli/promise-spawn/node_modules/which": { @@ -4468,13 +4471,13 @@ } }, "node_modules/@npmcli/run-script/node_modules/isexe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz", - "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/@npmcli/run-script/node_modules/lru-cache": { @@ -4609,9 +4612,9 @@ } }, "node_modules/@npmcli/run-script/node_modules/ssri": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", - "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", "dev": true, "license": "ISC", "dependencies": { @@ -6032,9 +6035,9 @@ } }, "node_modules/@sigstore/sign/node_modules/ssri": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", - "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", "dev": true, "license": "ISC", "dependencies": { @@ -6250,6 +6253,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -6313,9 +6325,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.11.tgz", - "integrity": "sha512-/Af7O8r1frCVgOz0I62jWUtMohJ0/ZQU/ZoketltOJPZpnb17yoNc9BSoVuV9qlaIXJiPNOpsfq4ByFajSArNQ==", + "version": "24.10.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.12.tgz", + "integrity": "sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==", "dev": true, "license": "MIT", "dependencies": { @@ -6344,6 +6356,12 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -6705,14 +6723,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", - "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.54.0", - "@typescript-eslint/types": "^8.54.0", + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", "debug": "^4.4.3" }, "engines": { @@ -6727,14 +6745,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", - "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0" + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6745,9 +6763,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", - "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", "dev": true, "license": "MIT", "engines": { @@ -6942,9 +6960,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", - "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", "dev": true, "license": "MIT", "engines": { @@ -6956,16 +6974,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", - "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.54.0", - "@typescript-eslint/tsconfig-utils": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", @@ -6984,16 +7002,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", - "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0" + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7008,13 +7026,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", - "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/types": "8.55.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -7624,13 +7642,13 @@ } }, "node_modules/app-builder-lib/node_modules/isexe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz", - "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/app-builder-lib/node_modules/minimatch": { @@ -7785,14 +7803,14 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -9540,6 +9558,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -9897,9 +9924,9 @@ "license": "ISC" }, "node_modules/electron/node_modules/@types/node": { - "version": "22.19.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.9.tgz", - "integrity": "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==", + "version": "22.19.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.10.tgz", + "integrity": "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw==", "dev": true, "license": "MIT", "dependencies": { @@ -13476,6 +13503,18 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/marked": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", + "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -14083,13 +14122,13 @@ } }, "node_modules/node-gyp/node_modules/isexe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz", - "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/node-gyp/node_modules/which": { @@ -14461,9 +14500,9 @@ } }, "node_modules/npm-registry-fetch/node_modules/ssri": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", - "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", "dev": true, "license": "ISC", "dependencies": { @@ -14892,9 +14931,9 @@ } }, "node_modules/pacote/node_modules/ssri": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", - "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", "dev": true, "license": "ISC", "dependencies": { @@ -17514,9 +17553,9 @@ } }, "node_modules/tuf-js/node_modules/ssri": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", - "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 25f129d..259f44f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "overrides": { "node-forge": "^1.3.2", "hono": "4.11.8", - "lodash": "4.17.21" + "lodash": "4.17.21", + "axios": "1.13.5" }, "scripts": { "start": "ng serve", @@ -158,7 +159,10 @@ "@primeuix/themes": "^1.2.0", "@prisma/adapter-better-sqlite3": "^7.1.0", "@prisma/client": "^7.1.0", + "@types/dompurify": "^3.0.5", "better-sqlite3": "^12.5.0", + "dompurify": "^3.3.1", + "marked": "^17.0.1", "primeflex": "^4.0.0", "primeicons": "^7.0.0", "primeng": "^20.3.0", @@ -212,4 +216,4 @@ "prettier --write" ] } -} +} \ No newline at end of file diff --git a/src/app/app.html b/src/app/app.html index 1cbfd09..422b567 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,5 +1,6 @@
- + +
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 3d5a1b6..9887b38 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -49,6 +49,13 @@ export const routes: Routes = [ (m) => m.OpenSettingsStatusesComponent, ), }, + { + path: 'settings/updates', + loadComponent: () => + import( + './pages/open-settings-updates/open-settings-updates' + ).then((m) => m.OpenSettingsUpdatesComponent), + }, { path: '**', redirectTo: '', diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 21ad9f9..9c271eb 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -3,15 +3,25 @@ import { App } from './app'; import { TranslateModule } from '@ngx-translate/core'; import { MessageService } from 'primeng/api'; import { ActionHistoryService } from './services/action-history.service'; +import { UpdateService } from './services/update.service'; +import { signal } from '@angular/core'; describe('App', () => { let mockHistoryService: jasmine.SpyObj; let mockElectronAPI: { onUndoAction: jasmine.Spy; onRedoAction: jasmine.Spy; + checkForUpdates: jasmine.Spy; + }; + let mockUpdateService: { + init: jasmine.Spy; + autoCheck: ReturnType>; + updateAvailable: ReturnType>; + checking: ReturnType>; }; let undoCallback: () => void; let redoCallback: () => void; + let originalElectronAPI: unknown; beforeEach(async () => { mockHistoryService = jasmine.createSpyObj('ActionHistoryService', [ @@ -60,8 +70,20 @@ describe('App', () => { .and.callFake((cb: () => void) => { redoCallback = cb; }), + checkForUpdates: jasmine + .createSpy('checkForUpdates') + .and.returnValue(Promise.resolve({ updateAvailable: false, version: '', url: '' })), + }; + + mockUpdateService = { + init: jasmine.createSpy('init'), + autoCheck: signal(true), + updateAvailable: signal(null), + checking: signal(false), }; + // Save original electronAPI and set mock + originalElectronAPI = (window as unknown as { electronAPI?: unknown }).electronAPI; ( window as unknown as { electronAPI?: typeof mockElectronAPI } ).electronAPI = mockElectronAPI; @@ -71,13 +93,14 @@ describe('App', () => { providers: [ MessageService, { provide: ActionHistoryService, useValue: mockHistoryService }, + { provide: UpdateService, useValue: mockUpdateService }, ], }).compileComponents(); }); afterEach(() => { - delete (window as unknown as { electronAPI?: typeof mockElectronAPI }) - .electronAPI; + // Restore original electronAPI + (window as unknown as { electronAPI?: unknown }).electronAPI = originalElectronAPI; }); it('should create the app', () => { @@ -158,15 +181,40 @@ describe('App', () => { }); describe('App without electronAPI', () => { + let mockUpdateService: { + init: jasmine.Spy; + autoCheck: ReturnType>; + updateAvailable: ReturnType>; + checking: ReturnType>; + }; + let savedElectronAPI: unknown; + beforeEach(async () => { + // Save and delete electronAPI for this test suite + savedElectronAPI = (window as { electronAPI?: unknown }).electronAPI; delete (window as { electronAPI?: unknown }).electronAPI; + mockUpdateService = { + init: jasmine.createSpy('init'), + autoCheck: signal(true), + updateAvailable: signal(null), + checking: signal(false), + }; + await TestBed.configureTestingModule({ imports: [App, TranslateModule.forRoot()], - providers: [MessageService], + providers: [ + MessageService, + { provide: UpdateService, useValue: mockUpdateService }, + ], }).compileComponents(); }); + afterEach(() => { + // Restore original electronAPI + (window as { electronAPI?: unknown }).electronAPI = savedElectronAPI; + }); + it('should create without electron API', () => { const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; diff --git a/src/app/app.ts b/src/app/app.ts index 53492bb..3abbd6b 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,5 +1,6 @@ import { Component, inject, OnInit, NgZone, OnDestroy } from '@angular/core'; import { RouterOutlet, Router } from '@angular/router'; +import { UpdateBannerComponent } from './components/update-banner/update-banner.component'; import { ToastModule } from 'primeng/toast'; import { MessageService } from 'primeng/api'; import { TranslateService } from '@ngx-translate/core'; @@ -9,6 +10,7 @@ import { TranslationService, } from './services'; import { ActionHistoryService } from './services/action-history.service'; +import { UpdateService } from './services/update.service'; /** * Root component of the application. @@ -16,7 +18,8 @@ import { ActionHistoryService } from './services/action-history.service'; */ @Component({ selector: 'app-root', - imports: [RouterOutlet, ToastModule], + standalone: true, + imports: [RouterOutlet, ToastModule, UpdateBannerComponent], templateUrl: './app.html', styleUrl: './app.scss', }) @@ -28,12 +31,14 @@ export class App implements OnInit, OnDestroy { private readonly translationService = inject(TranslationService); private readonly historyService = inject(ActionHistoryService); private readonly messageService = inject(MessageService); + private readonly updateService = inject(UpdateService); private readonly translate = inject(TranslateService); private readonly ngZone = inject(NgZone); private readonly router = inject(Router); ngOnInit(): void { this.setupHistoryListeners(); + this.updateService.init(); } ngOnDestroy(): void { diff --git a/src/app/components/update-banner/update-banner.component.ts b/src/app/components/update-banner/update-banner.component.ts new file mode 100644 index 0000000..648d94f --- /dev/null +++ b/src/app/components/update-banner/update-banner.component.ts @@ -0,0 +1,42 @@ +import { Component, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { UpdateService } from '../../services/update.service'; +import { MessageModule } from 'primeng/message'; +import { ButtonModule } from 'primeng/button'; +import { TranslateModule } from '@ngx-translate/core'; + +@Component({ + selector: 'app-update-banner', + standalone: true, + imports: [CommonModule, MessageModule, ButtonModule, TranslateModule], + template: ` + @if (updateService.updateAvailable(); as update) { +
+
+ +
+ New version available: {{ update.version }} +
A new version of OpenTimeTracker is ready to download.
+
+
+
+ +
+
+ } + `, + styles: [` + .update-banner { + border-left: 4px solid var(--primary-color); + } + `] +}) +export class UpdateBannerComponent { + updateService = inject(UpdateService); + router = inject(Router); + + viewDetails() { + this.router.navigate(['/settings/updates']); + } +} diff --git a/src/app/pages/open-settings-updates/open-settings-updates.spec.ts b/src/app/pages/open-settings-updates/open-settings-updates.spec.ts new file mode 100644 index 0000000..364dbc1 --- /dev/null +++ b/src/app/pages/open-settings-updates/open-settings-updates.spec.ts @@ -0,0 +1,161 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { OpenSettingsUpdatesComponent } from './open-settings-updates'; +import { UpdateService } from '../../services/update.service'; +import { MessageService } from 'primeng/api'; +import { signal } from '@angular/core'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; + + +describe('OpenSettingsUpdatesComponent', () => { + let component: OpenSettingsUpdatesComponent; + let fixture: ComponentFixture; + let updateServiceMock: Partial & { + toggleAutoCheck: jasmine.Spy; + checkForUpdates: jasmine.Spy; + openDownloadPage: jasmine.Spy; + getReleaseByTag: jasmine.Spy; + }; + let originalElectronAPI: unknown; + + beforeEach(async () => { + updateServiceMock = { + autoCheck: signal(true), + updateAvailable: signal(null), + checking: signal(false), + toggleAutoCheck: jasmine.createSpy('toggleAutoCheck'), + checkForUpdates: jasmine.createSpy('checkForUpdates').and.returnValue(Promise.resolve(null)), + openDownloadPage: jasmine.createSpy('openDownloadPage'), + getReleaseByTag: jasmine.createSpy('getReleaseByTag').and.returnValue(Promise.resolve({ body: 'Release notes' })) + }; + + // Save original electronAPI and mock + originalElectronAPI = (window as unknown as { electronAPI?: unknown }).electronAPI; + (window as unknown as { electronAPI: { getVersion: jasmine.Spy } }).electronAPI = { + getVersion: jasmine.createSpy('getVersion').and.returnValue(Promise.resolve('1.0.0')) + }; + + await TestBed.configureTestingModule({ + imports: [OpenSettingsUpdatesComponent, TranslateModule.forRoot()], + providers: [ + provideNoopAnimations(), + { provide: UpdateService, useValue: updateServiceMock }, + MessageService + ] + }).compileComponents(); + + fixture = TestBed.createComponent(OpenSettingsUpdatesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + // Restore original electronAPI + (window as unknown as { electronAPI?: unknown }).electronAPI = originalElectronAPI; + }); + + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should toggle auto check', () => { + component.updateService.toggleAutoCheck(false); + expect(updateServiceMock.toggleAutoCheck).toHaveBeenCalledWith(false); + }); + + it('should call checkNow', async () => { + await component.checkNow(); + expect(updateServiceMock.checkForUpdates).toHaveBeenCalledWith(true); + }); + + it('should fetch current version on init', async () => { + await fixture.whenStable(); + expect(component.currentVersion).toBe('1.0.0'); + }); + + it('should fetch release notes for current version', async () => { + await fixture.whenStable(); + expect(updateServiceMock.getReleaseByTag).toHaveBeenCalledWith('v1.0.0'); + expect(component.currentReleaseNotes).toBe('Release notes'); + }); + + it('should handle version already starting with v', async () => { + (window as unknown as { electronAPI: { getVersion: jasmine.Spy } }).electronAPI.getVersion + .and.returnValue(Promise.resolve('v2.0.0')); + + await component.ngOnInit(); + await fixture.whenStable(); + + expect(updateServiceMock.getReleaseByTag).toHaveBeenCalledWith('v2.0.0'); + }); + + it('should set lastCheckResult after checkNow', async () => { + const mockResult = { updateAvailable: false, version: '1.0.0', url: '' }; + updateServiceMock.checkForUpdates.and.returnValue(Promise.resolve(mockResult)); + + await component.checkNow(); + + expect(component.lastCheckResult).toEqual(mockResult); + }); + + it('should reset lastCheckResult before check', async () => { + component.lastCheckResult = { updateAvailable: true, version: '2.0.0', url: 'url' }; + updateServiceMock.checkForUpdates.and.returnValue(Promise.resolve(null)); + + await component.checkNow(); + + expect(component.lastCheckResult).toBeNull(); + }); +}); + +describe('OpenSettingsUpdatesComponent without release notes', () => { + let component: OpenSettingsUpdatesComponent; + let fixture: ComponentFixture; + let updateServiceMock: Partial & { + toggleAutoCheck: jasmine.Spy; + checkForUpdates: jasmine.Spy; + openDownloadPage: jasmine.Spy; + getReleaseByTag: jasmine.Spy; + }; + let originalElectronAPI: unknown; + + beforeEach(async () => { + updateServiceMock = { + autoCheck: signal(true), + updateAvailable: signal(null), + checking: signal(false), + toggleAutoCheck: jasmine.createSpy('toggleAutoCheck'), + checkForUpdates: jasmine.createSpy('checkForUpdates').and.returnValue(Promise.resolve(null)), + openDownloadPage: jasmine.createSpy('openDownloadPage'), + getReleaseByTag: jasmine.createSpy('getReleaseByTag').and.returnValue(Promise.resolve(null)) + }; + + originalElectronAPI = (window as unknown as { electronAPI?: unknown }).electronAPI; + (window as unknown as { electronAPI: { getVersion: jasmine.Spy } }).electronAPI = { + getVersion: jasmine.createSpy('getVersion').and.returnValue(Promise.resolve('1.0.0')) + }; + + await TestBed.configureTestingModule({ + imports: [OpenSettingsUpdatesComponent, TranslateModule.forRoot()], + providers: [ + provideNoopAnimations(), + { provide: UpdateService, useValue: updateServiceMock }, + MessageService + ] + }).compileComponents(); + + fixture = TestBed.createComponent(OpenSettingsUpdatesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + (window as unknown as { electronAPI?: unknown }).electronAPI = originalElectronAPI; + }); + + it('should handle null release notes gracefully', async () => { + await fixture.whenStable(); + expect(component.currentReleaseNotes).toBeNull(); + }); +}); diff --git a/src/app/pages/open-settings-updates/open-settings-updates.ts b/src/app/pages/open-settings-updates/open-settings-updates.ts new file mode 100644 index 0000000..4e62b3d --- /dev/null +++ b/src/app/pages/open-settings-updates/open-settings-updates.ts @@ -0,0 +1,188 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ButtonModule } from 'primeng/button'; +import { CardModule } from 'primeng/card'; +import { ToggleSwitchModule } from 'primeng/toggleswitch'; +import { BlockUIModule } from 'primeng/blockui'; +import { ProgressSpinnerModule } from 'primeng/progressspinner'; +import { MessageModule } from 'primeng/message'; +import { PanelModule } from 'primeng/panel'; +import { TranslateModule } from '@ngx-translate/core'; +import { UpdateService } from '../../services/update.service'; +import { UpdateCheckResult } from '../../../types/electron'; +import { SafeMarkdownPipe } from '../../pipes/safe-markdown.pipe'; + +@Component({ + selector: 'app-open-settings-updates', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ButtonModule, + CardModule, + ToggleSwitchModule, + BlockUIModule, + ProgressSpinnerModule, + MessageModule, + PanelModule, + TranslateModule, + SafeMarkdownPipe + ], + template: ` +
+

{{ 'settings.updates.title' | translate }}

+ +
+ +
+ +
+ + + +
+
+
+ + +
+ +
+ +
+ + + + + @if (lastCheckResult !== null && !lastCheckResult.updateAvailable) { +
+ + {{ 'settings.updates.upToDate' | translate }} ({{currentVersion}}). +
+ } +
+ + +
+ + @if (currentReleaseNotes) { +
+ } @else { +

{{ 'settings.updates.noReleaseNotes' | translate }}

+ } +
+
+ + + @if (updateService.updateAvailable(); as update) { +
+
+
+ + {{ 'settings.updates.newVersionAvailable' | translate }}: {{ update.version }} +
+ + +
+ + @if (update.releaseNotes) { +
+

{{ 'settings.updates.releaseNotes' | translate }}

+
+
+ } +
+ } +
+
+
+
+
+ `, + styles: [` + :host { + display: block; + height: 100%; + } + + .markdown-content { + :deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) { + margin-top: 0.5rem; + margin-bottom: 0.5rem; + } + + :deep(p) { + margin: 0.5rem 0; + } + + :deep(ul), :deep(ol) { + margin: 0.5rem 0; + padding-left: 2rem; + } + + :deep(li) { + margin: 0.25rem 0; + } + + :deep(code) { + background: var(--surface-100); + padding: 0.125rem 0.25rem; + border-radius: 4px; + font-size: 0.9em; + } + + :deep(pre) { + background: var(--surface-100); + padding: 1rem; + border-radius: 8px; + overflow-x: auto; + } + + :deep(a) { + color: var(--primary-color); + } + + :deep(blockquote) { + border-left: 4px solid var(--primary-color); + margin: 0.5rem 0; + padding-left: 1rem; + color: var(--text-color-secondary); + } + } + `] +}) +export class OpenSettingsUpdatesComponent implements OnInit { + updateService = inject(UpdateService); + + lastCheckResult: UpdateCheckResult | null = null; + currentVersion = '...'; + currentReleaseNotes: string | null = null; + + async ngOnInit() { + this.currentVersion = await window.electronAPI.getVersion(); + // Fetch release notes for current version + const tag = this.currentVersion.startsWith('v') ? this.currentVersion : `v${this.currentVersion}`; + const release = await this.updateService.getReleaseByTag(tag); + if (release) { + this.currentReleaseNotes = release.body; + } + } + + async checkNow() { + this.lastCheckResult = null; + const result = await this.updateService.checkForUpdates(true); + this.lastCheckResult = result; + } +} diff --git a/src/app/pipes/safe-markdown.pipe.ts b/src/app/pipes/safe-markdown.pipe.ts new file mode 100644 index 0000000..765c510 --- /dev/null +++ b/src/app/pipes/safe-markdown.pipe.ts @@ -0,0 +1,49 @@ +import { Pipe, PipeTransform, SecurityContext, inject } from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; + +/** + * Pipe that safely converts Markdown content to HTML. + * Uses DOMPurify with a strict whitelist of allowed tags and attributes + * to sanitize the HTML before rendering. + * + * Usage: {{ markdownContent | safeMarkdown }} + */ +@Pipe({ + name: 'safeMarkdown', + standalone: true +}) +export class SafeMarkdownPipe implements PipeTransform { + private readonly allowedTags = [ + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'p', 'br', 'hr', + 'ul', 'ol', 'li', + 'a', 'strong', 'em', 'code', 'pre', 'blockquote' + ]; + + private readonly allowedAttributes = ['href', 'target', 'rel']; + + private readonly sanitizer = inject(DomSanitizer); + + transform(content: string | null | undefined): SafeHtml { + if (!content) { + return ''; + } + + // Parse markdown to HTML + const rawHtml = marked.parse(content, { async: false }); + + // Sanitize with DOMPurify using strict whitelist + const sanitizedHtml = DOMPurify.sanitize(rawHtml, { + ALLOWED_TAGS: this.allowedTags, + ALLOWED_ATTR: this.allowedAttributes + }); + + // Use Angular's sanitizer to verify the content is safe + // This returns null if deemed unsafe, otherwise returns the sanitized string + const angularSanitized = this.sanitizer.sanitize(SecurityContext.HTML, sanitizedHtml); + + return angularSanitized ?? ''; + } +} diff --git a/src/app/services/navigation/electron-navigation.service.spec.ts b/src/app/services/navigation/electron-navigation.service.spec.ts index e4791cc..73469d8 100644 --- a/src/app/services/navigation/electron-navigation.service.spec.ts +++ b/src/app/services/navigation/electron-navigation.service.spec.ts @@ -12,10 +12,14 @@ describe('ElectronNavigationService', () => { let mockRouter: jasmine.SpyObj; let mockElectronAPI: MockElectronAPI; let navigationCallback: ((route: string) => void) | null = null; + let originalElectronAPI: unknown; beforeEach(() => { mockRouter = jasmine.createSpyObj('Router', ['navigate']); + // Save original electronAPI + originalElectronAPI = (window as WindowWithOptionalElectronAPI).electronAPI; + // Setup mock electronAPI mockElectronAPI = { onNavigate: jasmine @@ -41,9 +45,11 @@ describe('ElectronNavigationService', () => { afterEach(() => { navigationCallback = null; - delete (window as WindowWithOptionalElectronAPI).electronAPI; + // Restore original electronAPI instead of deleting + (window as WindowWithOptionalElectronAPI).electronAPI = originalElectronAPI as Partial; }); + it('should be created', () => { service = TestBed.inject(ElectronNavigationService); expect(service).toBeTruthy(); diff --git a/src/app/services/update.service.spec.ts b/src/app/services/update.service.spec.ts new file mode 100644 index 0000000..ee98222 --- /dev/null +++ b/src/app/services/update.service.spec.ts @@ -0,0 +1,227 @@ +import { TestBed } from '@angular/core/testing'; +import { UpdateService } from './update.service'; + +describe('UpdateService', () => { + let service: UpdateService; + let originalElectronAPI: unknown; + let mockElectronAPI: { + checkForUpdates: jasmine.Spy; + openExternal: jasmine.Spy; + getReleaseByTag: jasmine.Spy; + }; + + beforeEach(() => { + // Save original electronAPI + originalElectronAPI = (window as unknown as { electronAPI?: unknown }).electronAPI; + + // Create mock + mockElectronAPI = { + checkForUpdates: jasmine.createSpy('checkForUpdates'), + openExternal: jasmine.createSpy('openExternal'), + getReleaseByTag: jasmine.createSpy('getReleaseByTag'), + }; + (window as unknown as { electronAPI: typeof mockElectronAPI }).electronAPI = mockElectronAPI; + + // Clear localStorage + localStorage.clear(); + + TestBed.configureTestingModule({ + providers: [UpdateService], + }); + + service = TestBed.inject(UpdateService); + }); + + afterEach(() => { + // Restore original electronAPI + (window as unknown as { electronAPI?: unknown }).electronAPI = originalElectronAPI; + localStorage.clear(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('constructor', () => { + it('should load autoCheck preference from localStorage', () => { + localStorage.setItem('autoCheckUpdates', 'false'); + + const newService = new UpdateService(); + + expect(newService.autoCheck()).toBe(false); + }); + + it('should default to true when no localStorage value', () => { + expect(service.autoCheck()).toBe(true); + }); + }); + + describe('init', () => { + it('should check for updates when autoCheck is true', () => { + mockElectronAPI.checkForUpdates.and.returnValue(Promise.resolve({ + updateAvailable: false, + version: '1.0.0', + url: '', + })); + + service.init(); + + expect(mockElectronAPI.checkForUpdates).toHaveBeenCalled(); + }); + + it('should not check for updates when autoCheck is false', () => { + service.autoCheck.set(false); + + service.init(); + + expect(mockElectronAPI.checkForUpdates).not.toHaveBeenCalled(); + }); + }); + + describe('toggleAutoCheck', () => { + it('should update autoCheck signal', () => { + service.toggleAutoCheck(false); + + expect(service.autoCheck()).toBe(false); + }); + + it('should save preference to localStorage', () => { + service.toggleAutoCheck(false); + + expect(localStorage.getItem('autoCheckUpdates')).toBe('false'); + }); + }); + + describe('checkForUpdates', () => { + it('should return null if already checking', async () => { + service.checking.set(true); + + const result = await service.checkForUpdates(); + + expect(result).toBeNull(); + expect(mockElectronAPI.checkForUpdates).not.toHaveBeenCalled(); + }); + + it('should set checking to true while checking', async () => { + let resolvePromise: (value: unknown) => void; + const pendingPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockElectronAPI.checkForUpdates.and.returnValue(pendingPromise); + + const checkPromise = service.checkForUpdates(); + expect(service.checking()).toBe(true); + + resolvePromise!({ updateAvailable: false, version: '1.0.0', url: '' }); + await checkPromise; + + expect(service.checking()).toBe(false); + }); + + it('should set updateAvailable when update is available', async () => { + const updateResult = { + updateAvailable: true, + version: '2.0.0', + url: 'https://example.com/release', + }; + mockElectronAPI.checkForUpdates.and.returnValue(Promise.resolve(updateResult)); + + await service.checkForUpdates(); + + expect(service.updateAvailable()).toEqual(updateResult); + }); + + it('should not set updateAvailable when no update is available', async () => { + const noUpdateResult = { + updateAvailable: false, + version: '1.0.0', + url: '', + }; + mockElectronAPI.checkForUpdates.and.returnValue(Promise.resolve(noUpdateResult)); + + await service.checkForUpdates(); + + expect(service.updateAvailable()).toBeNull(); + }); + + it('should return null and log error on failure', async () => { + const consoleSpy = spyOn(console, 'error'); + mockElectronAPI.checkForUpdates.and.returnValue(Promise.reject(new Error('Network error'))); + + const result = await service.checkForUpdates(); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + expect(service.checking()).toBe(false); + }); + + it('should return the result on success', async () => { + const updateResult = { + updateAvailable: true, + version: '2.0.0', + url: 'https://example.com', + }; + mockElectronAPI.checkForUpdates.and.returnValue(Promise.resolve(updateResult)); + + const result = await service.checkForUpdates(); + + expect(result).toEqual(updateResult); + }); + }); + + describe('openDownloadPage', () => { + it('should call openExternal with update URL', () => { + service.updateAvailable.set({ + updateAvailable: true, + version: '2.0.0', + url: 'https://github.com/releases/v2.0.0', + }); + + service.openDownloadPage(); + + expect(mockElectronAPI.openExternal).toHaveBeenCalledWith('https://github.com/releases/v2.0.0'); + }); + + it('should not call openExternal when no update is available', () => { + service.updateAvailable.set(null); + + service.openDownloadPage(); + + expect(mockElectronAPI.openExternal).not.toHaveBeenCalled(); + }); + + it('should not call openExternal when update has no URL', () => { + service.updateAvailable.set({ + updateAvailable: true, + version: '2.0.0', + url: '', + }); + + service.openDownloadPage(); + + expect(mockElectronAPI.openExternal).not.toHaveBeenCalled(); + }); + }); + + describe('getReleaseByTag', () => { + it('should return release data on success', async () => { + const releaseData = { body: 'Release notes for v1.2.3' }; + mockElectronAPI.getReleaseByTag.and.returnValue(Promise.resolve(releaseData)); + + const result = await service.getReleaseByTag('v1.2.3'); + + expect(mockElectronAPI.getReleaseByTag).toHaveBeenCalledWith('v1.2.3'); + expect(result).toEqual(releaseData); + }); + + it('should return null on failure', async () => { + const consoleSpy = spyOn(console, 'error'); + mockElectronAPI.getReleaseByTag.and.returnValue(Promise.reject(new Error('Not found'))); + + const result = await service.getReleaseByTag('v99.99.99'); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/services/update.service.ts b/src/app/services/update.service.ts new file mode 100644 index 0000000..8a74bdd --- /dev/null +++ b/src/app/services/update.service.ts @@ -0,0 +1,73 @@ +import { Injectable, signal } from '@angular/core'; + +interface UpdateCheckResult { + updateAvailable: boolean; + version: string; + url: string; + releaseNotes?: string; +} + +@Injectable({ + providedIn: 'root', +}) +export class UpdateService { + updateAvailable = signal(null); + checking = signal(false); + autoCheck = signal(true); + + constructor() { + // Load auto-check preference + const savedAutoCheck = localStorage.getItem('autoCheckUpdates'); + if (savedAutoCheck !== null) { + this.autoCheck.set(JSON.parse(savedAutoCheck)); + } + } + + init(): void { + if (this.autoCheck()) { + this.checkForUpdates(); + } + } + + toggleAutoCheck(value: boolean): void { + this.autoCheck.set(value); + localStorage.setItem('autoCheckUpdates', JSON.stringify(value)); + } + + async checkForUpdates(manual = false): Promise { + if (this.checking()) return null; + + this.checking.set(true); + try { + const result = await window.electronAPI.checkForUpdates(); + if (result.updateAvailable) { + this.updateAvailable.set(result); + } else if (manual) { + // Clear previous update if any, or just leave it? + // For manual check, we want to know the result regardless. + } + return result; + } catch (error) { + console.error('Error checking for updates:', error); + return null; + } finally { + this.checking.set(false); + } + } + + openDownloadPage(): void { + const update = this.updateAvailable(); + if (update?.url) { + window.electronAPI.openExternal(update.url); + } + } + + async getReleaseByTag(tag: string): Promise<{ body: string } | null> { + try { + return await window.electronAPI.getReleaseByTag(tag); + } catch (error) { + console.error('Error fetching release by tag:', error); + return null; + } + } +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index e789003..c06dbf3 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -365,6 +365,19 @@ "add": "Add", "cannotDeleteDefault": "Cannot delete default statuses", "cannotDeleteInUse": "Cannot delete: status is in use" + }, + "updates": { + "title": "Updates", + "settings": "Settings", + "autoCheck": "Check for updates automatically", + "checkForUpdates": "Check for Updates", + "checkNow": "Check Now", + "upToDate": "You are using the latest version", + "newVersionAvailable": "New version available", + "download": "Download", + "currentVersionNotes": "Current Version Release Notes", + "releaseNotes": "Release Notes", + "noReleaseNotes": "No release notes available." } } -} +} \ No newline at end of file diff --git a/src/assets/i18n/es.json b/src/assets/i18n/es.json index 5bf049b..61feb55 100644 --- a/src/assets/i18n/es.json +++ b/src/assets/i18n/es.json @@ -365,6 +365,19 @@ "add": "Añadir", "cannotDeleteDefault": "No se pueden eliminar los estados predeterminados", "cannotDeleteInUse": "No se puede eliminar: el estado está en uso" + }, + "updates": { + "title": "Actualizaciones", + "settings": "Configuración", + "autoCheck": "Buscar actualizaciones automáticamente", + "checkForUpdates": "Buscar actualizaciones", + "checkNow": "Comprobar ahora", + "upToDate": "Estás usando la última versión", + "newVersionAvailable": "Nueva versión disponible", + "download": "Descargar", + "currentVersionNotes": "Notas de la versión actual", + "releaseNotes": "Notas de la versión", + "noReleaseNotes": "No hay notas de la versión disponibles." } } -} +} \ No newline at end of file diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index add2972..fdf0076 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -159,6 +159,21 @@ export interface DeleteResult { success: boolean; } +export interface UpdateCheckResult { + updateAvailable: boolean; + version: string; + url: string; + releaseNotes?: string; +} + +export interface GitHubRelease { + tag_name: string; + html_url: string; + body: string; + name: string; + published_at: string; +} + declare global { interface Window { electronAPI: { @@ -345,8 +360,17 @@ declare global { // System openExternal: (url: string) => Promise; + + // Updates + checkForUpdates: () => Promise; + + // App Info + getVersion: () => Promise; + + // Release Info + getReleaseByTag: (tag: string) => Promise; }; } } -export {}; +export { }; From 7131941bd35d5d84eeebbd06585497fca19d8f99 Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:49:22 +0100 Subject: [PATCH 4/8] feat: redesign update settings, secure IPC, and fix dialog visibility --- electron/src/preload/preload.ts | 21 +- electron/src/services/ipc/index.ts | 3 + electron/src/services/ipc/system-handlers.ts | 16 + .../update-dialog/update-dialog.component.ts | 214 ++++++++++ .../open-settings-updates.ts | 376 ++++++++++++------ src/app/pipes/safe-markdown.pipe.ts | 87 ++-- src/app/services/update.service.ts | 117 +++--- src/assets/i18n/en.json | 14 +- src/assets/i18n/es.json | 14 +- src/types/electron.d.ts | 4 +- 10 files changed, 645 insertions(+), 221 deletions(-) create mode 100644 electron/src/services/ipc/system-handlers.ts create mode 100644 src/app/components/update-dialog/update-dialog.component.ts diff --git a/electron/src/preload/preload.ts b/electron/src/preload/preload.ts index ab9e07d..9e07353 100644 --- a/electron/src/preload/preload.ts +++ b/electron/src/preload/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer, shell } from 'electron'; +import { contextBridge, ipcRenderer } from 'electron'; /** * Type definitions for database entities @@ -128,8 +128,21 @@ interface BackupResult { path?: string; } +interface GitHubRelease { + tag_name: string; + html_url: string; + body: string; + name: string; + published_at: string; +} + try { const electronAPI = { + // ... + // Release Info + getReleaseByTag: (tag: string): Promise => + ipcRenderer.invoke('get-release-by-tag', tag), + // Projects getProjects: (): Promise => ipcRenderer.invoke('get-projects'), createProject: (name: string, description?: string): Promise => @@ -395,7 +408,8 @@ try { getBackupDir: (): Promise => ipcRenderer.invoke('backup-get-dir'), // System - openExternal: (url: string): Promise => shell.openExternal(url), + openExternal: (url: string): Promise => + ipcRenderer.invoke('open-external', url), // Updates checkForUpdates: (): Promise<{ @@ -407,9 +421,6 @@ try { // App Info getVersion: (): Promise => ipcRenderer.invoke('get-version'), - - // Release Info - getReleaseByTag: (tag: string): Promise => ipcRenderer.invoke('get-release-by-tag', tag), }; contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/electron/src/services/ipc/index.ts b/electron/src/services/ipc/index.ts index bc11c49..552b161 100644 --- a/electron/src/services/ipc/index.ts +++ b/electron/src/services/ipc/index.ts @@ -7,6 +7,8 @@ import { setupLanguageHandlers } from './language-handlers.js'; import { setupBackupHandlers } from './backup-handlers.js'; import { setupUpdateHandlers } from './update-handlers.js'; +import { setupSystemHandlers } from './system-handlers.js'; + /** * Sets up all IPC handlers */ @@ -24,4 +26,5 @@ export const setupIpcHandlers = ( setupBackupHandlers(backupService); } setupUpdateHandlers(updateService); + setupSystemHandlers(); }; diff --git a/electron/src/services/ipc/system-handlers.ts b/electron/src/services/ipc/system-handlers.ts new file mode 100644 index 0000000..9d15e62 --- /dev/null +++ b/electron/src/services/ipc/system-handlers.ts @@ -0,0 +1,16 @@ +import { ipcMain, shell } from 'electron'; + +/** + * Sets up system-related IPC handlers + */ +export const setupSystemHandlers = (): void => { + // Open external links + ipcMain.handle('open-external', async (_event, url: string) => { + try { + await shell.openExternal(url); + } catch (error) { + console.error('Error opening external URL:', error); + throw error; + } + }); +}; diff --git a/src/app/components/update-dialog/update-dialog.component.ts b/src/app/components/update-dialog/update-dialog.component.ts new file mode 100644 index 0000000..ec5f56c --- /dev/null +++ b/src/app/components/update-dialog/update-dialog.component.ts @@ -0,0 +1,214 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { DialogModule } from 'primeng/dialog'; +import { ButtonModule } from 'primeng/button'; +import { TranslateModule } from '@ngx-translate/core'; +import { UpdateCheckResult } from '../../../types/electron'; +import { SafeMarkdownPipe } from '../../pipes/safe-markdown.pipe'; + +@Component({ + selector: 'app-update-dialog', + standalone: true, + imports: [ + CommonModule, + DialogModule, + ButtonModule, + TranslateModule, + SafeMarkdownPipe, + ], + template: ` + + @if (updateInfo) { +
+ + @if (!isCurrentVersion) { +
+ +
+
+ {{ 'settings.updates.newVersion' | translate }}: + {{ updateInfo.version }} +
+
+ {{ 'settings.updates.currentVersion' | translate }}: + {{ currentVersion }} +
+
+
+ } @else { +
+ +
+ {{ updateInfo.version }} +
+
+ } + + + @if (releaseDate) { +
+ + {{ 'settings.updates.releaseDate' | translate }}: + {{ releaseDate | date: 'medium' }} +
+ } + + + @if (updateInfo.releaseNotes) { +
+

+ {{ 'settings.updates.whatIsNew' | translate }} +

+
+
+ } +
+ + + +
+ @if (!isCurrentVersion) { + + + + + } @else { + + + } +
+
+ } +
+ `, + styles: [ + ` + .markdown-content { + :deep(h1), + :deep(h2), + :deep(h3), + :deep(h4), + :deep(h5), + :deep(h6) { + margin-top: 0.5rem; + margin-bottom: 0.5rem; + } + + :deep(p) { + margin: 0.5rem 0; + } + + :deep(ul), + :deep(ol) { + margin: 0.5rem 0; + padding-left: 2rem; + } + + :deep(li) { + margin: 0.25rem 0; + } + + :deep(code) { + background: var(--surface-100); + padding: 0.125rem 0.25rem; + border-radius: 4px; + font-size: 0.9em; + } + + :deep(pre) { + background: var(--surface-100); + padding: 1rem; + border-radius: 8px; + overflow-x: auto; + } + + :deep(a) { + color: var(--primary-color); + text-decoration: underline; + cursor: pointer; + } + + :deep(blockquote) { + border-left: 4px solid var(--primary-color); + margin: 0.5rem 0; + padding-left: 1rem; + color: var(--text-color-secondary); + } + } + `, + ], +}) +export class UpdateDialogComponent { + @Input() visible = false; + @Input() updateInfo: UpdateCheckResult | null = null; + @Input() currentVersion = ''; + @Input() releaseDate: Date | null = null; + @Input() isCurrentVersion = false; + + @Output() visibleChange = new EventEmitter(); + @Output() download = new EventEmitter(); + + onClose() { + this.visible = false; + this.visibleChange.emit(false); + } + + onDownload() { + this.download.emit(); + this.onClose(); + } + + handleLinkClick(event: Event) { + const target = event.target as HTMLElement; + const anchor = target.closest('a'); + + if (anchor && anchor.href) { + event.preventDefault(); + if (globalThis.window?.electronAPI) { + globalThis.window.electronAPI.openExternal(anchor.href); + } else { + window.open(anchor.href, '_blank'); + } + } + } +} diff --git a/src/app/pages/open-settings-updates/open-settings-updates.ts b/src/app/pages/open-settings-updates/open-settings-updates.ts index 4e62b3d..fce75e1 100644 --- a/src/app/pages/open-settings-updates/open-settings-updates.ts +++ b/src/app/pages/open-settings-updates/open-settings-updates.ts @@ -1,17 +1,20 @@ -import { Component, inject, OnInit } from '@angular/core'; +import { + Component, + inject, + OnInit, + NgZone, + ChangeDetectorRef, +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ButtonModule } from 'primeng/button'; import { CardModule } from 'primeng/card'; import { ToggleSwitchModule } from 'primeng/toggleswitch'; -import { BlockUIModule } from 'primeng/blockui'; -import { ProgressSpinnerModule } from 'primeng/progressspinner'; import { MessageModule } from 'primeng/message'; -import { PanelModule } from 'primeng/panel'; import { TranslateModule } from '@ngx-translate/core'; import { UpdateService } from '../../services/update.service'; import { UpdateCheckResult } from '../../../types/electron'; -import { SafeMarkdownPipe } from '../../pipes/safe-markdown.pipe'; +import { UpdateDialogComponent } from '../../components/update-dialog/update-dialog.component'; @Component({ selector: 'app-open-settings-updates', @@ -22,87 +25,140 @@ import { SafeMarkdownPipe } from '../../pipes/safe-markdown.pipe'; ButtonModule, CardModule, ToggleSwitchModule, - BlockUIModule, - ProgressSpinnerModule, MessageModule, - PanelModule, TranslateModule, - SafeMarkdownPipe + UpdateDialogComponent, ], template: `

{{ 'settings.updates.title' | translate }}

- -
- -
- - - -
-
-
- - +
- -
- -
- - - - - @if (lastCheckResult !== null && !lastCheckResult.updateAvailable) { -
- - {{ 'settings.updates.upToDate' | translate }} ({{currentVersion}}). -
- } + + +
+

+ {{ 'settings.updates.checkForUpdates' | translate }} +

+
+
+ +
+ +
+
+ + {{ 'settings.updates.currentVersion' | translate }}: + {{ currentVersion }} +
+ +
+ + +
+
+ + +
+ +
- -
- - @if (currentReleaseNotes) { -
- } @else { -

{{ 'settings.updates.noReleaseNotes' | translate }}

- } -
-
- - - @if (updateService.updateAvailable(); as update) { -
-
-
- - {{ 'settings.updates.newVersionAvailable' | translate }}: {{ update.version }} + +
+
+ + + + + @if ( + lastCheckResult !== null && !lastCheckResult.updateAvailable + ) { +
+ + {{ + 'settings.updates.upToDate' | translate + }}
- + } +
+ + + @if (updateService.updateAvailable(); as update) { + + + + + + +
+
- - @if (update.releaseNotes) { -
-

{{ 'settings.updates.releaseNotes' | translate }}

-
-
- } + } +
+ + +
+ + +
+ + + @if (updateService.lastChecked(); as lastChecked) { +
+ + {{ 'settings.updates.lastChecked' | translate }}: + {{ lastChecked | date: 'medium' }}
}
@@ -110,79 +166,137 @@ import { SafeMarkdownPipe } from '../../pipes/safe-markdown.pipe';
+ + + + + + + + `, - styles: [` - :host { - display: block; - height: 100%; - } - - .markdown-content { - :deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) { - margin-top: 0.5rem; - margin-bottom: 0.5rem; - } - - :deep(p) { - margin: 0.5rem 0; - } - - :deep(ul), :deep(ol) { - margin: 0.5rem 0; - padding-left: 2rem; - } - - :deep(li) { - margin: 0.25rem 0; - } - - :deep(code) { - background: var(--surface-100); - padding: 0.125rem 0.25rem; - border-radius: 4px; - font-size: 0.9em; + styles: [ + ` + :host { + display: block; + height: 100%; } - - :deep(pre) { - background: var(--surface-100); - padding: 1rem; - border-radius: 8px; - overflow-x: auto; - } - - :deep(a) { - color: var(--primary-color); - } - - :deep(blockquote) { - border-left: 4px solid var(--primary-color); - margin: 0.5rem 0; - padding-left: 1rem; - color: var(--text-color-secondary); - } - } - `] + `, + ], }) export class OpenSettingsUpdatesComponent implements OnInit { updateService = inject(UpdateService); + private ngZone = inject(NgZone); + private cdr = inject(ChangeDetectorRef); lastCheckResult: UpdateCheckResult | null = null; currentVersion = '...'; - currentReleaseNotes: string | null = null; + dialogVisible = false; + updateReleaseDate: Date | null = null; + currentVersionDialogVisible = false; + currentVersionReleaseNotes: string | null = null; - async ngOnInit() { - this.currentVersion = await window.electronAPI.getVersion(); - // Fetch release notes for current version - const tag = this.currentVersion.startsWith('v') ? this.currentVersion : `v${this.currentVersion}`; - const release = await this.updateService.getReleaseByTag(tag); - if (release) { - this.currentReleaseNotes = release.body; + ngOnInit() { + if (globalThis.window?.electronAPI) { + // Use promise chain to ensure we handle the zone correctly from the start + globalThis.window.electronAPI.getVersion().then((version) => { + this.ngZone.run(() => { + this.currentVersion = version; + // Fetch notes after getting version + this.fetchCurrentVersionNotes(); + }); + }); } } async checkNow() { this.lastCheckResult = null; const result = await this.updateService.checkForUpdates(true); - this.lastCheckResult = result; + + this.ngZone.run(async () => { + this.lastCheckResult = result; + // If update is available, show the dialog + if (result?.updateAvailable) { + await this.fetchUpdateReleaseDate(result.version); + this.showUpdateDialog(); + } + }); + } + + showUpdateDialog() { + const update = this.updateService.updateAvailable(); + if (update) { + this.fetchUpdateReleaseDate(update.version); + this.dialogVisible = true; + } + } + + async fetchUpdateReleaseDate(version: string) { + const tag = version.startsWith('v') ? version : `v${version}`; + const release = await this.updateService.getReleaseByTag(tag); + if (release && 'published_at' in release) { + this.ngZone.run(() => { + this.updateReleaseDate = new Date(release.published_at); + }); + } + } + + handleDownload() { + this.updateService.openDownloadPage(); + } + + openGitHubReleases(event: Event) { + event.preventDefault(); + const repoUrl = 'https://github.com/altaskur/OpenTimeTracker/releases'; + if (globalThis.window?.electronAPI) { + globalThis.window.electronAPI.openExternal(repoUrl); + } else { + window.open(repoUrl, '_blank'); + } + } + + async fetchCurrentVersionNotes() { + const tag = this.currentVersion.startsWith('v') + ? this.currentVersion + : `v${this.currentVersion}`; + const release = await this.updateService.getReleaseByTag(tag); + if (release) { + this.ngZone.run(() => { + this.currentVersionReleaseNotes = release.body; + this.cdr.detectChanges(); + }); + } + } + + showCurrentVersionNotes() { + // Use setTimeout to ensure we are in a clean cycle and force update + setTimeout(() => { + this.ngZone.run(() => { + this.currentVersionDialogVisible = true; + this.cdr.detectChanges(); + }); + }, 0); + + // Ensure we have the notes, if not fetch them again + if (!this.currentVersionReleaseNotes) { + this.fetchCurrentVersionNotes(); + } } } diff --git a/src/app/pipes/safe-markdown.pipe.ts b/src/app/pipes/safe-markdown.pipe.ts index 765c510..a715031 100644 --- a/src/app/pipes/safe-markdown.pipe.ts +++ b/src/app/pipes/safe-markdown.pipe.ts @@ -7,43 +7,76 @@ import DOMPurify from 'dompurify'; * Pipe that safely converts Markdown content to HTML. * Uses DOMPurify with a strict whitelist of allowed tags and attributes * to sanitize the HTML before rendering. - * + * * Usage: {{ markdownContent | safeMarkdown }} */ @Pipe({ - name: 'safeMarkdown', - standalone: true + name: 'safeMarkdown', + standalone: true, }) export class SafeMarkdownPipe implements PipeTransform { - private readonly allowedTags = [ - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - 'p', 'br', 'hr', - 'ul', 'ol', 'li', - 'a', 'strong', 'em', 'code', 'pre', 'blockquote' - ]; + private readonly allowedTags = [ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'p', + 'br', + 'hr', + 'ul', + 'ol', + 'li', + 'a', + 'strong', + 'em', + 'code', + 'pre', + 'blockquote', + ]; - private readonly allowedAttributes = ['href', 'target', 'rel']; + private readonly allowedAttributes = ['href', 'target', 'rel', 'class']; - private readonly sanitizer = inject(DomSanitizer); + private readonly sanitizer = inject(DomSanitizer); - transform(content: string | null | undefined): SafeHtml { - if (!content) { - return ''; - } + constructor() { + // Configure marked to open links in new tabs using extensions + marked.use({ + gfm: true, // GitHub Flavored Markdown + breaks: true, // Convert \n to
+ renderer: { + link({ href, title, text }) { + const titleAttr = title ? ` title="${title}"` : ''; + return `${text}`; + }, + }, + }); + } - // Parse markdown to HTML - const rawHtml = marked.parse(content, { async: false }); + transform(content: string | null | undefined): SafeHtml { + if (!content) { + return ''; + } - // Sanitize with DOMPurify using strict whitelist - const sanitizedHtml = DOMPurify.sanitize(rawHtml, { - ALLOWED_TAGS: this.allowedTags, - ALLOWED_ATTR: this.allowedAttributes - }); + // Parse markdown to HTML + const rawHtml = marked.parse(content, { async: false }); - // Use Angular's sanitizer to verify the content is safe - // This returns null if deemed unsafe, otherwise returns the sanitized string - const angularSanitized = this.sanitizer.sanitize(SecurityContext.HTML, sanitizedHtml); + // Sanitize with DOMPurify using strict whitelist + // ADD_ATTR ensures target and rel attributes are preserved + const sanitizedHtml = DOMPurify.sanitize(rawHtml, { + ALLOWED_TAGS: this.allowedTags, + ALLOWED_ATTR: this.allowedAttributes, + ADD_ATTR: ['target', 'rel'], + }); - return angularSanitized ?? ''; - } + // Use Angular's sanitizer to verify the content is safe + // This returns null if deemed unsafe, otherwise returns the sanitized string + const angularSanitized = this.sanitizer.sanitize( + SecurityContext.HTML, + sanitizedHtml, + ); + + return angularSanitized ?? ''; + } } diff --git a/src/app/services/update.service.ts b/src/app/services/update.service.ts index 8a74bdd..8eed909 100644 --- a/src/app/services/update.service.ts +++ b/src/app/services/update.service.ts @@ -1,73 +1,86 @@ import { Injectable, signal } from '@angular/core'; +import { GitHubRelease } from '../../types/electron'; interface UpdateCheckResult { - updateAvailable: boolean; - version: string; - url: string; - releaseNotes?: string; + updateAvailable: boolean; + version: string; + url: string; + releaseNotes?: string | null; } @Injectable({ - providedIn: 'root', + providedIn: 'root', }) export class UpdateService { - updateAvailable = signal(null); - checking = signal(false); - autoCheck = signal(true); + updateAvailable = signal(null); + checking = signal(false); + autoCheck = signal(true); + lastChecked = signal(null); - constructor() { - // Load auto-check preference - const savedAutoCheck = localStorage.getItem('autoCheckUpdates'); - if (savedAutoCheck !== null) { - this.autoCheck.set(JSON.parse(savedAutoCheck)); - } + constructor() { + // Load auto-check preference + const savedAutoCheck = localStorage.getItem('autoCheckUpdates'); + if (savedAutoCheck !== null) { + this.autoCheck.set(JSON.parse(savedAutoCheck)); } + } - init(): void { - if (this.autoCheck()) { - this.checkForUpdates(); - } + init(): void { + if (this.autoCheck()) { + this.checkForUpdates(); } + } - toggleAutoCheck(value: boolean): void { - this.autoCheck.set(value); - localStorage.setItem('autoCheckUpdates', JSON.stringify(value)); - } + toggleAutoCheck(value: boolean): void { + this.autoCheck.set(value); + localStorage.setItem('autoCheckUpdates', JSON.stringify(value)); + } - async checkForUpdates(manual = false): Promise { - if (this.checking()) return null; + async checkForUpdates(manual = false): Promise { + if (this.checking()) return null; - this.checking.set(true); - try { - const result = await window.electronAPI.checkForUpdates(); - if (result.updateAvailable) { - this.updateAvailable.set(result); - } else if (manual) { - // Clear previous update if any, or just leave it? - // For manual check, we want to know the result regardless. - } - return result; - } catch (error) { - console.error('Error checking for updates:', error); - return null; - } finally { - this.checking.set(false); - } + this.checking.set(true); + this.checking.set(true); + try { + if (!globalThis.window?.electronAPI) { + console.warn('Electron API not available'); + return null; + } + const result = await globalThis.window.electronAPI.checkForUpdates(); + if (result.updateAvailable) { + this.updateAvailable.set(result); + } else if (manual) { + // Clear previous update if any, or just leave it? + // For manual check, we want to know the result regardless. + } + this.lastChecked.set(new Date()); + return result; + } catch (error) { + console.error('Error checking for updates:', error); + return null; + } finally { + this.checking.set(false); } + } - openDownloadPage(): void { - const update = this.updateAvailable(); - if (update?.url) { - window.electronAPI.openExternal(update.url); - } + openDownloadPage(): void { + const update = this.updateAvailable(); + if (update?.url) { + if (globalThis.window?.electronAPI) { + globalThis.window.electronAPI.openExternal(update.url); + } else { + window.open(update.url, '_blank'); + } } + } - async getReleaseByTag(tag: string): Promise<{ body: string } | null> { - try { - return await window.electronAPI.getReleaseByTag(tag); - } catch (error) { - console.error('Error fetching release by tag:', error); - return null; - } + async getReleaseByTag(tag: string): Promise { + try { + if (!globalThis.window?.electronAPI) return null; + return await globalThis.window.electronAPI.getReleaseByTag(tag); + } catch (error) { + console.error('Error fetching release by tag:', error); + return null; } + } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index c06dbf3..7d35ed0 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -377,7 +377,17 @@ "download": "Download", "currentVersionNotes": "Current Version Release Notes", "releaseNotes": "Release Notes", - "noReleaseNotes": "No release notes available." + "noReleaseNotes": "No release notes available.", + "currentVersion": "Current Version", + "viewDetails": "View Details", + "viewReleasesOnGitHub": "View Releases on GitHub", + "lastChecked": "Last checked", + "updateAvailable": "Update Available", + "newVersion": "New Version", + "releaseDate": "Release Date", + "whatIsNew": "What's New", + "later": "Later", + "viewReleaseNotes": "View Release Notes" } } -} \ No newline at end of file +} diff --git a/src/assets/i18n/es.json b/src/assets/i18n/es.json index 61feb55..3e0d88a 100644 --- a/src/assets/i18n/es.json +++ b/src/assets/i18n/es.json @@ -377,7 +377,17 @@ "download": "Descargar", "currentVersionNotes": "Notas de la versión actual", "releaseNotes": "Notas de la versión", - "noReleaseNotes": "No hay notas de la versión disponibles." + "noReleaseNotes": "No hay notas de la versión disponibles.", + "currentVersion": "Versión actual", + "viewDetails": "Ver detalles", + "viewReleasesOnGitHub": "Ver lanzamientos en GitHub", + "lastChecked": "Última comprobación", + "updateAvailable": "Actualización disponible", + "newVersion": "Nueva versión", + "releaseDate": "Fecha de lanzamiento", + "whatIsNew": "Novedades", + "later": "Más tarde", + "viewReleaseNotes": "Ver notas de la versión" } } -} \ No newline at end of file +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index fdf0076..b80bcb8 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -163,7 +163,7 @@ export interface UpdateCheckResult { updateAvailable: boolean; version: string; url: string; - releaseNotes?: string; + releaseNotes?: string | null; } export interface GitHubRelease { @@ -373,4 +373,4 @@ declare global { } } -export { }; +export {}; From 443dc4ebf7dd0cdbbebce678e14b296606ab815d Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:27:22 +0100 Subject: [PATCH 5/8] fix: resolve unit test failures in update service mocks --- src/app/app.spec.ts | 14 +- .../open-settings-updates.spec.ts | 325 +++++++++------- src/app/services/update.service.spec.ts | 355 ++++++++++-------- 3 files changed, 380 insertions(+), 314 deletions(-) diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 9c271eb..fa9e20d 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -18,6 +18,7 @@ describe('App', () => { autoCheck: ReturnType>; updateAvailable: ReturnType>; checking: ReturnType>; + lastChecked: ReturnType>; }; let undoCallback: () => void; let redoCallback: () => void; @@ -72,7 +73,9 @@ describe('App', () => { }), checkForUpdates: jasmine .createSpy('checkForUpdates') - .and.returnValue(Promise.resolve({ updateAvailable: false, version: '', url: '' })), + .and.returnValue( + Promise.resolve({ updateAvailable: false, version: '', url: '' }), + ), }; mockUpdateService = { @@ -80,10 +83,12 @@ describe('App', () => { autoCheck: signal(true), updateAvailable: signal(null), checking: signal(false), + lastChecked: signal(null), }; // Save original electronAPI and set mock - originalElectronAPI = (window as unknown as { electronAPI?: unknown }).electronAPI; + originalElectronAPI = (window as unknown as { electronAPI?: unknown }) + .electronAPI; ( window as unknown as { electronAPI?: typeof mockElectronAPI } ).electronAPI = mockElectronAPI; @@ -100,7 +105,8 @@ describe('App', () => { afterEach(() => { // Restore original electronAPI - (window as unknown as { electronAPI?: unknown }).electronAPI = originalElectronAPI; + (window as unknown as { electronAPI?: unknown }).electronAPI = + originalElectronAPI; }); it('should create the app', () => { @@ -186,6 +192,7 @@ describe('App without electronAPI', () => { autoCheck: ReturnType>; updateAvailable: ReturnType>; checking: ReturnType>; + lastChecked: ReturnType>; }; let savedElectronAPI: unknown; @@ -199,6 +206,7 @@ describe('App without electronAPI', () => { autoCheck: signal(true), updateAvailable: signal(null), checking: signal(false), + lastChecked: signal(null), }; await TestBed.configureTestingModule({ diff --git a/src/app/pages/open-settings-updates/open-settings-updates.spec.ts b/src/app/pages/open-settings-updates/open-settings-updates.spec.ts index 364dbc1..1a2584f 100644 --- a/src/app/pages/open-settings-updates/open-settings-updates.spec.ts +++ b/src/app/pages/open-settings-updates/open-settings-updates.spec.ts @@ -6,156 +6,189 @@ import { signal } from '@angular/core'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { TranslateModule } from '@ngx-translate/core'; - describe('OpenSettingsUpdatesComponent', () => { - let component: OpenSettingsUpdatesComponent; - let fixture: ComponentFixture; - let updateServiceMock: Partial & { - toggleAutoCheck: jasmine.Spy; - checkForUpdates: jasmine.Spy; - openDownloadPage: jasmine.Spy; - getReleaseByTag: jasmine.Spy; + let component: OpenSettingsUpdatesComponent; + let fixture: ComponentFixture; + let updateServiceMock: Partial & { + toggleAutoCheck: jasmine.Spy; + checkForUpdates: jasmine.Spy; + openDownloadPage: jasmine.Spy; + getReleaseByTag: jasmine.Spy; + }; + let originalElectronAPI: unknown; + + beforeEach(async () => { + updateServiceMock = { + autoCheck: signal(true), + updateAvailable: signal(null), + checking: signal(false), + toggleAutoCheck: jasmine.createSpy('toggleAutoCheck'), + checkForUpdates: jasmine + .createSpy('checkForUpdates') + .and.returnValue(Promise.resolve(null)), + openDownloadPage: jasmine.createSpy('openDownloadPage'), + getReleaseByTag: jasmine.createSpy('getReleaseByTag').and.returnValue( + Promise.resolve({ + tag_name: 'v1.0.0', + html_url: 'https://example.com/releases/v1.0.0', + body: 'Release notes', + name: 'Release v1.0.0', + published_at: '2023-01-01T00:00:00Z', + }), + ), + lastChecked: signal(null), + }; + + // Save original electronAPI and mock + originalElectronAPI = (window as unknown as { electronAPI?: unknown }) + .electronAPI; + ( + window as unknown as { electronAPI: { getVersion: jasmine.Spy } } + ).electronAPI = { + getVersion: jasmine + .createSpy('getVersion') + .and.returnValue(Promise.resolve('1.0.0')), + }; + + await TestBed.configureTestingModule({ + imports: [OpenSettingsUpdatesComponent, TranslateModule.forRoot()], + providers: [ + provideNoopAnimations(), + { provide: UpdateService, useValue: updateServiceMock }, + MessageService, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(OpenSettingsUpdatesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + // Restore original electronAPI + (window as unknown as { electronAPI?: unknown }).electronAPI = + originalElectronAPI; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should toggle auto check', () => { + component.updateService.toggleAutoCheck(false); + expect(updateServiceMock.toggleAutoCheck).toHaveBeenCalledWith(false); + }); + + it('should call checkNow', async () => { + await component.checkNow(); + expect(updateServiceMock.checkForUpdates).toHaveBeenCalledWith(true); + }); + + it('should fetch current version on init', async () => { + await fixture.whenStable(); + expect(component.currentVersion).toBe('1.0.0'); + }); + + it('should fetch release notes for current version', async () => { + await fixture.whenStable(); + expect(updateServiceMock.getReleaseByTag).toHaveBeenCalledWith('v1.0.0'); + expect(component.currentVersionReleaseNotes).toBe('Release notes'); + }); + + it('should handle version already starting with v', async () => { + ( + window as unknown as { electronAPI: { getVersion: jasmine.Spy } } + ).electronAPI.getVersion.and.returnValue(Promise.resolve('v2.0.0')); + + await component.ngOnInit(); + await fixture.whenStable(); + + expect(updateServiceMock.getReleaseByTag).toHaveBeenCalledWith('v2.0.0'); + }); + + it('should set lastCheckResult after checkNow', async () => { + const mockResult = { updateAvailable: false, version: '1.0.0', url: '' }; + updateServiceMock.checkForUpdates.and.returnValue( + Promise.resolve(mockResult), + ); + + await component.checkNow(); + + expect(component.lastCheckResult).toEqual(mockResult); + }); + + it('should reset lastCheckResult before check', async () => { + component.lastCheckResult = { + updateAvailable: true, + version: '2.0.0', + url: 'url', }; - let originalElectronAPI: unknown; - - beforeEach(async () => { - updateServiceMock = { - autoCheck: signal(true), - updateAvailable: signal(null), - checking: signal(false), - toggleAutoCheck: jasmine.createSpy('toggleAutoCheck'), - checkForUpdates: jasmine.createSpy('checkForUpdates').and.returnValue(Promise.resolve(null)), - openDownloadPage: jasmine.createSpy('openDownloadPage'), - getReleaseByTag: jasmine.createSpy('getReleaseByTag').and.returnValue(Promise.resolve({ body: 'Release notes' })) - }; - - // Save original electronAPI and mock - originalElectronAPI = (window as unknown as { electronAPI?: unknown }).electronAPI; - (window as unknown as { electronAPI: { getVersion: jasmine.Spy } }).electronAPI = { - getVersion: jasmine.createSpy('getVersion').and.returnValue(Promise.resolve('1.0.0')) - }; - - await TestBed.configureTestingModule({ - imports: [OpenSettingsUpdatesComponent, TranslateModule.forRoot()], - providers: [ - provideNoopAnimations(), - { provide: UpdateService, useValue: updateServiceMock }, - MessageService - ] - }).compileComponents(); - - fixture = TestBed.createComponent(OpenSettingsUpdatesComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - afterEach(() => { - // Restore original electronAPI - (window as unknown as { electronAPI?: unknown }).electronAPI = originalElectronAPI; - }); - - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should toggle auto check', () => { - component.updateService.toggleAutoCheck(false); - expect(updateServiceMock.toggleAutoCheck).toHaveBeenCalledWith(false); - }); - - it('should call checkNow', async () => { - await component.checkNow(); - expect(updateServiceMock.checkForUpdates).toHaveBeenCalledWith(true); - }); - - it('should fetch current version on init', async () => { - await fixture.whenStable(); - expect(component.currentVersion).toBe('1.0.0'); - }); - - it('should fetch release notes for current version', async () => { - await fixture.whenStable(); - expect(updateServiceMock.getReleaseByTag).toHaveBeenCalledWith('v1.0.0'); - expect(component.currentReleaseNotes).toBe('Release notes'); - }); - - it('should handle version already starting with v', async () => { - (window as unknown as { electronAPI: { getVersion: jasmine.Spy } }).electronAPI.getVersion - .and.returnValue(Promise.resolve('v2.0.0')); - - await component.ngOnInit(); - await fixture.whenStable(); - - expect(updateServiceMock.getReleaseByTag).toHaveBeenCalledWith('v2.0.0'); - }); - - it('should set lastCheckResult after checkNow', async () => { - const mockResult = { updateAvailable: false, version: '1.0.0', url: '' }; - updateServiceMock.checkForUpdates.and.returnValue(Promise.resolve(mockResult)); - - await component.checkNow(); - - expect(component.lastCheckResult).toEqual(mockResult); - }); - - it('should reset lastCheckResult before check', async () => { - component.lastCheckResult = { updateAvailable: true, version: '2.0.0', url: 'url' }; - updateServiceMock.checkForUpdates.and.returnValue(Promise.resolve(null)); - - await component.checkNow(); - - expect(component.lastCheckResult).toBeNull(); - }); + updateServiceMock.checkForUpdates.and.returnValue(Promise.resolve(null)); + + await component.checkNow(); + + expect(component.lastCheckResult).toBeNull(); + }); }); describe('OpenSettingsUpdatesComponent without release notes', () => { - let component: OpenSettingsUpdatesComponent; - let fixture: ComponentFixture; - let updateServiceMock: Partial & { - toggleAutoCheck: jasmine.Spy; - checkForUpdates: jasmine.Spy; - openDownloadPage: jasmine.Spy; - getReleaseByTag: jasmine.Spy; + let component: OpenSettingsUpdatesComponent; + let fixture: ComponentFixture; + let updateServiceMock: Partial & { + toggleAutoCheck: jasmine.Spy; + checkForUpdates: jasmine.Spy; + openDownloadPage: jasmine.Spy; + getReleaseByTag: jasmine.Spy; + }; + let originalElectronAPI: unknown; + + beforeEach(async () => { + updateServiceMock = { + autoCheck: signal(true), + updateAvailable: signal(null), + checking: signal(false), + toggleAutoCheck: jasmine.createSpy('toggleAutoCheck'), + checkForUpdates: jasmine + .createSpy('checkForUpdates') + .and.returnValue(Promise.resolve(null)), + openDownloadPage: jasmine.createSpy('openDownloadPage'), + getReleaseByTag: jasmine + .createSpy('getReleaseByTag') + .and.returnValue(Promise.resolve(null)), + lastChecked: signal(null), }; - let originalElectronAPI: unknown; - - beforeEach(async () => { - updateServiceMock = { - autoCheck: signal(true), - updateAvailable: signal(null), - checking: signal(false), - toggleAutoCheck: jasmine.createSpy('toggleAutoCheck'), - checkForUpdates: jasmine.createSpy('checkForUpdates').and.returnValue(Promise.resolve(null)), - openDownloadPage: jasmine.createSpy('openDownloadPage'), - getReleaseByTag: jasmine.createSpy('getReleaseByTag').and.returnValue(Promise.resolve(null)) - }; - - originalElectronAPI = (window as unknown as { electronAPI?: unknown }).electronAPI; - (window as unknown as { electronAPI: { getVersion: jasmine.Spy } }).electronAPI = { - getVersion: jasmine.createSpy('getVersion').and.returnValue(Promise.resolve('1.0.0')) - }; - - await TestBed.configureTestingModule({ - imports: [OpenSettingsUpdatesComponent, TranslateModule.forRoot()], - providers: [ - provideNoopAnimations(), - { provide: UpdateService, useValue: updateServiceMock }, - MessageService - ] - }).compileComponents(); - - fixture = TestBed.createComponent(OpenSettingsUpdatesComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - afterEach(() => { - (window as unknown as { electronAPI?: unknown }).electronAPI = originalElectronAPI; - }); - - it('should handle null release notes gracefully', async () => { - await fixture.whenStable(); - expect(component.currentReleaseNotes).toBeNull(); - }); + + originalElectronAPI = (window as unknown as { electronAPI?: unknown }) + .electronAPI; + ( + window as unknown as { electronAPI: { getVersion: jasmine.Spy } } + ).electronAPI = { + getVersion: jasmine + .createSpy('getVersion') + .and.returnValue(Promise.resolve('1.0.0')), + }; + + await TestBed.configureTestingModule({ + imports: [OpenSettingsUpdatesComponent, TranslateModule.forRoot()], + providers: [ + provideNoopAnimations(), + { provide: UpdateService, useValue: updateServiceMock }, + MessageService, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(OpenSettingsUpdatesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + (window as unknown as { electronAPI?: unknown }).electronAPI = + originalElectronAPI; + }); + + it('should handle null release notes gracefully', async () => { + await fixture.whenStable(); + expect(component.currentVersionReleaseNotes).toBeNull(); + }); }); diff --git a/src/app/services/update.service.spec.ts b/src/app/services/update.service.spec.ts index ee98222..ef8f30e 100644 --- a/src/app/services/update.service.spec.ts +++ b/src/app/services/update.service.spec.ts @@ -2,226 +2,251 @@ import { TestBed } from '@angular/core/testing'; import { UpdateService } from './update.service'; describe('UpdateService', () => { - let service: UpdateService; - let originalElectronAPI: unknown; - let mockElectronAPI: { - checkForUpdates: jasmine.Spy; - openExternal: jasmine.Spy; - getReleaseByTag: jasmine.Spy; + let service: UpdateService; + let originalElectronAPI: unknown; + let mockElectronAPI: { + checkForUpdates: jasmine.Spy; + openExternal: jasmine.Spy; + getReleaseByTag: jasmine.Spy; + }; + + beforeEach(() => { + // Save original electronAPI + originalElectronAPI = (window as unknown as { electronAPI?: unknown }) + .electronAPI; + + // Create mock + mockElectronAPI = { + checkForUpdates: jasmine.createSpy('checkForUpdates'), + openExternal: jasmine.createSpy('openExternal'), + getReleaseByTag: jasmine.createSpy('getReleaseByTag'), }; + (window as unknown as { electronAPI: typeof mockElectronAPI }).electronAPI = + mockElectronAPI; - beforeEach(() => { - // Save original electronAPI - originalElectronAPI = (window as unknown as { electronAPI?: unknown }).electronAPI; + // Clear localStorage + localStorage.clear(); - // Create mock - mockElectronAPI = { - checkForUpdates: jasmine.createSpy('checkForUpdates'), - openExternal: jasmine.createSpy('openExternal'), - getReleaseByTag: jasmine.createSpy('getReleaseByTag'), - }; - (window as unknown as { electronAPI: typeof mockElectronAPI }).electronAPI = mockElectronAPI; - - // Clear localStorage - localStorage.clear(); - - TestBed.configureTestingModule({ - providers: [UpdateService], - }); - - service = TestBed.inject(UpdateService); + TestBed.configureTestingModule({ + providers: [UpdateService], }); - afterEach(() => { - // Restore original electronAPI - (window as unknown as { electronAPI?: unknown }).electronAPI = originalElectronAPI; - localStorage.clear(); - }); + service = TestBed.inject(UpdateService); + }); - it('should be created', () => { - expect(service).toBeTruthy(); - }); + afterEach(() => { + // Restore original electronAPI + (window as unknown as { electronAPI?: unknown }).electronAPI = + originalElectronAPI; + localStorage.clear(); + }); - describe('constructor', () => { - it('should load autoCheck preference from localStorage', () => { - localStorage.setItem('autoCheckUpdates', 'false'); + it('should be created', () => { + expect(service).toBeTruthy(); + }); - const newService = new UpdateService(); + describe('constructor', () => { + it('should load autoCheck preference from localStorage', () => { + localStorage.setItem('autoCheckUpdates', 'false'); - expect(newService.autoCheck()).toBe(false); - }); + const newService = new UpdateService(); - it('should default to true when no localStorage value', () => { - expect(service.autoCheck()).toBe(true); - }); + expect(newService.autoCheck()).toBe(false); }); - describe('init', () => { - it('should check for updates when autoCheck is true', () => { - mockElectronAPI.checkForUpdates.and.returnValue(Promise.resolve({ - updateAvailable: false, - version: '1.0.0', - url: '', - })); - - service.init(); - - expect(mockElectronAPI.checkForUpdates).toHaveBeenCalled(); - }); + it('should default to true when no localStorage value', () => { + expect(service.autoCheck()).toBe(true); + }); + }); - it('should not check for updates when autoCheck is false', () => { - service.autoCheck.set(false); + describe('init', () => { + it('should check for updates when autoCheck is true', () => { + mockElectronAPI.checkForUpdates.and.returnValue( + Promise.resolve({ + updateAvailable: false, + version: '1.0.0', + url: '', + }), + ); - service.init(); + service.init(); - expect(mockElectronAPI.checkForUpdates).not.toHaveBeenCalled(); - }); + expect(mockElectronAPI.checkForUpdates).toHaveBeenCalled(); }); - describe('toggleAutoCheck', () => { - it('should update autoCheck signal', () => { - service.toggleAutoCheck(false); + it('should not check for updates when autoCheck is false', () => { + service.autoCheck.set(false); - expect(service.autoCheck()).toBe(false); - }); + service.init(); - it('should save preference to localStorage', () => { - service.toggleAutoCheck(false); - - expect(localStorage.getItem('autoCheckUpdates')).toBe('false'); - }); + expect(mockElectronAPI.checkForUpdates).not.toHaveBeenCalled(); }); + }); + + describe('toggleAutoCheck', () => { + it('should update autoCheck signal', () => { + service.toggleAutoCheck(false); - describe('checkForUpdates', () => { - it('should return null if already checking', async () => { - service.checking.set(true); + expect(service.autoCheck()).toBe(false); + }); - const result = await service.checkForUpdates(); + it('should save preference to localStorage', () => { + service.toggleAutoCheck(false); - expect(result).toBeNull(); - expect(mockElectronAPI.checkForUpdates).not.toHaveBeenCalled(); - }); + expect(localStorage.getItem('autoCheckUpdates')).toBe('false'); + }); + }); - it('should set checking to true while checking', async () => { - let resolvePromise: (value: unknown) => void; - const pendingPromise = new Promise((resolve) => { - resolvePromise = resolve; - }); - mockElectronAPI.checkForUpdates.and.returnValue(pendingPromise); + describe('checkForUpdates', () => { + it('should return null if already checking', async () => { + service.checking.set(true); - const checkPromise = service.checkForUpdates(); - expect(service.checking()).toBe(true); + const result = await service.checkForUpdates(); - resolvePromise!({ updateAvailable: false, version: '1.0.0', url: '' }); - await checkPromise; + expect(result).toBeNull(); + expect(mockElectronAPI.checkForUpdates).not.toHaveBeenCalled(); + }); - expect(service.checking()).toBe(false); - }); + it('should set checking to true while checking', async () => { + let resolvePromise: (value: unknown) => void; + const pendingPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockElectronAPI.checkForUpdates.and.returnValue(pendingPromise); - it('should set updateAvailable when update is available', async () => { - const updateResult = { - updateAvailable: true, - version: '2.0.0', - url: 'https://example.com/release', - }; - mockElectronAPI.checkForUpdates.and.returnValue(Promise.resolve(updateResult)); + const checkPromise = service.checkForUpdates(); + expect(service.checking()).toBe(true); - await service.checkForUpdates(); + resolvePromise!({ updateAvailable: false, version: '1.0.0', url: '' }); + await checkPromise; - expect(service.updateAvailable()).toEqual(updateResult); - }); + expect(service.checking()).toBe(false); + }); - it('should not set updateAvailable when no update is available', async () => { - const noUpdateResult = { - updateAvailable: false, - version: '1.0.0', - url: '', - }; - mockElectronAPI.checkForUpdates.and.returnValue(Promise.resolve(noUpdateResult)); + it('should set updateAvailable when update is available', async () => { + const updateResult = { + updateAvailable: true, + version: '2.0.0', + url: 'https://example.com/release', + }; + mockElectronAPI.checkForUpdates.and.returnValue( + Promise.resolve(updateResult), + ); - await service.checkForUpdates(); + await service.checkForUpdates(); - expect(service.updateAvailable()).toBeNull(); - }); + expect(service.updateAvailable()).toEqual(updateResult); + }); - it('should return null and log error on failure', async () => { - const consoleSpy = spyOn(console, 'error'); - mockElectronAPI.checkForUpdates.and.returnValue(Promise.reject(new Error('Network error'))); + it('should not set updateAvailable when no update is available', async () => { + const noUpdateResult = { + updateAvailable: false, + version: '1.0.0', + url: '', + }; + mockElectronAPI.checkForUpdates.and.returnValue( + Promise.resolve(noUpdateResult), + ); - const result = await service.checkForUpdates(); + await service.checkForUpdates(); - expect(result).toBeNull(); - expect(consoleSpy).toHaveBeenCalled(); - expect(service.checking()).toBe(false); - }); + expect(service.updateAvailable()).toBeNull(); + }); - it('should return the result on success', async () => { - const updateResult = { - updateAvailable: true, - version: '2.0.0', - url: 'https://example.com', - }; - mockElectronAPI.checkForUpdates.and.returnValue(Promise.resolve(updateResult)); + it('should return null and log error on failure', async () => { + const consoleSpy = spyOn(console, 'error'); + mockElectronAPI.checkForUpdates.and.returnValue( + Promise.reject(new Error('Network error')), + ); - const result = await service.checkForUpdates(); + const result = await service.checkForUpdates(); - expect(result).toEqual(updateResult); - }); + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + expect(service.checking()).toBe(false); }); - describe('openDownloadPage', () => { - it('should call openExternal with update URL', () => { - service.updateAvailable.set({ - updateAvailable: true, - version: '2.0.0', - url: 'https://github.com/releases/v2.0.0', - }); + it('should return the result on success', async () => { + const updateResult = { + updateAvailable: true, + version: '2.0.0', + url: 'https://example.com', + }; + mockElectronAPI.checkForUpdates.and.returnValue( + Promise.resolve(updateResult), + ); - service.openDownloadPage(); + const result = await service.checkForUpdates(); - expect(mockElectronAPI.openExternal).toHaveBeenCalledWith('https://github.com/releases/v2.0.0'); - }); + expect(result).toEqual(updateResult); + }); + }); - it('should not call openExternal when no update is available', () => { - service.updateAvailable.set(null); + describe('openDownloadPage', () => { + it('should call openExternal with update URL', () => { + service.updateAvailable.set({ + updateAvailable: true, + version: '2.0.0', + url: 'https://github.com/releases/v2.0.0', + }); - service.openDownloadPage(); + service.openDownloadPage(); - expect(mockElectronAPI.openExternal).not.toHaveBeenCalled(); - }); + expect(mockElectronAPI.openExternal).toHaveBeenCalledWith( + 'https://github.com/releases/v2.0.0', + ); + }); - it('should not call openExternal when update has no URL', () => { - service.updateAvailable.set({ - updateAvailable: true, - version: '2.0.0', - url: '', - }); + it('should not call openExternal when no update is available', () => { + service.updateAvailable.set(null); - service.openDownloadPage(); + service.openDownloadPage(); - expect(mockElectronAPI.openExternal).not.toHaveBeenCalled(); - }); + expect(mockElectronAPI.openExternal).not.toHaveBeenCalled(); }); - describe('getReleaseByTag', () => { - it('should return release data on success', async () => { - const releaseData = { body: 'Release notes for v1.2.3' }; - mockElectronAPI.getReleaseByTag.and.returnValue(Promise.resolve(releaseData)); + it('should not call openExternal when update has no URL', () => { + service.updateAvailable.set({ + updateAvailable: true, + version: '2.0.0', + url: '', + }); - const result = await service.getReleaseByTag('v1.2.3'); + service.openDownloadPage(); - expect(mockElectronAPI.getReleaseByTag).toHaveBeenCalledWith('v1.2.3'); - expect(result).toEqual(releaseData); - }); + expect(mockElectronAPI.openExternal).not.toHaveBeenCalled(); + }); + }); + + describe('getReleaseByTag', () => { + it('should return release data on success', async () => { + const releaseData = { + tag_name: 'v1.2.3', + html_url: 'https://example.com/releases/v1.2.3', + body: 'Release notes for v1.2.3', + name: 'Release v1.2.3', + published_at: '2023-01-01T00:00:00Z', + }; + mockElectronAPI.getReleaseByTag.and.returnValue( + Promise.resolve(releaseData), + ); + + const result = await service.getReleaseByTag('v1.2.3'); + + expect(mockElectronAPI.getReleaseByTag).toHaveBeenCalledWith('v1.2.3'); + expect(result).toEqual(releaseData); + }); - it('should return null on failure', async () => { - const consoleSpy = spyOn(console, 'error'); - mockElectronAPI.getReleaseByTag.and.returnValue(Promise.reject(new Error('Not found'))); + it('should return null on failure', async () => { + const consoleSpy = spyOn(console, 'error'); + mockElectronAPI.getReleaseByTag.and.returnValue( + Promise.reject(new Error('Not found')), + ); - const result = await service.getReleaseByTag('v99.99.99'); + const result = await service.getReleaseByTag('v99.99.99'); - expect(result).toBeNull(); - expect(consoleSpy).toHaveBeenCalled(); - }); + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); }); + }); }); From 50510e0f5c44d1cc6d9761ce2a43e454e4aa5f44 Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:57:58 +0100 Subject: [PATCH 6/8] fix(test): resolve sonar issues and improve test coverage --- .../src/services/ipc/system-handlers.spec.ts | 50 ++++++ .../open-settings-updates.spec.ts | 67 +++++++- .../open-settings-updates.ts | 6 +- src/app/pipes/safe-markdown.pipe.spec.ts | 67 ++++++++ src/app/pipes/safe-markdown.pipe.ts | 4 +- .../services/action-history.service.spec.ts | 147 +++++++++++++----- 6 files changed, 299 insertions(+), 42 deletions(-) create mode 100644 electron/src/services/ipc/system-handlers.spec.ts create mode 100644 src/app/pipes/safe-markdown.pipe.spec.ts diff --git a/electron/src/services/ipc/system-handlers.spec.ts b/electron/src/services/ipc/system-handlers.spec.ts new file mode 100644 index 0000000..90fe447 --- /dev/null +++ b/electron/src/services/ipc/system-handlers.spec.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { ipcMain, shell } from 'electron'; +import { setupSystemHandlers } from './system-handlers.js'; + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + }, + shell: { + openExternal: vi.fn(), + }, +})); + +describe('System Handlers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should setup open-external handler', () => { + setupSystemHandlers(); + expect(ipcMain.handle).toHaveBeenCalledWith( + 'open-external', + expect.any(Function), + ); + }); + + it('should handle open-external call', async () => { + setupSystemHandlers(); + const handler = (ipcMain.handle as Mock).mock.calls.find( + (call) => call[0] === 'open-external', + )?.[1]; + + const url = 'https://example.com'; + await handler(null, url); + + expect(shell.openExternal).toHaveBeenCalledWith(url); + }); + + it('should handle errors in open-external', async () => { + setupSystemHandlers(); + const handler = (ipcMain.handle as Mock).mock.calls.find( + (call) => call[0] === 'open-external', + )?.[1]; + + const error = new Error('Failed to open'); + (shell.openExternal as Mock).mockRejectedValue(error); + + await expect(handler(null, 'https://example.com')).rejects.toThrow(error); + }); +}); diff --git a/src/app/pages/open-settings-updates/open-settings-updates.spec.ts b/src/app/pages/open-settings-updates/open-settings-updates.spec.ts index 1a2584f..a897335 100644 --- a/src/app/pages/open-settings-updates/open-settings-updates.spec.ts +++ b/src/app/pages/open-settings-updates/open-settings-updates.spec.ts @@ -1,4 +1,9 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing'; import { OpenSettingsUpdatesComponent } from './open-settings-updates'; import { UpdateService } from '../../services/update.service'; import { MessageService } from 'primeng/api'; @@ -129,6 +134,66 @@ describe('OpenSettingsUpdatesComponent', () => { expect(component.lastCheckResult).toBeNull(); }); + + it('should call openDownloadPage', () => { + component.handleDownload(); + expect(updateServiceMock.openDownloadPage).toHaveBeenCalled(); + }); + + it('should open GitHub releases via electronAPI if available', () => { + const event = new Event('click'); + spyOn(event, 'preventDefault'); + const mockElectronAPI = { + openExternal: jasmine.createSpy('openExternal'), + }; + (window as unknown as { electronAPI: typeof mockElectronAPI }).electronAPI = + mockElectronAPI; + + component.openGitHubReleases(event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockElectronAPI.openExternal).toHaveBeenCalledWith( + 'https://github.com/altaskur/OpenTimeTracker/releases', + ); + }); + + it('should open GitHub releases via window.open if electronAPI is not available', () => { + const event = new Event('click'); + spyOn(event, 'preventDefault'); + const win = window as unknown as { electronAPI?: unknown }; + const originalElectronAPI = win.electronAPI; + delete win.electronAPI; + spyOn(window, 'open'); + + component.openGitHubReleases(event); + + expect(window.open).toHaveBeenCalledWith( + 'https://github.com/altaskur/OpenTimeTracker/releases', + '_blank', + 'noopener', + ); + + // Restore + win.electronAPI = originalElectronAPI; + }); + + it('should show current version notes', fakeAsync(() => { + component.currentVersionReleaseNotes = null; + updateServiceMock.getReleaseByTag.and.returnValue( + Promise.resolve({ + tag_name: 'v1.0.0', + html_url: 'url', + body: 'Notes', + name: 'Name', + published_at: 'date', + }), + ); + + component.showCurrentVersionNotes(); + tick(); + + expect(component.currentVersionDialogVisible).toBeTrue(); + expect(updateServiceMock.getReleaseByTag).toHaveBeenCalled(); + })); }); describe('OpenSettingsUpdatesComponent without release notes', () => { diff --git a/src/app/pages/open-settings-updates/open-settings-updates.ts b/src/app/pages/open-settings-updates/open-settings-updates.ts index fce75e1..bee7b7b 100644 --- a/src/app/pages/open-settings-updates/open-settings-updates.ts +++ b/src/app/pages/open-settings-updates/open-settings-updates.ts @@ -203,8 +203,8 @@ import { UpdateDialogComponent } from '../../components/update-dialog/update-dia }) export class OpenSettingsUpdatesComponent implements OnInit { updateService = inject(UpdateService); - private ngZone = inject(NgZone); - private cdr = inject(ChangeDetectorRef); + private readonly ngZone = inject(NgZone); + private readonly cdr = inject(ChangeDetectorRef); lastCheckResult: UpdateCheckResult | null = null; currentVersion = '...'; @@ -268,7 +268,7 @@ export class OpenSettingsUpdatesComponent implements OnInit { if (globalThis.window?.electronAPI) { globalThis.window.electronAPI.openExternal(repoUrl); } else { - window.open(repoUrl, '_blank'); + window.open(repoUrl, '_blank', 'noopener'); } } diff --git a/src/app/pipes/safe-markdown.pipe.spec.ts b/src/app/pipes/safe-markdown.pipe.spec.ts new file mode 100644 index 0000000..cb819f2 --- /dev/null +++ b/src/app/pipes/safe-markdown.pipe.spec.ts @@ -0,0 +1,67 @@ +import { SafeMarkdownPipe } from './safe-markdown.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; +import { TestBed } from '@angular/core/testing'; + +describe('SafeMarkdownPipe', () => { + let pipe: SafeMarkdownPipe; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + DomSanitizer, + { + provide: DomSanitizer, + useValue: { + sanitize: (_context: unknown, value: unknown) => value, + bypassSecurityTrustHtml: (value: unknown) => value, + }, + }, + ], + }); + pipe = TestBed.runInInjectionContext(() => new SafeMarkdownPipe()); + }); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should return empty string for null/undefined content', () => { + expect(pipe.transform(null)).toBe(''); + expect(pipe.transform(undefined)).toBe(''); + }); + + it('should sanitize HTML', () => { + const content = '\n**bold**'; + const result = pipe.transform(content); + expect(result).not.toContain('