diff --git a/package.json b/package.json index 7e3e51543..4b0409817 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "test:publint": "publint", "test:specs": "vitest run --project specs", "test:e2e": "vitest run --coverage --project e2e", - "test:e2e:dev": "vitest --browser.headless=false", + "test:e2e:dev": "vitest --coverage --browser.headless=false", "test:locales": "tsx ./scripts/test-locales.ts", "release": "shipjs prepare", "build:lib": "npm run clean:web && npm run clean:dist && tsx ./scripts/build.ts", diff --git a/src/abstract/managers/__tests__/ModalManager.test.ts b/src/abstract/managers/__tests__/ModalManager.test.ts new file mode 100644 index 000000000..17da5ab92 --- /dev/null +++ b/src/abstract/managers/__tests__/ModalManager.test.ts @@ -0,0 +1,470 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Modal as ModalNode } from '../../../blocks/Modal/Modal'; +import type { Block } from '../../Block'; +import type { ModalId } from '../ModalManager'; +import { ModalEvents, ModalManager } from '../ModalManager'; + +const createMockBlock = (): Block => + ({ + debugPrint: vi.fn(), + telemetryManager: { + sendEventError: vi.fn(), + }, + }) as unknown as Block; + +const createMockModal = (id: ModalId): ModalNode => + ({ + id, + show: vi.fn(), + hide: vi.fn(), + }) as unknown as ModalNode; + +describe('ModalManager', () => { + let manager: ModalManager; + let mockBlock: Block; + + beforeEach(() => { + mockBlock = createMockBlock(); + manager = new ModalManager(mockBlock); + }); + + describe('constructor', () => { + it('should create a new ModalManager instance', () => { + expect(manager).toBeInstanceOf(ModalManager); + }); + }); + + describe('registerModal', () => { + it('should register a modal', () => { + const modal = createMockModal('test-modal'); + const callback = vi.fn(); + + manager.subscribe(ModalEvents.ADD, callback); + manager.registerModal('test-modal', modal); + + expect(callback).toHaveBeenCalledWith({ id: 'test-modal', modal }); + }); + + it('should allow registering multiple modals', () => { + const modal1 = createMockModal('modal-1'); + const modal2 = createMockModal('modal-2'); + const callback = vi.fn(); + + manager.subscribe(ModalEvents.ADD, callback); + manager.registerModal('modal-1', modal1); + manager.registerModal('modal-2', modal2); + + expect(callback).toHaveBeenCalledTimes(2); + }); + }); + + describe('deleteModal', () => { + it('should delete an existing modal', () => { + const modal = createMockModal('test-modal'); + const callback = vi.fn(); + + manager.registerModal('test-modal', modal); + manager.subscribe(ModalEvents.DELETE, callback); + + const result = manager.deleteModal('test-modal'); + + expect(result).toBe(true); + expect(callback).toHaveBeenCalledWith({ id: 'test-modal', modal }); + }); + + it('should return false when deleting non-existent modal', () => { + const result = manager.deleteModal('non-existent'); + + expect(result).toBe(false); + }); + + it('should remove modal from active modals when deleted', () => { + const modal = createMockModal('test-modal'); + + manager.registerModal('test-modal', modal); + manager.open('test-modal'); + + expect(manager.hasActiveModals).toBe(true); + + manager.deleteModal('test-modal'); + + expect(manager.hasActiveModals).toBe(false); + }); + }); + + describe('open', () => { + it('should open a registered modal', () => { + const modal = createMockModal('test-modal'); + const callback = vi.fn(); + + manager.registerModal('test-modal', modal); + manager.subscribe(ModalEvents.OPEN, callback); + + const result = manager.open('test-modal'); + + expect(result).toBe(true); + expect(callback).toHaveBeenCalledWith({ id: 'test-modal', modal }); + }); + + it('should return false for non-existent modal', () => { + const result = manager.open('non-existent'); + + expect(result).toBe(false); + expect(mockBlock.debugPrint).toHaveBeenCalled(); + }); + + it('should set hasActiveModals to true after opening', () => { + const modal = createMockModal('test-modal'); + + manager.registerModal('test-modal', modal); + + expect(manager.hasActiveModals).toBe(false); + + manager.open('test-modal'); + + expect(manager.hasActiveModals).toBe(true); + }); + }); + + describe('close', () => { + it('should close an open modal', () => { + const modal = createMockModal('test-modal'); + const callback = vi.fn(); + + manager.registerModal('test-modal', modal); + manager.open('test-modal'); + manager.subscribe(ModalEvents.CLOSE, callback); + + const result = manager.close('test-modal'); + + expect(result).toBe(true); + expect(callback).toHaveBeenCalledWith({ id: 'test-modal', modal }); + }); + + it('should return false for non-existent modal', () => { + const result = manager.close('non-existent'); + + expect(result).toBe(false); + expect(mockBlock.debugPrint).toHaveBeenCalled(); + }); + + it('should return false for modal that is not active', () => { + const modal = createMockModal('test-modal'); + + manager.registerModal('test-modal', modal); + + const result = manager.close('test-modal'); + + expect(result).toBe(false); + }); + + it('should set hasActiveModals to false after closing last modal', () => { + const modal = createMockModal('test-modal'); + + manager.registerModal('test-modal', modal); + manager.open('test-modal'); + + expect(manager.hasActiveModals).toBe(true); + + manager.close('test-modal'); + + expect(manager.hasActiveModals).toBe(false); + }); + }); + + describe('toggle', () => { + it('should open a closed modal', () => { + const modal = createMockModal('test-modal'); + const openCallback = vi.fn(); + + manager.registerModal('test-modal', modal); + manager.subscribe(ModalEvents.OPEN, openCallback); + + const result = manager.toggle('test-modal'); + + expect(result).toBe(true); + expect(openCallback).toHaveBeenCalled(); + }); + + it('should close an open modal', () => { + const modal = createMockModal('test-modal'); + const closeCallback = vi.fn(); + + manager.registerModal('test-modal', modal); + manager.open('test-modal'); + manager.subscribe(ModalEvents.CLOSE, closeCallback); + + const result = manager.toggle('test-modal'); + + expect(result).toBe(true); + expect(closeCallback).toHaveBeenCalled(); + }); + + it('should return false for non-existent modal', () => { + const result = manager.toggle('non-existent'); + + expect(result).toBe(false); + expect(mockBlock.debugPrint).toHaveBeenCalled(); + }); + }); + + describe('hasActiveModals', () => { + it('should return false when no modals are active', () => { + expect(manager.hasActiveModals).toBe(false); + }); + + it('should return true when at least one modal is active', () => { + const modal = createMockModal('test-modal'); + + manager.registerModal('test-modal', modal); + manager.open('test-modal'); + + expect(manager.hasActiveModals).toBe(true); + }); + + it('should return true when multiple modals are active', () => { + const modal1 = createMockModal('modal-1'); + const modal2 = createMockModal('modal-2'); + + manager.registerModal('modal-1', modal1); + manager.registerModal('modal-2', modal2); + manager.open('modal-1'); + manager.open('modal-2'); + + expect(manager.hasActiveModals).toBe(true); + }); + }); + + describe('back', () => { + it('should close the most recently opened modal', () => { + const modal1 = createMockModal('modal-1'); + const modal2 = createMockModal('modal-2'); + const closeCallback = vi.fn(); + + manager.registerModal('modal-1', modal1); + manager.registerModal('modal-2', modal2); + manager.open('modal-1'); + manager.open('modal-2'); + manager.subscribe(ModalEvents.CLOSE, closeCallback); + + const result = manager.back(); + + expect(result).toBe(true); + expect(closeCallback).toHaveBeenCalledWith({ id: 'modal-2', modal: modal2 }); + }); + + it('should return false when no active modals', () => { + const result = manager.back(); + + expect(result).toBe(false); + expect(mockBlock.debugPrint).toHaveBeenCalled(); + }); + + it('should close only the last modal and keep previous ones active', () => { + const modal1 = createMockModal('modal-1'); + const modal2 = createMockModal('modal-2'); + + manager.registerModal('modal-1', modal1); + manager.registerModal('modal-2', modal2); + manager.open('modal-1'); + manager.open('modal-2'); + + manager.back(); + + expect(manager.hasActiveModals).toBe(true); + }); + }); + + describe('closeAll', () => { + it('should close all open modals and return count', () => { + const modal1 = createMockModal('modal-1'); + const modal2 = createMockModal('modal-2'); + const closeAllCallback = vi.fn(); + + manager.registerModal('modal-1', modal1); + manager.registerModal('modal-2', modal2); + manager.open('modal-1'); + manager.open('modal-2'); + manager.subscribe(ModalEvents.CLOSE_ALL, closeAllCallback); + + const count = manager.closeAll(); + + expect(count).toBe(2); + expect(manager.hasActiveModals).toBe(false); + expect(closeAllCallback).toHaveBeenCalled(); + }); + + it('should return 0 when no modals are open', () => { + const count = manager.closeAll(); + + expect(count).toBe(0); + }); + }); + + describe('subscribe/unsubscribe', () => { + it('should subscribe to events and receive notifications', () => { + const callback = vi.fn(); + + manager.subscribe(ModalEvents.ADD, callback); + + const modal = createMockModal('test-modal'); + manager.registerModal('test-modal', modal); + + expect(callback).toHaveBeenCalledWith({ id: 'test-modal', modal }); + }); + + it('should return unsubscribe function', () => { + const callback = vi.fn(); + const unsubscribe = manager.subscribe(ModalEvents.ADD, callback); + + unsubscribe(); + + const modal = createMockModal('test-modal'); + manager.registerModal('test-modal', modal); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should unsubscribe using unsubscribe method', () => { + const callback = vi.fn(); + + manager.subscribe(ModalEvents.ADD, callback); + manager.unsubscribe(ModalEvents.ADD, callback); + + const modal = createMockModal('test-modal'); + manager.registerModal('test-modal', modal); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should handle unsubscribe with undefined callback', () => { + manager.subscribe(ModalEvents.ADD, vi.fn()); + + // Should not throw + expect(() => manager.unsubscribe(ModalEvents.ADD, undefined)).not.toThrow(); + }); + + it('should handle multiple subscribers for same event', () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + manager.subscribe(ModalEvents.ADD, callback1); + manager.subscribe(ModalEvents.ADD, callback2); + + const modal = createMockModal('test-modal'); + manager.registerModal('test-modal', modal); + + expect(callback1).toHaveBeenCalled(); + expect(callback2).toHaveBeenCalled(); + }); + + it('should handle subscriber error gracefully', () => { + const errorCallback = vi.fn(() => { + throw new Error('Subscriber error'); + }); + const successCallback = vi.fn(); + + manager.subscribe(ModalEvents.ADD, errorCallback); + manager.subscribe(ModalEvents.ADD, successCallback); + + const modal = createMockModal('test-modal'); + manager.registerModal('test-modal', modal); + + expect(mockBlock.telemetryManager.sendEventError).toHaveBeenCalled(); + expect(mockBlock.debugPrint).toHaveBeenCalled(); + expect(successCallback).toHaveBeenCalled(); + }); + }); + + describe('destroy', () => { + it('should close all modals and clear resources', () => { + const modal = createMockModal('test-modal'); + const closeAllCallback = vi.fn(); + + manager.registerModal('test-modal', modal); + manager.open('test-modal'); + manager.subscribe(ModalEvents.CLOSE_ALL, closeAllCallback); + + manager.destroy(); + + expect(manager.hasActiveModals).toBe(false); + expect(closeAllCallback).toHaveBeenCalled(); + }); + + it('should clear all modals after destroy', () => { + const modal = createMockModal('test-modal'); + + manager.registerModal('test-modal', modal); + manager.destroy(); + + const result = manager.open('test-modal'); + expect(result).toBe(false); + }); + + it('should clear all subscribers after destroy', () => { + const callback = vi.fn(); + + manager.subscribe(ModalEvents.ADD, callback); + manager.destroy(); + + const modal = createMockModal('test-modal'); + manager.registerModal('test-modal', modal); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('ModalEvents', () => { + it('should have correct event constants', () => { + expect(ModalEvents.ADD).toBe('modal:add'); + expect(ModalEvents.DELETE).toBe('modal:delete'); + expect(ModalEvents.OPEN).toBe('modal:open'); + expect(ModalEvents.CLOSE).toBe('modal:close'); + expect(ModalEvents.CLOSE_ALL).toBe('modal:closeAll'); + expect(ModalEvents.DESTROY).toBe('modal:destroy'); + }); + + it('should be frozen', () => { + expect(Object.isFrozen(ModalEvents)).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should handle opening the same modal multiple times', () => { + const modal = createMockModal('test-modal'); + const openCallback = vi.fn(); + + manager.registerModal('test-modal', modal); + manager.subscribe(ModalEvents.OPEN, openCallback); + + manager.open('test-modal'); + manager.open('test-modal'); + + expect(openCallback).toHaveBeenCalledTimes(2); + }); + + it('should handle registering modal with same id (overwrite)', () => { + const modal1 = createMockModal('test-modal'); + const modal2 = createMockModal('test-modal'); + const addCallback = vi.fn(); + + manager.subscribe(ModalEvents.ADD, addCallback); + manager.registerModal('test-modal', modal1); + manager.registerModal('test-modal', modal2); + + expect(addCallback).toHaveBeenCalledTimes(2); + expect(addCallback).toHaveBeenLastCalledWith({ id: 'test-modal', modal: modal2 }); + }); + + it('should handle closing already closed modal', () => { + const modal = createMockModal('test-modal'); + + manager.registerModal('test-modal', modal); + manager.open('test-modal'); + manager.close('test-modal'); + + const result = manager.close('test-modal'); + expect(result).toBe(false); + }); + }); +}); diff --git a/src/abstract/managers/__tests__/SecureUploadsManager.test.ts b/src/abstract/managers/__tests__/SecureUploadsManager.test.ts new file mode 100644 index 000000000..00d2855af --- /dev/null +++ b/src/abstract/managers/__tests__/SecureUploadsManager.test.ts @@ -0,0 +1,362 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SecureUploadsSignatureAndExpire } from '../../../types/index'; +import type { UploaderBlock } from '../../UploaderBlock'; +import { SecureUploadsManager } from '../SecureUploadsManager'; + +const createMockBlock = (config: Partial = {}): UploaderBlock => + ({ + debugPrint: vi.fn(), + telemetryManager: { + sendEventError: vi.fn(), + }, + cfg: { + secureSignature: undefined, + secureExpire: undefined, + secureUploadsSignatureResolver: undefined, + secureUploadsExpireThreshold: 10000, + ...config, + }, + }) as unknown as UploaderBlock; + +describe('SecureUploadsManager', () => { + let manager: SecureUploadsManager; + let mockBlock: UploaderBlock; + + beforeEach(() => { + vi.useFakeTimers(); + mockBlock = createMockBlock(); + manager = new SecureUploadsManager(mockBlock); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('constructor', () => { + it('should create a new SecureUploadsManager instance', () => { + expect(manager).toBeInstanceOf(SecureUploadsManager); + }); + }); + + describe('getSecureToken', () => { + describe('when no secure config is set', () => { + it('should return null when no secure configuration is provided', async () => { + const result = await manager.getSecureToken(); + + expect(result).toBeNull(); + }); + }); + + describe('with static secureSignature and secureExpire', () => { + it('should return the static secure token', async () => { + mockBlock = createMockBlock({ + secureSignature: 'test-signature', + secureExpire: '1234567890', + }); + manager = new SecureUploadsManager(mockBlock); + + const result = await manager.getSecureToken(); + + expect(result).toEqual({ + secureSignature: 'test-signature', + secureExpire: '1234567890', + }); + }); + + it('should debug print when using static signature and expire', async () => { + mockBlock = createMockBlock({ + secureSignature: 'test-signature', + secureExpire: '1234567890', + }); + manager = new SecureUploadsManager(mockBlock); + + await manager.getSecureToken(); + + expect(mockBlock.debugPrint).toHaveBeenCalled(); + }); + + it('should return null if only secureSignature is set', async () => { + mockBlock = createMockBlock({ + secureSignature: 'test-signature', + }); + manager = new SecureUploadsManager(mockBlock); + + const result = await manager.getSecureToken(); + + expect(result).toBeNull(); + }); + + it('should return null if only secureExpire is set', async () => { + mockBlock = createMockBlock({ + secureExpire: '1234567890', + }); + manager = new SecureUploadsManager(mockBlock); + + const result = await manager.getSecureToken(); + + expect(result).toBeNull(); + }); + }); + + describe('with secureUploadsSignatureResolver', () => { + it('should call the resolver and return the token', async () => { + const mockToken: SecureUploadsSignatureAndExpire = { + secureSignature: 'resolved-signature', + secureExpire: String(Math.floor(Date.now() / 1000) + 3600), + }; + const resolver = vi.fn().mockResolvedValue(mockToken); + + mockBlock = createMockBlock({ + secureUploadsSignatureResolver: resolver, + }); + manager = new SecureUploadsManager(mockBlock); + + const result = await manager.getSecureToken(); + + expect(resolver).toHaveBeenCalled(); + expect(result).toEqual(mockToken); + }); + + it('should cache the resolved token and not call resolver again', async () => { + const futureExpire = String(Math.floor(Date.now() / 1000) + 3600); + const mockToken: SecureUploadsSignatureAndExpire = { + secureSignature: 'resolved-signature', + secureExpire: futureExpire, + }; + const resolver = vi.fn().mockResolvedValue(mockToken); + + mockBlock = createMockBlock({ + secureUploadsSignatureResolver: resolver, + }); + manager = new SecureUploadsManager(mockBlock); + + await manager.getSecureToken(); + await manager.getSecureToken(); + await manager.getSecureToken(); + + expect(resolver).toHaveBeenCalledTimes(1); + }); + + it('should resolve a new token when the cached one is expired', async () => { + const nowUnix = Math.floor(Date.now() / 1000); + const expiredToken: SecureUploadsSignatureAndExpire = { + secureSignature: 'expired-signature', + secureExpire: String(nowUnix + 5), // Expires in 5 seconds + }; + const newToken: SecureUploadsSignatureAndExpire = { + secureSignature: 'new-signature', + secureExpire: String(nowUnix + 3600), // Expires in 1 hour + }; + const resolver = vi.fn().mockResolvedValueOnce(expiredToken).mockResolvedValueOnce(newToken); + + mockBlock = createMockBlock({ + secureUploadsSignatureResolver: resolver, + secureUploadsExpireThreshold: 10000, // 10 seconds threshold + }); + manager = new SecureUploadsManager(mockBlock); + + const result1 = await manager.getSecureToken(); + expect(result1).toEqual(expiredToken); + + vi.advanceTimersByTime(6000); + + const result2 = await manager.getSecureToken(); + expect(result2).toEqual(newToken); + expect(resolver).toHaveBeenCalledTimes(2); + }); + + it('should warn when both static config and resolver are set', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockToken: SecureUploadsSignatureAndExpire = { + secureSignature: 'resolved-signature', + secureExpire: String(Math.floor(Date.now() / 1000) + 3600), + }; + + mockBlock = createMockBlock({ + secureSignature: 'static-signature', + secureExpire: '1234567890', + secureUploadsSignatureResolver: vi.fn().mockResolvedValue(mockToken), + }); + manager = new SecureUploadsManager(mockBlock); + + await manager.getSecureToken(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Both secureSignature/secureExpire and secureUploadsSignatureResolver are set. secureUploadsSignatureResolver will be used.', + ); + + consoleWarnSpy.mockRestore(); + }); + + it('should use resolver even when static config is set', async () => { + const mockToken: SecureUploadsSignatureAndExpire = { + secureSignature: 'resolved-signature', + secureExpire: String(Math.floor(Date.now() / 1000) + 3600), + }; + const resolver = vi.fn().mockResolvedValue(mockToken); + + mockBlock = createMockBlock({ + secureSignature: 'static-signature', + secureExpire: '1234567890', + secureUploadsSignatureResolver: resolver, + }); + manager = new SecureUploadsManager(mockBlock); + + const result = await manager.getSecureToken(); + + expect(result).toEqual(mockToken); + expect(resolver).toHaveBeenCalled(); + }); + + it('should return null when resolver returns nothing', async () => { + const resolver = vi.fn().mockResolvedValue(undefined); + + mockBlock = createMockBlock({ + secureUploadsSignatureResolver: resolver, + }); + manager = new SecureUploadsManager(mockBlock); + + const result = await manager.getSecureToken(); + + expect(result).toBeNull(); + expect(mockBlock.debugPrint).toHaveBeenCalled(); + }); + + it('should log error when resolver returns invalid result (missing secureSignature)', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const invalidToken = { secureExpire: '1234567890' }; + const resolver = vi.fn().mockResolvedValue(invalidToken); + + mockBlock = createMockBlock({ + secureUploadsSignatureResolver: resolver, + }); + manager = new SecureUploadsManager(mockBlock); + + await manager.getSecureToken(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Secure signature resolver returned an invalid result:', + invalidToken, + ); + + consoleErrorSpy.mockRestore(); + }); + + it('should log error when resolver returns invalid result (missing secureExpire)', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const invalidToken = { secureSignature: 'test-signature' }; + const resolver = vi.fn().mockResolvedValue(invalidToken); + + mockBlock = createMockBlock({ + secureUploadsSignatureResolver: resolver, + }); + manager = new SecureUploadsManager(mockBlock); + + await manager.getSecureToken(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Secure signature resolver returned an invalid result:', + invalidToken, + ); + + consoleErrorSpy.mockRestore(); + }); + + it('should handle resolver error and return previous token', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const nowUnix = Math.floor(Date.now() / 1000); + const validToken: SecureUploadsSignatureAndExpire = { + secureSignature: 'valid-signature', + secureExpire: String(nowUnix + 5), + }; + const resolverError = new Error('Resolver failed'); + const resolver = vi.fn().mockResolvedValueOnce(validToken).mockRejectedValueOnce(resolverError); + + mockBlock = createMockBlock({ + secureUploadsSignatureResolver: resolver, + secureUploadsExpireThreshold: 10000, + }); + manager = new SecureUploadsManager(mockBlock); + + const result1 = await manager.getSecureToken(); + expect(result1).toEqual(validToken); + + vi.advanceTimersByTime(6000); + + const result2 = await manager.getSecureToken(); + expect(result2).toEqual(validToken); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Secure signature resolving failed. Falling back to the previous one.', + resolverError, + ); + expect(mockBlock.telemetryManager.sendEventError).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + it('should debug print when token is not set yet', async () => { + const mockToken: SecureUploadsSignatureAndExpire = { + secureSignature: 'resolved-signature', + secureExpire: String(Math.floor(Date.now() / 1000) + 3600), + }; + const resolver = vi.fn().mockResolvedValue(mockToken); + + mockBlock = createMockBlock({ + secureUploadsSignatureResolver: resolver, + }); + manager = new SecureUploadsManager(mockBlock); + + await manager.getSecureToken(); + + expect(mockBlock.debugPrint).toHaveBeenCalledWith('[secure-uploads]', 'Secure signature is not set yet.'); + }); + + it('should debug print when token is expired', async () => { + const nowUnix = Math.floor(Date.now() / 1000); + const expiredToken: SecureUploadsSignatureAndExpire = { + secureSignature: 'expired-signature', + secureExpire: String(nowUnix + 5), + }; + const newToken: SecureUploadsSignatureAndExpire = { + secureSignature: 'new-signature', + secureExpire: String(nowUnix + 3600), + }; + const resolver = vi.fn().mockResolvedValueOnce(expiredToken).mockResolvedValueOnce(newToken); + + mockBlock = createMockBlock({ + secureUploadsSignatureResolver: resolver, + secureUploadsExpireThreshold: 10000, + }); + manager = new SecureUploadsManager(mockBlock); + + await manager.getSecureToken(); + + vi.advanceTimersByTime(6000); + + await manager.getSecureToken(); + + expect(mockBlock.debugPrint).toHaveBeenCalledWith( + '[secure-uploads]', + 'Secure signature is expired. Resolving a new one...', + ); + }); + + it('should debug print resolved token details', async () => { + const mockToken: SecureUploadsSignatureAndExpire = { + secureSignature: 'resolved-signature', + secureExpire: String(Math.floor(Date.now() / 1000) + 3600), + }; + const resolver = vi.fn().mockResolvedValue(mockToken); + + mockBlock = createMockBlock({ + secureUploadsSignatureResolver: resolver, + }); + manager = new SecureUploadsManager(mockBlock); + + await manager.getSecureToken(); + + expect(mockBlock.debugPrint).toHaveBeenCalledWith('[secure-uploads]', 'Secure signature resolved:', mockToken); + }); + }); + }); +}); diff --git a/src/blocks/CameraSource/CameraSource.ts b/src/blocks/CameraSource/CameraSource.ts index 758855a69..a23dda263 100644 --- a/src/blocks/CameraSource/CameraSource.ts +++ b/src/blocks/CameraSource/CameraSource.ts @@ -1028,6 +1028,7 @@ CameraSource.template = /* HTML */ ` type="button" class="uc-switch uc-mini-btn" set="onclick: onClickTab; @hidden: tabCameraHidden" + data-testid="tab-photo" > @@ -1036,6 +1037,7 @@ CameraSource.template = /* HTML */ ` type="button" class="uc-switch uc-mini-btn" set="onclick: onClickTab; @hidden: tabVideoHidden" + data-testid="tab-video" > @@ -1044,6 +1046,7 @@ CameraSource.template = /* HTML */ ` @@ -1087,6 +1091,7 @@ CameraSource.template = /* HTML */ ` diff --git a/src/blocks/CameraSource/__tests__/calcCameraModes.test.ts b/src/blocks/CameraSource/__tests__/calcCameraModes.test.ts new file mode 100644 index 000000000..48d677f7f --- /dev/null +++ b/src/blocks/CameraSource/__tests__/calcCameraModes.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import type { ConfigType } from '../../../types/index'; +import { initialConfig } from '../../Config/initialConfig'; +import { calcCameraModes } from '../calcCameraModes'; + +describe('calcCameraModes', () => { + it('should return both modes enabled when cameraModes includes video and photo', () => { + const cfg = { ...initialConfig } as ConfigType; + const result = calcCameraModes(cfg); + expect(result).toEqual({ + isVideoRecordingEnabled: true, + isPhotoEnabled: true, + }); + }); + + it('should return only video enabled when cameraModes includes only video', () => { + const cfg = { ...initialConfig, cameraModes: 'video' } as ConfigType; + const result = calcCameraModes(cfg); + expect(result).toEqual({ + isVideoRecordingEnabled: true, + isPhotoEnabled: false, + }); + }); + + it('should return only photo enabled when cameraModes includes only photo', () => { + const cfg = { ...initialConfig, cameraModes: 'photo' } as ConfigType; + const result = calcCameraModes(cfg); + expect(result).toEqual({ + isVideoRecordingEnabled: false, + isPhotoEnabled: true, + }); + }); + + it('should return both modes disabled when cameraModes is empty', () => { + const cfg = { ...initialConfig, cameraModes: '' } as ConfigType; + const result = calcCameraModes(cfg); + expect(result).toEqual({ + isVideoRecordingEnabled: false, + isPhotoEnabled: false, + }); + }); + + it('should handle mixed valid and invalid values', () => { + const cfg = { cameraModes: 'video,unknown,photo' } as ConfigType; + const result = calcCameraModes(cfg); + expect(result).toEqual({ + isVideoRecordingEnabled: true, + isPhotoEnabled: true, + }); + }); +}); diff --git a/src/blocks/CloudImageEditor/src/EditorToolbar.ts b/src/blocks/CloudImageEditor/src/EditorToolbar.ts index ed9da17cd..03473f672 100644 --- a/src/blocks/CloudImageEditor/src/EditorToolbar.ts +++ b/src/blocks/CloudImageEditor/src/EditorToolbar.ts @@ -501,8 +501,8 @@ EditorToolbar.template = /* HTML */ `
- - + +
diff --git a/src/blocks/CloudImageEditor/src/crop-utils.test.ts b/src/blocks/CloudImageEditor/src/crop-utils.test.ts new file mode 100644 index 000000000..13bf0bb9d --- /dev/null +++ b/src/blocks/CloudImageEditor/src/crop-utils.test.ts @@ -0,0 +1,719 @@ +import { describe, expect, it } from 'vitest'; +import { + calculateMaxCenteredCropFrame, + clamp, + constraintRect, + cornerPath, + createSvgNode, + isRectInsideRect, + isRectMatchesAspectRatio, + moveRect, + rectContainsPoint, + resizeRect, + rotateSize, + roundRect, + setSvgNodeAttrs, + sidePath, + thumbCursor, +} from './crop-utils'; +import type { Rectangle } from './types'; + +describe('crop-utils', () => { + describe('setSvgNodeAttrs', () => { + it('should set string attributes on SVG node', () => { + const node = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + setSvgNodeAttrs(node, { fill: 'red', stroke: 'blue' }); + + expect(node.getAttribute('fill')).toBe('red'); + expect(node.getAttribute('stroke')).toBe('blue'); + }); + + it('should set numeric attributes as strings on SVG node', () => { + const node = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + setSvgNodeAttrs(node, { width: 100, height: 200, x: 10, y: 20 }); + + expect(node.getAttribute('width')).toBe('100'); + expect(node.getAttribute('height')).toBe('200'); + expect(node.getAttribute('x')).toBe('10'); + expect(node.getAttribute('y')).toBe('20'); + }); + }); + + describe('createSvgNode', () => { + it('should create an SVG element with given name', () => { + const node = createSvgNode('rect'); + + expect(node.tagName).toBe('rect'); + expect(node.namespaceURI).toBe('http://www.w3.org/2000/svg'); + }); + + it('should create an SVG element with attributes', () => { + const node = createSvgNode('circle', { cx: 50, cy: 50, r: 25, fill: 'green' }); + + expect(node.tagName).toBe('circle'); + expect(node.getAttribute('cx')).toBe('50'); + expect(node.getAttribute('cy')).toBe('50'); + expect(node.getAttribute('r')).toBe('25'); + expect(node.getAttribute('fill')).toBe('green'); + }); + + it('should create an SVG element without attributes when none provided', () => { + const node = createSvgNode('path'); + + expect(node.tagName).toBe('path'); + expect(node.attributes.length).toBe(0); + }); + }); + + describe('cornerPath', () => { + const rect: Rectangle = { x: 100, y: 100, width: 200, height: 150 }; + + it('should generate path for northwest corner', () => { + const result = cornerPath(rect, 'nw', 1); + + expect(result.d).toContain('M'); + expect(result.d).toContain('L'); + expect(result.center).toHaveLength(2); + }); + + it('should generate path for northeast corner', () => { + const result = cornerPath(rect, 'ne', 1); + + expect(result.d).toContain('M'); + expect(result.d).toContain('L'); + expect(result.center).toHaveLength(2); + }); + + it('should generate path for southwest corner', () => { + const result = cornerPath(rect, 'sw', 1); + + expect(result.d).toContain('M'); + expect(result.d).toContain('L'); + expect(result.center).toHaveLength(2); + }); + + it('should generate path for southeast corner', () => { + const result = cornerPath(rect, 'se', 1); + + expect(result.d).toContain('M'); + expect(result.d).toContain('L'); + expect(result.center).toHaveLength(2); + }); + + it('should scale path with size multiplier', () => { + const result1 = cornerPath(rect, 'nw', 1); + const result2 = cornerPath(rect, 'nw', 2); + + expect(result1.d).not.toBe(result2.d); + }); + }); + + describe('sidePath', () => { + const rect: Rectangle = { x: 100, y: 100, width: 200, height: 150 }; + + it('should generate path for north side', () => { + const result = sidePath(rect, 'n', 1); + + expect(result.d).toContain('M'); + expect(result.d).toContain('L'); + expect(result.center).toHaveLength(2); + }); + + it('should generate path for south side', () => { + const result = sidePath(rect, 's', 1); + + expect(result.d).toContain('M'); + expect(result.d).toContain('L'); + expect(result.center).toHaveLength(2); + }); + + it('should generate path for east side', () => { + const result = sidePath(rect, 'e', 1); + + expect(result.d).toContain('M'); + expect(result.d).toContain('L'); + expect(result.center).toHaveLength(2); + }); + + it('should generate path for west side', () => { + const result = sidePath(rect, 'w', 1); + + expect(result.d).toContain('M'); + expect(result.d).toContain('L'); + expect(result.center).toHaveLength(2); + }); + + it('should scale path with size multiplier', () => { + const result1 = sidePath(rect, 'n', 1); + const result2 = sidePath(rect, 'n', 2); + + expect(result1.d).not.toBe(result2.d); + }); + }); + + describe('thumbCursor', () => { + it('should return move cursor for empty direction', () => { + expect(thumbCursor('')).toBe('move'); + }); + + it('should return ew-resize for east direction', () => { + expect(thumbCursor('e')).toBe('ew-resize'); + }); + + it('should return ew-resize for west direction', () => { + expect(thumbCursor('w')).toBe('ew-resize'); + }); + + it('should return ns-resize for north direction', () => { + expect(thumbCursor('n')).toBe('ns-resize'); + }); + + it('should return ns-resize for south direction', () => { + expect(thumbCursor('s')).toBe('ns-resize'); + }); + + it('should return nwse-resize for northwest direction', () => { + expect(thumbCursor('nw')).toBe('nwse-resize'); + }); + + it('should return nwse-resize for southeast direction', () => { + expect(thumbCursor('se')).toBe('nwse-resize'); + }); + + it('should return nesw-resize for northeast direction', () => { + expect(thumbCursor('ne')).toBe('nesw-resize'); + }); + + it('should return nesw-resize for southwest direction', () => { + expect(thumbCursor('sw')).toBe('nesw-resize'); + }); + }); + + describe('moveRect', () => { + const imageBox: Rectangle = { x: 0, y: 0, width: 500, height: 400 }; + + it('should move rectangle by delta', () => { + const rect: Rectangle = { x: 100, y: 100, width: 100, height: 100 }; + const result = moveRect({ rect, delta: [50, 30], imageBox }); + + expect(result.x).toBe(150); + expect(result.y).toBe(130); + expect(result.width).toBe(100); + expect(result.height).toBe(100); + }); + + it('should constrain rectangle to image box when moving left', () => { + const rect: Rectangle = { x: 10, y: 100, width: 100, height: 100 }; + const result = moveRect({ rect, delta: [-50, 0], imageBox }); + + expect(result.x).toBe(0); + }); + + it('should constrain rectangle to image box when moving up', () => { + const rect: Rectangle = { x: 100, y: 10, width: 100, height: 100 }; + const result = moveRect({ rect, delta: [0, -50], imageBox }); + + expect(result.y).toBe(0); + }); + + it('should constrain rectangle to image box when moving right', () => { + const rect: Rectangle = { x: 350, y: 100, width: 100, height: 100 }; + const result = moveRect({ rect, delta: [100, 0], imageBox }); + + expect(result.x).toBe(400); + }); + + it('should constrain rectangle to image box when moving down', () => { + const rect: Rectangle = { x: 100, y: 250, width: 100, height: 100 }; + const result = moveRect({ rect, delta: [0, 100], imageBox }); + + expect(result.y).toBe(300); + }); + }); + + describe('constraintRect', () => { + const imageBox: Rectangle = { x: 0, y: 0, width: 500, height: 400 }; + + it('should not modify rectangle that is inside bounds', () => { + const rect: Rectangle = { x: 100, y: 100, width: 100, height: 100 }; + const result = constraintRect(rect, imageBox); + + expect(result.x).toBe(100); + expect(result.y).toBe(100); + }); + + it('should constrain rectangle that exceeds left bound', () => { + const rect: Rectangle = { x: -50, y: 100, width: 100, height: 100 }; + const result = constraintRect(rect, imageBox); + + expect(result.x).toBe(0); + }); + + it('should constrain rectangle that exceeds top bound', () => { + const rect: Rectangle = { x: 100, y: -50, width: 100, height: 100 }; + const result = constraintRect(rect, imageBox); + + expect(result.y).toBe(0); + }); + + it('should constrain rectangle that exceeds right bound', () => { + const rect: Rectangle = { x: 450, y: 100, width: 100, height: 100 }; + const result = constraintRect(rect, imageBox); + + expect(result.x).toBe(400); + }); + + it('should constrain rectangle that exceeds bottom bound', () => { + const rect: Rectangle = { x: 100, y: 350, width: 100, height: 100 }; + const result = constraintRect(rect, imageBox); + + expect(result.y).toBe(300); + }); + }); + + describe('resizeRect', () => { + const imageBox: Rectangle = { x: 0, y: 0, width: 500, height: 400 }; + const rect: Rectangle = { x: 100, y: 100, width: 200, height: 150 }; + + describe('without aspect ratio', () => { + it('should resize north', () => { + const result = resizeRect({ direction: 'n', rect, delta: [0, -50], imageBox }); + + expect(result.height).toBeGreaterThan(rect.height); + expect(result.y).toBeLessThan(rect.y); + }); + + it('should resize south', () => { + const result = resizeRect({ direction: 's', rect, delta: [0, 50], imageBox }); + + expect(result.height).toBeGreaterThan(rect.height); + expect(result.y).toBe(rect.y); + }); + + it('should resize east', () => { + const result = resizeRect({ direction: 'e', rect, delta: [50, 0], imageBox }); + + expect(result.width).toBeGreaterThan(rect.width); + expect(result.x).toBe(rect.x); + }); + + it('should resize west', () => { + const result = resizeRect({ direction: 'w', rect, delta: [-50, 0], imageBox }); + + expect(result.width).toBeGreaterThan(rect.width); + expect(result.x).toBeLessThan(rect.x); + }); + + it('should resize northwest', () => { + const result = resizeRect({ direction: 'nw', rect, delta: [-50, -50], imageBox }); + + expect(result.width).toBeGreaterThan(rect.width); + expect(result.height).toBeGreaterThan(rect.height); + expect(result.x).toBeLessThan(rect.x); + expect(result.y).toBeLessThan(rect.y); + }); + + it('should resize northeast', () => { + const result = resizeRect({ direction: 'ne', rect, delta: [50, -50], imageBox }); + + expect(result.width).toBeGreaterThan(rect.width); + expect(result.height).toBeGreaterThan(rect.height); + expect(result.y).toBeLessThan(rect.y); + }); + + it('should resize southwest', () => { + const result = resizeRect({ direction: 'sw', rect, delta: [-50, 50], imageBox }); + + expect(result.width).toBeGreaterThan(rect.width); + expect(result.height).toBeGreaterThan(rect.height); + expect(result.x).toBeLessThan(rect.x); + }); + + it('should resize southeast', () => { + const result = resizeRect({ direction: 'se', rect, delta: [50, 50], imageBox }); + + expect(result.width).toBeGreaterThan(rect.width); + expect(result.height).toBeGreaterThan(rect.height); + }); + + it('should return original rect for empty direction', () => { + const result = resizeRect({ direction: '', rect, delta: [50, 50], imageBox }); + + expect(result).toEqual(rect); + }); + }); + + describe('with aspect ratio', () => { + const aspectRatio = 4 / 3; + + it('should maintain aspect ratio when resizing north', () => { + const result = resizeRect({ direction: 'n', rect, delta: [0, -60], imageBox, aspectRatio }); + + expect(Math.abs(result.width / result.height - aspectRatio)).toBeLessThan(0.01); + }); + + it('should maintain aspect ratio when resizing south', () => { + const result = resizeRect({ direction: 's', rect, delta: [0, 60], imageBox, aspectRatio }); + + expect(Math.abs(result.width / result.height - aspectRatio)).toBeLessThan(0.01); + }); + + it('should maintain aspect ratio when resizing east', () => { + const result = resizeRect({ direction: 'e', rect, delta: [60, 0], imageBox, aspectRatio }); + + expect(Math.abs(result.width / result.height - aspectRatio)).toBeLessThan(0.01); + }); + + it('should maintain aspect ratio when resizing west', () => { + const result = resizeRect({ direction: 'w', rect, delta: [-60, 0], imageBox, aspectRatio }); + + expect(Math.abs(result.width / result.height - aspectRatio)).toBeLessThan(0.01); + }); + + it('should maintain aspect ratio when resizing northwest', () => { + const result = resizeRect({ direction: 'nw', rect, delta: [-60, -45], imageBox, aspectRatio }); + + expect(Math.abs(result.width / result.height - aspectRatio)).toBeLessThan(0.01); + }); + + it('should maintain aspect ratio when resizing northeast', () => { + const result = resizeRect({ direction: 'ne', rect, delta: [60, -45], imageBox, aspectRatio }); + + expect(Math.abs(result.width / result.height - aspectRatio)).toBeLessThan(0.01); + }); + + it('should maintain aspect ratio when resizing southwest', () => { + const result = resizeRect({ direction: 'sw', rect, delta: [-60, 45], imageBox, aspectRatio }); + + expect(Math.abs(result.width / result.height - aspectRatio)).toBeLessThan(0.01); + }); + + it('should maintain aspect ratio when resizing southeast', () => { + const result = resizeRect({ direction: 'se', rect, delta: [60, 45], imageBox, aspectRatio }); + + expect(Math.abs(result.width / result.height - aspectRatio)).toBeLessThan(0.01); + }); + }); + + describe('boundary constraints', () => { + it('should constrain to top boundary when resizing north', () => { + const nearTop: Rectangle = { x: 100, y: 20, width: 200, height: 150 }; + const result = resizeRect({ direction: 'n', rect: nearTop, delta: [0, -100], imageBox }); + + expect(result.y).toBeGreaterThanOrEqual(0); + }); + + it('should constrain to left boundary when resizing west', () => { + const nearLeft: Rectangle = { x: 20, y: 100, width: 200, height: 150 }; + const result = resizeRect({ direction: 'w', rect: nearLeft, delta: [-100, 0], imageBox }); + + expect(result.x).toBeGreaterThanOrEqual(0); + }); + + it('should constrain to bottom boundary when resizing south', () => { + const nearBottom: Rectangle = { x: 100, y: 200, width: 200, height: 150 }; + const result = resizeRect({ direction: 's', rect: nearBottom, delta: [0, 200], imageBox }); + + expect(result.y + result.height).toBeLessThanOrEqual(imageBox.height); + }); + + it('should constrain to right boundary when resizing east', () => { + const nearRight: Rectangle = { x: 250, y: 100, width: 200, height: 150 }; + const result = resizeRect({ direction: 'e', rect: nearRight, delta: [200, 0], imageBox }); + + expect(result.x + result.width).toBeLessThanOrEqual(imageBox.width); + }); + }); + + describe('minimum size constraints', () => { + it('should enforce minimum size when shrinking too much', () => { + const result = resizeRect({ direction: 's', rect, delta: [0, -200], imageBox }); + + expect(result.height).toBeGreaterThanOrEqual(1); + }); + + it('should enforce minimum width when shrinking east to west', () => { + const result = resizeRect({ direction: 'e', rect, delta: [-300, 0], imageBox }); + + expect(result.width).toBeGreaterThanOrEqual(1); + }); + }); + }); + + describe('rectContainsPoint', () => { + const rect: Rectangle = { x: 100, y: 100, width: 200, height: 150 }; + + it('should return true for point inside rectangle', () => { + expect(rectContainsPoint(rect, [150, 150])).toBe(true); + }); + + it('should return true for point on left edge', () => { + expect(rectContainsPoint(rect, [100, 150])).toBe(true); + }); + + it('should return true for point on top edge', () => { + expect(rectContainsPoint(rect, [150, 100])).toBe(true); + }); + + it('should return true for point on right edge', () => { + expect(rectContainsPoint(rect, [300, 150])).toBe(true); + }); + + it('should return true for point on bottom edge', () => { + expect(rectContainsPoint(rect, [150, 250])).toBe(true); + }); + + it('should return true for corner points', () => { + expect(rectContainsPoint(rect, [100, 100])).toBe(true); + expect(rectContainsPoint(rect, [300, 100])).toBe(true); + expect(rectContainsPoint(rect, [100, 250])).toBe(true); + expect(rectContainsPoint(rect, [300, 250])).toBe(true); + }); + + it('should return false for point outside rectangle', () => { + expect(rectContainsPoint(rect, [50, 150])).toBe(false); + expect(rectContainsPoint(rect, [350, 150])).toBe(false); + expect(rectContainsPoint(rect, [150, 50])).toBe(false); + expect(rectContainsPoint(rect, [150, 300])).toBe(false); + }); + }); + + describe('isRectInsideRect', () => { + const outerRect: Rectangle = { x: 0, y: 0, width: 500, height: 400 }; + + it('should return true when inner rect is completely inside outer rect', () => { + const innerRect: Rectangle = { x: 100, y: 100, width: 200, height: 150 }; + + expect(isRectInsideRect(innerRect, outerRect)).toBe(true); + }); + + it('should return true when inner rect matches outer rect exactly', () => { + expect(isRectInsideRect(outerRect, outerRect)).toBe(true); + }); + + it('should return true when inner rect touches edges', () => { + const touchingEdges: Rectangle = { x: 0, y: 0, width: 500, height: 400 }; + + expect(isRectInsideRect(touchingEdges, outerRect)).toBe(true); + }); + + it('should return false when inner rect exceeds left bound', () => { + const exceeds: Rectangle = { x: -10, y: 100, width: 200, height: 150 }; + + expect(isRectInsideRect(exceeds, outerRect)).toBe(false); + }); + + it('should return false when inner rect exceeds top bound', () => { + const exceeds: Rectangle = { x: 100, y: -10, width: 200, height: 150 }; + + expect(isRectInsideRect(exceeds, outerRect)).toBe(false); + }); + + it('should return false when inner rect exceeds right bound', () => { + const exceeds: Rectangle = { x: 350, y: 100, width: 200, height: 150 }; + + expect(isRectInsideRect(exceeds, outerRect)).toBe(false); + }); + + it('should return false when inner rect exceeds bottom bound', () => { + const exceeds: Rectangle = { x: 100, y: 300, width: 200, height: 150 }; + + expect(isRectInsideRect(exceeds, outerRect)).toBe(false); + }); + }); + + describe('isRectMatchesAspectRatio', () => { + it('should return true when aspect ratio matches within threshold', () => { + const rect: Rectangle = { x: 0, y: 0, width: 400, height: 300 }; + + expect(isRectMatchesAspectRatio(rect, 4 / 3)).toBe(true); + }); + + it('should return true for exact 16:9 aspect ratio', () => { + const rect: Rectangle = { x: 0, y: 0, width: 1920, height: 1080 }; + + expect(isRectMatchesAspectRatio(rect, 16 / 9)).toBe(true); + }); + + it('should return true for 1:1 aspect ratio', () => { + const rect: Rectangle = { x: 0, y: 0, width: 500, height: 500 }; + + expect(isRectMatchesAspectRatio(rect, 1)).toBe(true); + }); + + it('should return false when aspect ratio differs significantly', () => { + const rect: Rectangle = { x: 0, y: 0, width: 400, height: 300 }; + + expect(isRectMatchesAspectRatio(rect, 16 / 9)).toBe(false); + }); + + it('should return true for slight variation within threshold', () => { + const rect: Rectangle = { x: 0, y: 0, width: 401, height: 300 }; + + expect(isRectMatchesAspectRatio(rect, 4 / 3)).toBe(true); + }); + }); + + describe('rotateSize', () => { + it('should not swap dimensions for 0 degree rotation', () => { + const result = rotateSize({ width: 800, height: 600 }, 0); + + expect(result.width).toBe(800); + expect(result.height).toBe(600); + }); + + it('should swap dimensions for 90 degree rotation', () => { + const result = rotateSize({ width: 800, height: 600 }, 90); + + expect(result.width).toBe(600); + expect(result.height).toBe(800); + }); + + it('should not swap dimensions for 180 degree rotation', () => { + const result = rotateSize({ width: 800, height: 600 }, 180); + + expect(result.width).toBe(800); + expect(result.height).toBe(600); + }); + + it('should swap dimensions for 270 degree rotation', () => { + const result = rotateSize({ width: 800, height: 600 }, 270); + + expect(result.width).toBe(600); + expect(result.height).toBe(800); + }); + + it('should not swap dimensions for 360 degree rotation', () => { + const result = rotateSize({ width: 800, height: 600 }, 360); + + expect(result.width).toBe(800); + expect(result.height).toBe(600); + }); + }); + + describe('calculateMaxCenteredCropFrame', () => { + it('should calculate centered crop for wider image than aspect ratio', () => { + const result = calculateMaxCenteredCropFrame(1600, 900, 4 / 3); + + expect(result.width).toBeLessThanOrEqual(1600); + expect(result.height).toBe(900); + expect(result.x).toBeGreaterThan(0); + expect(result.y).toBe(0); + }); + + it('should calculate centered crop for taller image than aspect ratio', () => { + const result = calculateMaxCenteredCropFrame(800, 1200, 4 / 3); + + expect(result.width).toBe(800); + expect(result.height).toBeLessThanOrEqual(1200); + expect(result.x).toBe(0); + expect(result.y).toBeGreaterThan(0); + }); + + it('should calculate crop for exact aspect ratio match', () => { + const result = calculateMaxCenteredCropFrame(800, 600, 4 / 3); + + expect(result.width).toBe(800); + expect(result.height).toBe(600); + expect(result.x).toBe(0); + expect(result.y).toBe(0); + }); + + it('should calculate crop for 1:1 aspect ratio', () => { + const result = calculateMaxCenteredCropFrame(1920, 1080, 1); + + expect(result.width).toBe(1080); + expect(result.height).toBe(1080); + expect(result.x).toBe(420); + expect(result.y).toBe(0); + }); + + it('should calculate crop for 16:9 aspect ratio on 4:3 image', () => { + const result = calculateMaxCenteredCropFrame(800, 600, 16 / 9); + + expect(result.width).toBe(800); + expect(result.height).toBeLessThanOrEqual(600); + expect(Math.abs(result.width / result.height - 16 / 9)).toBeLessThan(0.1); + }); + + it('should not exceed image bounds', () => { + const result = calculateMaxCenteredCropFrame(100, 100, 2); + + expect(result.x + result.width).toBeLessThanOrEqual(100); + expect(result.y + result.height).toBeLessThanOrEqual(100); + }); + }); + + describe('roundRect', () => { + it('should round all rectangle values', () => { + const rect: Rectangle = { x: 10.4, y: 20.6, width: 100.3, height: 50.7 }; + const result = roundRect(rect); + + expect(result.x).toBe(10); + expect(result.y).toBe(21); + expect(result.width).toBe(100); + expect(result.height).toBe(51); + }); + + it('should handle already rounded values', () => { + const rect: Rectangle = { x: 10, y: 20, width: 100, height: 50 }; + const result = roundRect(rect); + + expect(result).toEqual(rect); + }); + + it('should round 0.5 up', () => { + const rect: Rectangle = { x: 10.5, y: 20.5, width: 100.5, height: 50.5 }; + const result = roundRect(rect); + + expect(result.x).toBe(11); + expect(result.y).toBe(21); + expect(result.width).toBe(101); + expect(result.height).toBe(51); + }); + + it('should handle negative values', () => { + const rect: Rectangle = { x: -10.4, y: -20.6, width: 100.3, height: 50.7 }; + const result = roundRect(rect); + + expect(result.x).toBe(-10); + expect(result.y).toBe(-21); + expect(result.width).toBe(100); + expect(result.height).toBe(51); + }); + }); + + describe('clamp', () => { + it('should return value when within range', () => { + expect(clamp(50, 0, 100)).toBe(50); + }); + + it('should return min when value is below range', () => { + expect(clamp(-10, 0, 100)).toBe(0); + }); + + it('should return max when value is above range', () => { + expect(clamp(150, 0, 100)).toBe(100); + }); + + it('should return min when value equals min', () => { + expect(clamp(0, 0, 100)).toBe(0); + }); + + it('should return max when value equals max', () => { + expect(clamp(100, 0, 100)).toBe(100); + }); + + it('should work with negative ranges', () => { + expect(clamp(-50, -100, -10)).toBe(-50); + expect(clamp(-150, -100, -10)).toBe(-100); + expect(clamp(0, -100, -10)).toBe(-10); + }); + + it('should work with decimal values', () => { + expect(clamp(0.5, 0, 1)).toBe(0.5); + expect(clamp(-0.5, 0, 1)).toBe(0); + expect(clamp(1.5, 0, 1)).toBe(1); + }); + }); +}); diff --git a/src/blocks/CloudImageEditor/src/lib/FocusVisible.ts b/src/blocks/CloudImageEditor/src/lib/FocusVisible.ts deleted file mode 100644 index 288bd7fc4..000000000 --- a/src/blocks/CloudImageEditor/src/lib/FocusVisible.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { applyFocusVisiblePolyfill } from './applyFocusVisiblePolyfill.js'; - -type FocusVisibleDestructor = () => void; - -const destructors = new WeakMap(); - -function handleFocusVisible(focusVisible: boolean, element: EventTarget): void { - if (!(element instanceof HTMLElement)) { - return; - } - if (focusVisible) { - const customOutline = element.style.getPropertyValue('--focus-visible-outline'); - element.style.outline = customOutline || '2px solid var(--color-focus-ring)'; - } else { - element.style.outline = 'none'; - } -} - -export const FocusVisible = { - handleFocusVisible, - register(scope: ShadowRoot | Document): void { - destructors.set(scope, applyFocusVisiblePolyfill(scope, handleFocusVisible)); - }, - unregister(scope: Document | ShadowRoot): void { - const removeFocusVisiblePolyfill = destructors.get(scope); - if (!removeFocusVisiblePolyfill) { - return; - } - removeFocusVisiblePolyfill(); - destructors.delete(scope); - }, -}; diff --git a/src/blocks/CloudImageEditor/src/lib/applyFocusVisiblePolyfill.ts b/src/blocks/CloudImageEditor/src/lib/applyFocusVisiblePolyfill.ts deleted file mode 100644 index eb453d26d..000000000 --- a/src/blocks/CloudImageEditor/src/lib/applyFocusVisiblePolyfill.ts +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Helper function for legacy browsers and iframes which sometimes focus on elements like document, body, and - * non-interactive SVG. - */ -function isElement(target: EventTarget | null): target is Element { - return target instanceof Element; -} - -function isValidFocusTarget(target: EventTarget | null): target is Element { - if (!target || target === document) { - return false; - } - - if (!isElement(target)) { - return false; - } - - if (target.nodeName === 'HTML' || target.nodeName === 'BODY') { - return false; - } - - return 'classList' in target && 'contains' in target.classList; -} - -/* - * Computes whether the given element should automatically trigger the `focus-visible` class being added, i.e., whether - * it should always match `:focus-visible` when focused. - */ -function focusTriggersKeyboardModality(target: EventTarget | null): boolean { - if (!isElement(target)) { - return false; - } - - if (target instanceof HTMLInputElement && !target.readOnly) { - return true; - } - - if (target instanceof HTMLTextAreaElement && !target.readOnly) { - return true; - } - - if (target instanceof HTMLElement && target.isContentEditable) { - return true; - } - - return false; -} - -let hadKeyboardEvent = true; -let hadFocusVisibleRecently = false; - -/* - * Applies the :focus-visible polyfill at the given scope. A scope, in this case, is either the top-level Document or a - * Shadow Root. - */ -export function applyFocusVisiblePolyfill( - scope: Document | ShadowRoot, - callback?: (focusVisible: boolean, target: EventTarget) => void, -): () => void { - let hadFocusVisibleRecentlyTimeout: number | null = null; - const onFocusVisibleChange = callback ?? (() => {}); - - function addFocusVisibleClass(target: Element): void { - target.setAttribute('focus-visible', ''); - onFocusVisibleChange(true, target); - } - - function removeFocusVisibleClass(target: Element): void { - if (!target.hasAttribute('focus-visible')) { - return; - } - target.removeAttribute('focus-visible'); - onFocusVisibleChange(false, target); - } - - function onKeyDown(event: KeyboardEvent): void { - if (event.metaKey || event.altKey || event.ctrlKey) { - return; - } - - const activeElement = scope.activeElement; - if (isValidFocusTarget(activeElement)) { - addFocusVisibleClass(activeElement); - } - - hadKeyboardEvent = true; - } - - function onPointerDown(): void { - hadKeyboardEvent = false; - } - - function onFocus(event: Event): void { - const target = event.target; - if (!isValidFocusTarget(target)) { - return; - } - - if (hadKeyboardEvent || focusTriggersKeyboardModality(target)) { - addFocusVisibleClass(target); - } - } - - function onBlur(event: Event): void { - const target = event.target; - if (!isValidFocusTarget(target)) { - return; - } - - if (target.hasAttribute('focus-visible')) { - hadFocusVisibleRecently = true; - if (hadFocusVisibleRecentlyTimeout) { - window.clearTimeout(hadFocusVisibleRecentlyTimeout); - } - hadFocusVisibleRecentlyTimeout = window.setTimeout(() => { - hadFocusVisibleRecently = false; - }, 100); - removeFocusVisibleClass(target); - } - } - - function onVisibilityChange(): void { - if (document.visibilityState === 'hidden') { - if (hadFocusVisibleRecently) { - hadKeyboardEvent = true; - } - addInitialPointerMoveListeners(); - } - } - - function onInitialPointerMove(event: Event): void { - const target = event.target; - if (isElement(target) && target.nodeName.toLowerCase() === 'html') { - return; - } - - hadKeyboardEvent = false; - removeInitialPointerMoveListeners(); - } - - function addInitialPointerMoveListeners(): void { - document.addEventListener('mousemove', onInitialPointerMove); - document.addEventListener('mousedown', onInitialPointerMove); - document.addEventListener('mouseup', onInitialPointerMove); - document.addEventListener('pointermove', onInitialPointerMove); - document.addEventListener('pointerdown', onInitialPointerMove); - document.addEventListener('pointerup', onInitialPointerMove); - document.addEventListener('touchmove', onInitialPointerMove); - document.addEventListener('touchstart', onInitialPointerMove); - document.addEventListener('touchend', onInitialPointerMove); - } - - function removeInitialPointerMoveListeners(): void { - document.removeEventListener('mousemove', onInitialPointerMove); - document.removeEventListener('mousedown', onInitialPointerMove); - document.removeEventListener('mouseup', onInitialPointerMove); - document.removeEventListener('pointermove', onInitialPointerMove); - document.removeEventListener('pointerdown', onInitialPointerMove); - document.removeEventListener('pointerup', onInitialPointerMove); - document.removeEventListener('touchmove', onInitialPointerMove); - document.removeEventListener('touchstart', onInitialPointerMove); - document.removeEventListener('touchend', onInitialPointerMove); - } - - document.addEventListener('keydown', onKeyDown, true); - document.addEventListener('mousedown', onPointerDown, true); - document.addEventListener('pointerdown', onPointerDown, true); - document.addEventListener('touchstart', onPointerDown, true); - document.addEventListener('visibilitychange', onVisibilityChange, true); - - addInitialPointerMoveListeners(); - - scope.addEventListener('focus', onFocus, true); - scope.addEventListener('blur', onBlur, true); - - return () => { - removeInitialPointerMoveListeners(); - - document.removeEventListener('keydown', onKeyDown, true); - document.removeEventListener('mousedown', onPointerDown, true); - document.removeEventListener('pointerdown', onPointerDown, true); - document.removeEventListener('touchstart', onPointerDown, true); - document.removeEventListener('visibilitychange', onVisibilityChange, true); - - scope.removeEventListener('focus', onFocus, true); - scope.removeEventListener('blur', onBlur, true); - }; -} diff --git a/src/blocks/FileItem/FileItem.ts b/src/blocks/FileItem/FileItem.ts index 399223ab9..a65538fcb 100644 --- a/src/blocks/FileItem/FileItem.ts +++ b/src/blocks/FileItem/FileItem.ts @@ -478,6 +478,7 @@ export class FileItem extends FileItemConfig { l10n="@title:file-item-edit-button;@aria-label:file-item-edit-button" class="uc-edit-btn uc-mini-btn" set="onclick: onEdit; @hidden: !isEditable" + data-testid="edit" > diff --git a/src/utils/get-top-level-origin.test.ts b/src/utils/get-top-level-origin.test.ts new file mode 100644 index 000000000..9de90bae1 --- /dev/null +++ b/src/utils/get-top-level-origin.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; +import { getTopLevelOrigin } from './get-top-level-origin'; + +describe('getTopLevelOrigin', () => { + it('should return the top-level origin', () => { + const origin = getTopLevelOrigin(); + expect(origin).toBe(window.location.origin); + }); +}); diff --git a/tests/adaptive-image.e2e.test.tsx b/tests/adaptive-image.e2e.test.tsx new file mode 100644 index 000000000..1386ecc11 --- /dev/null +++ b/tests/adaptive-image.e2e.test.tsx @@ -0,0 +1,19 @@ +import { commands, page, userEvent } from '@vitest/browser/context'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import '../types/jsx'; +// biome-ignore lint/correctness/noUnusedImports: Used in JSX +import { renderer } from './utils/test-renderer'; + +beforeAll(async () => { + await import('@/solutions/adaptive-image/index.js'); +}); + +beforeEach(() => { + page.render(); +}); + +describe('Adaptive Image', () => { + it('should be rendered', async () => { + // await expect.element(page.getByTestId('uc-img')).toBeVisible(); + }); +}); diff --git a/tests/api.e2e.test.tsx b/tests/api.e2e.test.tsx index b6f268d4f..4d281e132 100644 --- a/tests/api.e2e.test.tsx +++ b/tests/api.e2e.test.tsx @@ -18,7 +18,7 @@ beforeEach(() => { page.render( <> - + , ); diff --git a/tests/cloud-image-editor.e2e.test.tsx b/tests/cloud-image-editor.e2e.test.tsx new file mode 100644 index 000000000..4a79d9208 --- /dev/null +++ b/tests/cloud-image-editor.e2e.test.tsx @@ -0,0 +1,82 @@ +import { page, userEvent } from '@vitest/browser/context'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import '../types/jsx'; + +// biome-ignore lint/correctness/noUnusedImports: Used in JSX +import { renderer } from './utils/test-renderer'; + +beforeAll(async () => { + // biome-ignore lint/suspicious/noTsIgnore: Ignoring TypeScript error for CSS import + // @ts-ignore + await import('@/solutions/cloud-image-editor/index.css'); + const UC = await import('@/index.js'); + UC.defineComponents(UC); +}); + +beforeEach(() => { + const ctxName = `test-${Math.random().toString(36).slice(2)}`; + page.render( + <> + + + , + ); +}); + +describe('Cloud Image Editor', () => { + it('should be rendered', async () => { + await expect.element(page.getByTestId('uc-cloud-image-editor')).toBeVisible(); + }); + + it('should select tunings tab', async () => { + const flip = page.getByTestId('uc-editor-crop-button-control').nth(2); + + await userEvent.click(flip); + }); + + it('should select crop preset', async () => { + const freeform = page.getByTestId('uc-editor-freeform-button-control'); + + await userEvent.click(freeform); + + const preset16x9 = page.getByTestId('uc-editor-aspect-ratio-button-control').nth(1); + + await expect.element(preset16x9).toBeVisible(); + + await userEvent.click(preset16x9); + + const apply = page.getByRole('button', { name: /apply/i }); + + await userEvent.click(apply); + + await expect.element(freeform).toBeVisible(); + }); + + it("should apply 'brightness' operation", async () => { + const tuningTab = page.getByRole('tab', { name: /tuning/i }); + await userEvent.click(tuningTab); + + const brightness = page.getByRole('option', { name: /Brightness/i }); + await userEvent.click(brightness); + + const slider = page.getByTestId('uc-editor-slider'); + await expect.element(slider).toBeVisible(); + + const applySlider = page.getByRole('button', { name: /apply/i }); + await userEvent.click(slider); + await userEvent.keyboard('[ArrowRight]'); + await userEvent.click(applySlider); + + await expect.element(tuningTab).toBeVisible(); + }); +}); diff --git a/tests/config.e2e.test.tsx b/tests/config.e2e.test.tsx index 805c4edee..d202fcd17 100644 --- a/tests/config.e2e.test.tsx +++ b/tests/config.e2e.test.tsx @@ -17,7 +17,7 @@ beforeEach(() => { page.render( <> - + , ); diff --git a/tests/file-uploader-inline.e2e.test.tsx b/tests/file-uploader-inline.e2e.test.tsx new file mode 100644 index 000000000..065bcb687 --- /dev/null +++ b/tests/file-uploader-inline.e2e.test.tsx @@ -0,0 +1,68 @@ +import { commands, page, userEvent } from '@vitest/browser/context'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import '../types/jsx'; +import { delay } from '@/utils/delay'; +// biome-ignore lint/correctness/noUnusedImports: Used in JSX +import { renderer } from './utils/test-renderer'; + +beforeAll(async () => { + // biome-ignore lint/suspicious/noTsIgnore: Ignoring TypeScript error for CSS import + // @ts-ignore + await import('@/solutions/file-uploader/inline/index.css'); + const UC = await import('@/index.js'); + UC.defineComponents(UC); +}); + +beforeEach(() => { + const ctxName = `test-${Math.random().toString(36).slice(2)}`; + page.render( + <> + + + , + ); +}); + +describe('File uploader inline', () => { + it('should be rendered', async () => { + await expect.element(page.getByTestId('uc-start-from').getByText('Drop files here', { exact: true })).toBeVisible(); + }); + + it('should open the url source, when clicked', async () => { + await page.getByText('From link', { exact: true }).click(); + const urlSource = page.getByTestId('uc-url-source'); + await expect(urlSource).toBeDefined(); + }); + + it('should open the camera source, when clicked', async () => { + await page.getByTestId('uc-start-from').getByText('Camera', { exact: true }).click(); + const cameraSource = page.getByTestId('uc-camera-source'); + await expect(cameraSource).toBeDefined(); + + const tabVideo = cameraSource.getByTestId('uc-camera-source--tab-video'); + const toggleMicrophone = cameraSource.getByTestId('uc-camera-source--toggle-microphone'); + + await userEvent.click(tabVideo); + + await expect(tabVideo).toHaveClass('uc-active'); + await expect(toggleMicrophone).toBeVisible(); + + const shot = cameraSource.getByTestId('uc-camera-source--shot'); + await userEvent.click(shot); + + await userEvent.click(toggleMicrophone); + await delay(2000); + await userEvent.click(toggleMicrophone); + + await userEvent.click(shot); + + const recordingTimer = cameraSource.getByTestId('uc-camera-source--recording-timer'); + await expect(recordingTimer).toBeVisible(); + + const accptButton = cameraSource.getByTestId('uc-camera-source--accept'); + await userEvent.click(accptButton); + + const uploadList = page.getByTestId('uc-upload-list'); + await expect(uploadList).toBeVisible(); + }); +}); diff --git a/tests/file-uploader-minimal.e2e.test.tsx b/tests/file-uploader-minimal.e2e.test.tsx new file mode 100644 index 000000000..1f6dfca31 --- /dev/null +++ b/tests/file-uploader-minimal.e2e.test.tsx @@ -0,0 +1,68 @@ +import { commands, page, userEvent } from '@vitest/browser/context'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import '../types/jsx'; +import fi from '@/locales/file-uploader/fi'; +// biome-ignore lint/correctness/noUnusedImports: Used in JSX +import { renderer } from './utils/test-renderer'; + +beforeAll(async () => { + // biome-ignore lint/suspicious/noTsIgnore: Ignoring TypeScript error for CSS import + // @ts-ignore + await import('@/solutions/file-uploader/minimal/index.css'); + const UC = await import('@/index.js'); + UC.defineComponents(UC); +}); + +beforeEach(() => { + const ctxName = `test-${Math.random().toString(36).slice(2)}`; + page.render( + <> + + + , + ); +}); + +describe('File uploader minimal', () => { + describe('Upload button', () => { + it('should be rendered', async () => { + await expect.element(page.getByText('Choose file', { exact: true })).toBeVisible(); + }); + + it('should open file dialog on click', async () => { + await page.getByText('Choose file', { exact: true }).click(); + const startFrom = page.getByTestId('uc-start-from'); + await expect(startFrom).toBeDefined(); + }); + + it('should drag and drop file', async () => { + const fileUploader = page.getByTestId('uc-file-uploader-minimal'); + const copyright = page.getByText('Powered by Uploadcare', { exact: true }); + + const uploadList = page.getByTestId('uc-upload-list'); + + await userEvent.dragAndDrop(copyright, fileUploader); + + await expect.element(uploadList).toBeVisible(); + }); + + it('should open cloud image editor modal on edit button click', async () => { + await page.getByText('Choose file', { exact: true }).click(); + const startFrom = page.getByTestId('uc-start-from'); + const uploadList = page.getByTestId('uc-upload-list'); + + commands.waitFileChooserAndUpload(['./fixtures/test_image.jpeg']); + + await startFrom.getByText('From device', { exact: true }).click(); + + await expect.element(uploadList).toBeVisible(); + const file = page.getByTestId('uc-file-item'); + + const editButton = file.getByTestId('uc-file-item--edit'); + await userEvent.click(editButton); + + const modal = page.getByTestId('uc-cloud-image-editor-activity'); + await expect.element(modal).toBeVisible(); + }); + }); +}); diff --git a/tests/file-uploader-regular.e2e.test.tsx b/tests/file-uploader-regular.e2e.test.tsx index b04a1d038..40fee25c5 100644 --- a/tests/file-uploader-regular.e2e.test.tsx +++ b/tests/file-uploader-regular.e2e.test.tsx @@ -17,7 +17,7 @@ beforeEach(() => { page.render( <> - + , ); }); diff --git a/tests/validation.e2e.test.tsx b/tests/validation.e2e.test.tsx index 280ab8f1c..ce632369b 100644 --- a/tests/validation.e2e.test.tsx +++ b/tests/validation.e2e.test.tsx @@ -20,7 +20,7 @@ beforeEach(() => { page.render( <> - + , ); @@ -207,7 +207,7 @@ describe('Custom file validation', () => { await page.getByLabelText('Edit', { exact: true }).click(); await page.getByLabelText('Apply mirror operation', { exact: true }).click(); await delay(300); - await page.getByLabelText('apply', { exact: true }).click(); + await page.getByRole('button', { name: /apply/i }).click(); expect(customValidator).toHaveBeenCalledTimes(1); }); @@ -253,7 +253,7 @@ describe('Custom file validation', () => { }); await page.getByLabelText('Apply mirror operation', { exact: true }).click(); await delay(300); - await page.getByLabelText('apply', { exact: true }).click(); + await page.getByRole('button', { name: /apply/i }).click(); expect(customValidator).toHaveBeenCalledTimes(1); }); @@ -297,7 +297,7 @@ describe('Custom file validation', () => { await page.getByLabelText('Edit', { exact: true }).click(); await page.getByLabelText('Apply mirror operation', { exact: true }).click(); const callsBeforeEdit = customValidator.mock.calls.length; - await page.getByLabelText('apply', { exact: true }).click(); + await page.getByRole('button', { name: /apply/i }).click(); await expect.poll(() => customValidator.mock.calls.length).toBe(callsBeforeEdit + 1); }); }); @@ -446,7 +446,7 @@ describe('Custom file validation', () => { await page.getByLabelText('Edit', { exact: true }).click(); await page.getByLabelText('Apply mirror operation', { exact: true }).click(); await delay(300); - await page.getByLabelText('apply', { exact: true }).click(); + await page.getByRole('button', { name: /apply/i }).click(); await expect .poll(() => validator, { @@ -578,7 +578,7 @@ describe('File errors API', () => { await page.getByLabelText('Edit', { exact: true }).click(); await page.getByLabelText('Apply mirror operation', { exact: true }).click(); await delay(300); - await page.getByLabelText('apply', { exact: true }).click(); + await page.getByRole('button', { name: /apply/i }).click(); await expect .poll(() => { @@ -591,7 +591,7 @@ describe('File errors API', () => { await page.getByLabelText('Edit', { exact: true }).click(); await page.getByLabelText('Apply mirror operation', { exact: true }).click(); await delay(300); - await page.getByLabelText('apply', { exact: true }).click(); + await page.getByRole('button', { name: /apply/i }).click(); await expect .poll(() => { @@ -604,7 +604,7 @@ describe('File errors API', () => { await page.getByLabelText('Edit', { exact: true }).click(); await page.getByLabelText('Apply mirror operation', { exact: true }).click(); await delay(300); - await page.getByLabelText('apply', { exact: true }).click(); + await page.getByRole('button', { name: /apply/i }).click(); await expect .poll(() => { diff --git a/tsconfig.test.json b/tsconfig.test.json index 0ae3a937e..5161d0ff2 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -5,12 +5,16 @@ "module": "esnext", "target": "esnext", "lib": ["ESNext", "ESNext.Array", "DOM", "DOM.Iterable"], - "types": ["node", "vitest", "@total-typescript/ts-reset", "./types/jsx.d.ts"], + "types": ["node", "vitest", "@total-typescript/ts-reset", "./types/jsx.d.ts", "vite/client"], "allowJs": true, "strict": true, "skipLibCheck": true, "noEmit": true, - "esModuleInterop": true + "esModuleInterop": true, + "paths": { + "@/*": ["./src/*"], + "~/*": ["./*"] + } }, "include": ["src", "types/test", "./**/*.test.ts", "./**/*.test-d.tsx"], "exclude": ["node_modules"] diff --git a/vite.config.js b/vite.config.js index 496185553..d0d485c52 100644 --- a/vite.config.js +++ b/vite.config.js @@ -21,6 +21,8 @@ export default defineConfig(({ command }) => { provider: 'v8', reporter: ['text', 'html'], reportsDirectory: './tests/__coverage__', + include: ['src/**/*.ts'], + exclude: ['**/*.test.*', '**/vite.config.js', './src/locales/**', './dist/**'], }, projects: [ {