test(pwa): add comprehensive PWA and Vitest test suite

- Install Vitest and React Testing Library for frontend tests
- Configure Vitest with jsdom environment and coverage
- Add test setup file with global mocks (matchMedia, IntersectionObserver)
- Write InstallPWA component tests (14 tests):
  - iOS detection and manual installation instructions
  - Android/Chrome beforeinstallprompt event handling
  - Install and dismiss functionality
  - 7-day dismissal persistence (localStorage)
  - Installed state detection (standalone mode)
- Write PWA configuration tests (28 tests):
  - App icons existence (PNG and SVG)
  - iOS splash screens for multiple devices
  - Vite PWA plugin configuration
  - index.html meta tags (iOS PWA support)
  - Manifest schema validation
  - Service worker configuration (Workbox)
- Write service worker tests (24 tests):
  - Service worker registration and lifecycle
  - Workbox integration
  - Cache Storage API operations
- Migrate existing WebRTC tests from Jest to Vitest (25 tests):
  - Update imports to use Vitest (vi.fn, describe, it, expect)
  - Fix WebRTCWarning and webrtcDetection test expectations
- Add test scripts to package.json (test, test:watch, test:ui, test:coverage)

All 91 tests passing (InstallPWA: 14, PWA config: 28, Service Worker: 24,
WebRTC: 25 total across 2 files)
This commit is contained in:
Radosław Gierwiało
2025-11-19 21:24:34 +01:00
parent f0a1bfb31a
commit 9d1af60f30
9 changed files with 2332 additions and 11 deletions

View File

@@ -0,0 +1,356 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('Service Worker Registration Tests', () => {
let originalNavigator;
let mockServiceWorkerContainer;
let mockRegistration;
beforeEach(() => {
// Save original navigator
originalNavigator = global.navigator;
// Create mock service worker registration
mockRegistration = {
installing: null,
waiting: null,
active: {
state: 'activated',
postMessage: vi.fn(),
},
scope: '/',
update: vi.fn(),
unregister: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
// Create mock service worker container
mockServiceWorkerContainer = {
register: vi.fn().mockResolvedValue(mockRegistration),
getRegistration: vi.fn().mockResolvedValue(mockRegistration),
getRegistrations: vi.fn().mockResolvedValue([mockRegistration]),
ready: Promise.resolve(mockRegistration),
controller: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
// Mock navigator.serviceWorker
Object.defineProperty(global.navigator, 'serviceWorker', {
value: mockServiceWorkerContainer,
configurable: true,
writable: true,
});
});
afterEach(() => {
global.navigator = originalNavigator;
});
describe('Service Worker Support Detection', () => {
it('should detect service worker support in browser', () => {
expect('serviceWorker' in navigator).toBe(true);
});
it('should not have service worker in browsers that do not support it', () => {
// Remove serviceWorker
const navigatorWithoutSW = { ...global.navigator };
delete navigatorWithoutSW.serviceWorker;
global.navigator = navigatorWithoutSW;
expect('serviceWorker' in navigator).toBe(false);
});
});
describe('Service Worker Registration', () => {
it('should register service worker with correct scope', async () => {
if ('serviceWorker' in navigator) {
await navigator.serviceWorker.register('/sw.js', { scope: '/' });
expect(mockServiceWorkerContainer.register).toHaveBeenCalledWith(
'/sw.js',
expect.objectContaining({ scope: '/' })
);
}
});
it('should return registration object on successful registration', async () => {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.register('/sw.js');
expect(registration).toBeDefined();
expect(registration.scope).toBe('/');
expect(registration.active).toBeDefined();
}
});
it('should handle registration failure gracefully', async () => {
// Mock registration failure
mockServiceWorkerContainer.register = vi.fn().mockRejectedValue(
new Error('Registration failed')
);
if ('serviceWorker' in navigator) {
await expect(
navigator.serviceWorker.register('/sw.js')
).rejects.toThrow('Registration failed');
}
});
it('should get existing service worker registration', async () => {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.getRegistration('/');
expect(registration).toBeDefined();
expect(mockServiceWorkerContainer.getRegistration).toHaveBeenCalledWith('/');
}
});
it('should get all service worker registrations', async () => {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
expect(Array.isArray(registrations)).toBe(true);
expect(registrations.length).toBeGreaterThan(0);
}
});
});
describe('Service Worker Lifecycle', () => {
it('should have active service worker after registration', async () => {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.register('/sw.js');
expect(registration.active).toBeDefined();
expect(registration.active.state).toBe('activated');
}
});
it('should update service worker', async () => {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.register('/sw.js');
await registration.update();
expect(mockRegistration.update).toHaveBeenCalled();
}
});
it('should unregister service worker', async () => {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.register('/sw.js');
await registration.unregister();
expect(mockRegistration.unregister).toHaveBeenCalled();
}
});
it('should wait for service worker to be ready', async () => {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready;
expect(registration).toBeDefined();
expect(registration.active).toBeDefined();
}
});
});
describe('Service Worker Communication', () => {
it('should post message to service worker', async () => {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.register('/sw.js');
const message = { type: 'SKIP_WAITING' };
registration.active.postMessage(message);
expect(mockRegistration.active.postMessage).toHaveBeenCalledWith(message);
}
});
it('should listen for service worker messages', () => {
if ('serviceWorker' in navigator) {
const messageHandler = vi.fn();
navigator.serviceWorker.addEventListener('message', messageHandler);
expect(mockServiceWorkerContainer.addEventListener).toHaveBeenCalledWith(
'message',
messageHandler
);
}
});
it('should remove message event listener', () => {
if ('serviceWorker' in navigator) {
const messageHandler = vi.fn();
navigator.serviceWorker.addEventListener('message', messageHandler);
navigator.serviceWorker.removeEventListener('message', messageHandler);
expect(mockServiceWorkerContainer.removeEventListener).toHaveBeenCalledWith(
'message',
messageHandler
);
}
});
});
describe('Workbox Integration', () => {
it('should have workbox available in service worker scope', () => {
// Mock workbox global object
const mockWorkbox = {
core: {
clientsClaim: vi.fn(),
skipWaiting: vi.fn(),
},
precaching: {
precacheAndRoute: vi.fn(),
cleanupOutdatedCaches: vi.fn(),
},
routing: {
registerRoute: vi.fn(),
},
strategies: {
CacheFirst: vi.fn(),
NetworkFirst: vi.fn(),
StaleWhileRevalidate: vi.fn(),
},
};
global.workbox = mockWorkbox;
expect(global.workbox).toBeDefined();
expect(global.workbox.core).toBeDefined();
expect(global.workbox.precaching).toBeDefined();
expect(global.workbox.routing).toBeDefined();
expect(global.workbox.strategies).toBeDefined();
// Cleanup
delete global.workbox;
});
it('should call precacheAndRoute in service worker', () => {
const mockWorkbox = {
precaching: {
precacheAndRoute: vi.fn(),
},
};
global.workbox = mockWorkbox;
// Simulate precaching
const manifest = [
{ url: '/index.html', revision: 'abc123' },
{ url: '/assets/main.js', revision: 'def456' },
];
global.workbox.precaching.precacheAndRoute(manifest);
expect(mockWorkbox.precaching.precacheAndRoute).toHaveBeenCalledWith(manifest);
// Cleanup
delete global.workbox;
});
it('should cleanup outdated caches', () => {
const mockWorkbox = {
precaching: {
cleanupOutdatedCaches: vi.fn(),
},
};
global.workbox = mockWorkbox;
global.workbox.precaching.cleanupOutdatedCaches();
expect(mockWorkbox.precaching.cleanupOutdatedCaches).toHaveBeenCalled();
// Cleanup
delete global.workbox;
});
});
describe('Cache Storage API', () => {
let mockCache;
let mockCacheStorage;
beforeEach(() => {
mockCache = {
match: vi.fn(),
matchAll: vi.fn(),
add: vi.fn(),
addAll: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
keys: vi.fn(),
};
mockCacheStorage = {
open: vi.fn().mockResolvedValue(mockCache),
has: vi.fn().mockResolvedValue(true),
delete: vi.fn().mockResolvedValue(true),
keys: vi.fn().mockResolvedValue(['v1-static', 'v1-images']),
match: vi.fn(),
};
global.caches = mockCacheStorage;
});
it('should open cache storage', async () => {
const cache = await caches.open('v1-static');
expect(cache).toBeDefined();
expect(mockCacheStorage.open).toHaveBeenCalledWith('v1-static');
});
it('should check if cache exists', async () => {
const exists = await caches.has('v1-static');
expect(exists).toBe(true);
expect(mockCacheStorage.has).toHaveBeenCalledWith('v1-static');
});
it('should delete cache', async () => {
const deleted = await caches.delete('old-cache');
expect(deleted).toBe(true);
expect(mockCacheStorage.delete).toHaveBeenCalledWith('old-cache');
});
it('should list all caches', async () => {
const cacheNames = await caches.keys();
expect(Array.isArray(cacheNames)).toBe(true);
expect(cacheNames).toContain('v1-static');
expect(cacheNames).toContain('v1-images');
});
it('should add file to cache', async () => {
const cache = await caches.open('v1-static');
await cache.add('/index.html');
expect(mockCache.add).toHaveBeenCalledWith('/index.html');
});
it('should add multiple files to cache', async () => {
const cache = await caches.open('v1-static');
const urls = ['/index.html', '/main.js', '/style.css'];
await cache.addAll(urls);
expect(mockCache.addAll).toHaveBeenCalledWith(urls);
});
it('should match cached request', async () => {
mockCache.match = vi.fn().mockResolvedValue(
new Response('Cached content', { status: 200 })
);
const cache = await caches.open('v1-static');
const response = await cache.match('/index.html');
expect(response).toBeDefined();
expect(response.status).toBe(200);
expect(mockCache.match).toHaveBeenCalledWith('/index.html');
});
});
});