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,208 @@
import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
describe('PWA Configuration Tests', () => {
describe('App Icons', () => {
const iconsDir = path.join(__dirname, '../../public/icons');
it('should have 192x192 icon (PNG)', () => {
const iconPath = path.join(iconsDir, 'icon-192x192.png');
expect(fs.existsSync(iconPath)).toBe(true);
});
it('should have 512x512 icon (PNG)', () => {
const iconPath = path.join(iconsDir, 'icon-512x512.png');
expect(fs.existsSync(iconPath)).toBe(true);
});
it('should have apple-touch-icon (PNG)', () => {
const iconPath = path.join(iconsDir, 'apple-touch-icon.png');
expect(fs.existsSync(iconPath)).toBe(true);
});
it('should have 192x192 icon (SVG)', () => {
const iconPath = path.join(iconsDir, 'icon-192x192.svg');
expect(fs.existsSync(iconPath)).toBe(true);
});
it('should have 512x512 icon (SVG)', () => {
const iconPath = path.join(iconsDir, 'icon-512x512.svg');
expect(fs.existsSync(iconPath)).toBe(true);
});
it('should have apple-touch-icon (SVG)', () => {
const iconPath = path.join(iconsDir, 'apple-touch-icon.svg');
expect(fs.existsSync(iconPath)).toBe(true);
});
});
describe('iOS Splash Screens', () => {
const splashDir = path.join(__dirname, '../../public/splash');
it('should have iPhone X splash screen (PNG)', () => {
const splashPath = path.join(splashDir, 'iphone-x.png');
expect(fs.existsSync(splashPath)).toBe(true);
});
it('should have iPhone XR splash screen (PNG)', () => {
const splashPath = path.join(splashDir, 'iphone-xr.png');
expect(fs.existsSync(splashPath)).toBe(true);
});
it('should have iPhone XS Max splash screen (PNG)', () => {
const splashPath = path.join(splashDir, 'iphone-xsmax.png');
expect(fs.existsSync(splashPath)).toBe(true);
});
it('should have iPhone 8 splash screen (PNG)', () => {
const splashPath = path.join(splashDir, 'iphone-8.png');
expect(fs.existsSync(splashPath)).toBe(true);
});
it('should have iPhone X splash screen (SVG)', () => {
const splashPath = path.join(splashDir, 'iphone-x.svg');
expect(fs.existsSync(splashPath)).toBe(true);
});
it('should have iPhone XR splash screen (SVG)', () => {
const splashPath = path.join(splashDir, 'iphone-xr.svg');
expect(fs.existsSync(splashPath)).toBe(true);
});
it('should have iPhone XS Max splash screen (SVG)', () => {
const splashPath = path.join(splashDir, 'iphone-xsmax.svg');
expect(fs.existsSync(splashPath)).toBe(true);
});
it('should have iPhone 8 splash screen (SVG)', () => {
const splashPath = path.join(splashDir, 'iphone-8.svg');
expect(fs.existsSync(splashPath)).toBe(true);
});
});
describe('Vite PWA Configuration', () => {
it('should have VitePWA plugin configured', () => {
const configPath = path.join(__dirname, '../../vite.config.js');
const configContent = fs.readFileSync(configPath, 'utf-8');
// Check for VitePWA import and usage
expect(configContent).toContain("import { VitePWA } from 'vite-plugin-pwa'");
expect(configContent).toContain('VitePWA(');
expect(configContent).toContain('plugins:');
});
});
describe('index.html Meta Tags', () => {
let htmlContent;
beforeAll(() => {
const htmlPath = path.join(__dirname, '../../index.html');
htmlContent = fs.readFileSync(htmlPath, 'utf-8');
});
it('should have apple-mobile-web-app-capable meta tag', () => {
expect(htmlContent).toContain('name="apple-mobile-web-app-capable"');
expect(htmlContent).toContain('content="yes"');
});
it('should have apple-mobile-web-app-status-bar-style meta tag', () => {
expect(htmlContent).toContain('name="apple-mobile-web-app-status-bar-style"');
expect(htmlContent).toContain('content="black-translucent"');
});
it('should have apple-mobile-web-app-title meta tag', () => {
expect(htmlContent).toContain('name="apple-mobile-web-app-title"');
expect(htmlContent).toContain('content="spotlight"');
});
it('should have apple-touch-icon link', () => {
expect(htmlContent).toContain('rel="apple-touch-icon"');
expect(htmlContent).toContain('href="/icons/apple-touch-icon.png"');
});
it('should have iOS splash screens for iPhone X', () => {
expect(htmlContent).toContain('rel="apple-touch-startup-image"');
expect(htmlContent).toContain('href="/splash/iphone-x.png"');
expect(htmlContent).toContain('device-width: 375px');
expect(htmlContent).toContain('device-height: 812px');
});
it('should have iOS splash screens for iPhone XR', () => {
expect(htmlContent).toContain('href="/splash/iphone-xr.png"');
expect(htmlContent).toContain('device-width: 414px');
expect(htmlContent).toContain('device-height: 896px');
});
it('should have iOS splash screens for iPhone XS Max', () => {
expect(htmlContent).toContain('href="/splash/iphone-xsmax.png"');
expect(htmlContent).toContain('device-width: 414px');
expect(htmlContent).toContain('device-height: 896px');
expect(htmlContent).toContain('-webkit-device-pixel-ratio: 3');
});
it('should have iOS splash screens for iPhone 8', () => {
expect(htmlContent).toContain('href="/splash/iphone-8.png"');
expect(htmlContent).toContain('device-width: 375px');
expect(htmlContent).toContain('device-height: 667px');
});
it('should have theme-color meta tag', () => {
expect(htmlContent).toContain('name="theme-color"');
expect(htmlContent).toContain('content="#6366f1"');
});
it('should have viewport meta tag', () => {
expect(htmlContent).toContain('name="viewport"');
expect(htmlContent).toContain('width=device-width');
expect(htmlContent).toContain('initial-scale=1.0');
});
});
describe('PWA Manifest Schema Validation', () => {
it('should have valid manifest configuration in vite.config.js', async () => {
const configPath = path.join(__dirname, '../../vite.config.js');
const configContent = fs.readFileSync(configPath, 'utf-8');
// Check for required manifest fields
expect(configContent).toContain('manifest:');
expect(configContent).toContain('name:');
expect(configContent).toContain('short_name:');
expect(configContent).toContain('theme_color:');
expect(configContent).toContain('display:');
expect(configContent).toContain('icons:');
// Check for spotlight.cam specific values
expect(configContent).toContain('spotlight.cam');
expect(configContent).toContain('#6366f1'); // indigo theme
expect(configContent).toContain('standalone');
});
});
describe('Service Worker Configuration', () => {
it('should have workbox configuration in vite.config.js', async () => {
const configPath = path.join(__dirname, '../../vite.config.js');
const configContent = fs.readFileSync(configPath, 'utf-8');
// Check for Workbox settings
expect(configContent).toContain('workbox:');
expect(configContent).toContain('globPatterns');
// Should cache static assets
expect(configContent).toMatch(/\.(js|css|html|ico|png|svg|woff2)/);
});
it('should have registerType configured', async () => {
const configPath = path.join(__dirname, '../../vite.config.js');
const configContent = fs.readFileSync(configPath, 'utf-8');
// Should use autoUpdate
expect(configContent).toContain('registerType:');
expect(configContent).toContain('autoUpdate');
});
});
});

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');
});
});
});