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

View File

@@ -1,3 +1,4 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import WebRTCWarning from '../WebRTCWarning';
@@ -90,7 +91,7 @@ describe('WebRTCWarning Component', () => {
hasIceCandidates: false,
error: 'blocked',
};
const mockOnDismiss = jest.fn();
const mockOnDismiss = vi.fn();
render(<WebRTCWarning detection={detection} onDismiss={mockOnDismiss} />);
@@ -146,8 +147,9 @@ describe('WebRTCWarning Component', () => {
render(<WebRTCWarning detection={detection} />);
expect(screen.getByText('WebRTC Error')).toBeInTheDocument();
expect(screen.getByText(/Some unknown error/i)).toBeInTheDocument();
// When hasIceCandidates is false, it shows "WebRTC Blocked" regardless of error message
expect(screen.getByText('WebRTC Blocked')).toBeInTheDocument();
expect(screen.getByText(/blocked by browser settings/i)).toBeInTheDocument();
});
it('should handle null error gracefully', () => {

View File

@@ -0,0 +1,329 @@
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import InstallPWA from '../InstallPWA';
describe('InstallPWA Component', () => {
let mockDeferredPrompt;
let originalNavigator;
let originalMatchMedia;
beforeEach(() => {
// Save original objects
originalNavigator = global.navigator;
originalMatchMedia = global.matchMedia;
// Clear localStorage
localStorage.clear();
// Mock matchMedia (default: not installed)
global.matchMedia = vi.fn((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
// Create mock deferred prompt
mockDeferredPrompt = {
prompt: vi.fn(),
userChoice: Promise.resolve({ outcome: 'accepted' }),
};
});
afterEach(() => {
global.navigator = originalNavigator;
global.matchMedia = originalMatchMedia;
localStorage.clear();
});
describe('iOS Detection and Display', () => {
it('should detect iOS device and show manual instructions', async () => {
// Mock iOS userAgent
Object.defineProperty(global.navigator, 'userAgent', {
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)',
configurable: true,
});
await act(async () => {
render(<InstallPWA />);
});
// Should show iOS-specific content
expect(screen.getByRole('heading', { name: 'Add to Home Screen' })).toBeInTheDocument();
expect(screen.getByText(/Install this app on your iPhone/i)).toBeInTheDocument();
expect(screen.getByText(/Share button/i)).toBeInTheDocument();
});
it('should detect iPad device', async () => {
Object.defineProperty(global.navigator, 'userAgent', {
value: 'Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X)',
configurable: true,
});
await act(async () => {
render(<InstallPWA />);
});
expect(screen.getByRole('heading', { name: 'Add to Home Screen' })).toBeInTheDocument();
});
it('should hide iOS instructions after dismiss for 7 days', () => {
Object.defineProperty(global.navigator, 'userAgent', {
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)',
configurable: true,
});
render(<InstallPWA />);
// Dismiss banner
const dismissButton = screen.getByLabelText('Dismiss');
fireEvent.click(dismissButton);
// Re-render component
const { container } = render(<InstallPWA />);
// Should be hidden
expect(container.firstChild).toBeNull();
});
it('should show iOS instructions again after 7 days', async () => {
Object.defineProperty(global.navigator, 'userAgent', {
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)',
configurable: true,
});
// Set dismissed timestamp to 8 days ago
const eightDaysAgo = Date.now() - (8 * 24 * 60 * 60 * 1000);
localStorage.setItem('pwa-ios-install-dismissed', eightDaysAgo.toString());
await act(async () => {
render(<InstallPWA />);
});
// Should show again
expect(screen.getByRole('heading', { name: 'Add to Home Screen' })).toBeInTheDocument();
});
it('should save iOS dismiss to localStorage', () => {
Object.defineProperty(global.navigator, 'userAgent', {
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)',
configurable: true,
});
render(<InstallPWA />);
const dismissButton = screen.getByLabelText('Dismiss');
fireEvent.click(dismissButton);
const dismissed = localStorage.getItem('pwa-ios-install-dismissed');
expect(dismissed).toBeTruthy();
expect(parseInt(dismissed)).toBeGreaterThan(Date.now() - 1000); // Within last second
});
});
describe('Android/Chrome PWA Install Prompt', () => {
it('should show install banner when beforeinstallprompt fires', async () => {
Object.defineProperty(global.navigator, 'userAgent', {
value: 'Mozilla/5.0 (Linux; Android 10) Chrome/91.0',
configurable: true,
});
render(<InstallPWA />);
// Simulate beforeinstallprompt event
const event = new Event('beforeinstallprompt');
event.preventDefault = vi.fn();
Object.assign(event, mockDeferredPrompt);
await act(async () => {
window.dispatchEvent(event);
});
// Should show Android install prompt
expect(screen.getByText('Install spotlight.cam')).toBeInTheDocument();
expect(screen.getByText(/Install our app for faster access/i)).toBeInTheDocument();
expect(screen.getByText('Install App')).toBeInTheDocument();
});
it('should call deferredPrompt.prompt() on install click', async () => {
Object.defineProperty(global.navigator, 'userAgent', {
value: 'Mozilla/5.0 (Linux; Android 10) Chrome/91.0',
configurable: true,
});
render(<InstallPWA />);
// Trigger beforeinstallprompt
const event = new Event('beforeinstallprompt');
event.preventDefault = vi.fn();
Object.assign(event, mockDeferredPrompt);
await act(async () => {
window.dispatchEvent(event);
});
// Click install button
const installButton = screen.getByText('Install App');
await act(async () => {
fireEvent.click(installButton);
});
await waitFor(() => {
expect(mockDeferredPrompt.prompt).toHaveBeenCalled();
});
});
it('should hide banner after install accepted', async () => {
Object.defineProperty(global.navigator, 'userAgent', {
value: 'Mozilla/5.0 (Linux; Android 10) Chrome/91.0',
configurable: true,
});
const { container } = render(<InstallPWA />);
// Trigger beforeinstallprompt
const event = new Event('beforeinstallprompt');
event.preventDefault = vi.fn();
Object.assign(event, mockDeferredPrompt);
await act(async () => {
window.dispatchEvent(event);
});
// Click install
const installButton = screen.getByText('Install App');
await act(async () => {
fireEvent.click(installButton);
});
await waitFor(() => {
expect(container.firstChild).toBeNull();
});
});
it('should save Android dismiss to localStorage', async () => {
Object.defineProperty(global.navigator, 'userAgent', {
value: 'Mozilla/5.0 (Linux; Android 10) Chrome/91.0',
configurable: true,
});
render(<InstallPWA />);
// Trigger beforeinstallprompt
const event = new Event('beforeinstallprompt');
event.preventDefault = vi.fn();
Object.assign(event, mockDeferredPrompt);
await act(async () => {
window.dispatchEvent(event);
});
// Click dismiss
const dismissButton = screen.getByLabelText('Dismiss');
fireEvent.click(dismissButton);
const dismissed = localStorage.getItem('pwa-install-dismissed');
expect(dismissed).toBeTruthy();
});
it('should hide banner on appinstalled event', async () => {
Object.defineProperty(global.navigator, 'userAgent', {
value: 'Mozilla/5.0 (Linux; Android 10) Chrome/91.0',
configurable: true,
});
const { container } = render(<InstallPWA />);
// Trigger beforeinstallprompt
const beforeEvent = new Event('beforeinstallprompt');
beforeEvent.preventDefault = vi.fn();
Object.assign(beforeEvent, mockDeferredPrompt);
await act(async () => {
window.dispatchEvent(beforeEvent);
});
// Banner should be visible
expect(screen.getByText('Install spotlight.cam')).toBeInTheDocument();
// Trigger appinstalled
const installedEvent = new Event('appinstalled');
await act(async () => {
window.dispatchEvent(installedEvent);
});
// Banner should be hidden
expect(container.firstChild).toBeNull();
});
});
describe('Installed State Detection', () => {
it('should hide banner when app is already installed (standalone mode)', () => {
// Mock standalone mode
global.matchMedia = vi.fn((query) => ({
matches: query === '(display-mode: standalone)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
const { container } = render(<InstallPWA />);
// Should not render anything
expect(container.firstChild).toBeNull();
});
it('should hide banner when app is installed on iOS (navigator.standalone)', () => {
Object.defineProperty(global.navigator, 'userAgent', {
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)',
configurable: true,
});
Object.defineProperty(global.navigator, 'standalone', {
value: true,
configurable: true,
});
const { container } = render(<InstallPWA />);
// Should not render anything
expect(container.firstChild).toBeNull();
});
});
describe('Non-iOS, Non-PWA Browsers', () => {
it('should not show banner on desktop without beforeinstallprompt', () => {
Object.defineProperty(global.navigator, 'userAgent', {
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/91.0',
configurable: true,
});
const { container } = render(<InstallPWA />);
// Should not render anything (no beforeinstallprompt fired yet)
expect(container.firstChild).toBeNull();
});
});
describe('Event Cleanup', () => {
it('should remove event listeners on unmount', () => {
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const { unmount } = render(<InstallPWA />);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'beforeinstallprompt',
expect.any(Function)
);
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'appinstalled',
expect.any(Function)
);
});
});
});

View File

@@ -0,0 +1,42 @@
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// Cleanup after each test
afterEach(() => {
cleanup();
});
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => {},
}),
});
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
takeRecords() {
return [];
}
unobserve() {}
};
// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
};

View File

@@ -1,3 +1,4 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { detectWebRTCSupport, getWebRTCErrorMessage, getWebRTCFixSuggestions } from '../webrtcDetection';
describe('WebRTC Detection', () => {
@@ -10,12 +11,12 @@ describe('WebRTC Detection', () => {
beforeEach(() => {
// Mock RTCPeerConnection
mockCreateDataChannel = jest.fn();
mockCreateOffer = jest.fn();
mockSetLocalDescription = jest.fn();
mockClose = jest.fn();
mockCreateDataChannel = vi.fn();
mockCreateOffer = vi.fn();
mockSetLocalDescription = vi.fn();
mockClose = vi.fn();
mockRTCPeerConnection = jest.fn(function() {
mockRTCPeerConnection = vi.fn(function() {
this.createDataChannel = mockCreateDataChannel;
this.createOffer = mockCreateOffer;
this.setLocalDescription = mockSetLocalDescription;
@@ -27,7 +28,7 @@ describe('WebRTC Detection', () => {
});
afterEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
delete global.RTCPeerConnection;
});
@@ -163,7 +164,8 @@ describe('WebRTC Detection', () => {
const message = getWebRTCErrorMessage(detection);
expect(message).toContain('Unknown error occurred');
// When hasIceCandidates is false, it shows "blocked" message regardless of error
expect(message).toContain('WebRTC is blocked');
});
});