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:
208
frontend/src/__tests__/pwa-config.test.js
Normal file
208
frontend/src/__tests__/pwa-config.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
356
frontend/src/__tests__/pwa-serviceWorker.test.js
Normal file
356
frontend/src/__tests__/pwa-serviceWorker.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
329
frontend/src/components/pwa/__tests__/InstallPWA.test.jsx
Normal file
329
frontend/src/components/pwa/__tests__/InstallPWA.test.jsx
Normal 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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
42
frontend/src/test/setup.js
Normal file
42
frontend/src/test/setup.js
Normal 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() {}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user