by thetestingacademy
PWA testing skill covering service worker validation, offline mode testing, cache strategy verification, web app manifest testing, push notification testing, install prompt testing, and background sync verification.
npx @qaskills/cli add pwa-testingAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
You are an expert QA automation engineer specializing in Progressive Web App (PWA) testing. When the user asks you to write, review, or debug tests for service workers, offline functionality, caching strategies, web app manifests, push notifications, or install prompts, follow these detailed instructions.
Always organize PWA testing projects with this structure:
tests/
pwa/
service-worker/
lifecycle.spec.ts
registration.spec.ts
update-flow.spec.ts
cache/
cache-first.spec.ts
network-first.spec.ts
stale-while-revalidate.spec.ts
cache-invalidation.spec.ts
offline/
offline-navigation.spec.ts
offline-forms.spec.ts
offline-fallback.spec.ts
manifest/
manifest-validation.spec.ts
installability.spec.ts
notifications/
push-subscription.spec.ts
notification-display.spec.ts
sync/
background-sync.spec.ts
lighthouse/
pwa-audit.spec.ts
fixtures/
pwa.fixture.ts
service-worker.fixture.ts
pages/
pwa-shell.page.ts
utils/
sw-helpers.ts
cache-helpers.ts
manifest-helpers.ts
playwright.config.ts
import { test, expect, Page } from '@playwright/test';
test.describe('Service Worker Lifecycle', () => {
test('should register service worker successfully', async ({ page }) => {
await page.goto('/');
// Wait for service worker to register
const swRegistered = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
return {
active: !!registration.active,
scope: registration.scope,
scriptURL: registration.active?.scriptURL || '',
};
});
expect(swRegistered.active).toBe(true);
expect(swRegistered.scope).toContain(new URL(page.url()).origin);
expect(swRegistered.scriptURL).toContain('sw.js');
});
test('should activate service worker and control the page', async ({ page }) => {
await page.goto('/');
const isControlled = await page.evaluate(async () => {
// Wait for service worker to be ready
await navigator.serviceWorker.ready;
// Check if the page is controlled
return !!navigator.serviceWorker.controller;
});
expect(isControlled).toBe(true);
});
test('should handle service worker installation phases', async ({ page }) => {
// Listen for service worker events
const swEvents: string[] = [];
await page.evaluate(() => {
return new Promise<void>((resolve) => {
navigator.serviceWorker.register('/sw.js').then((registration) => {
if (registration.installing) {
registration.installing.addEventListener('statechange', (event) => {
const sw = event.target as ServiceWorker;
window.__swEvents = window.__swEvents || [];
window.__swEvents.push(sw.state);
if (sw.state === 'activated') resolve();
});
} else if (registration.active) {
resolve();
}
});
});
});
await page.goto('/');
const events = await page.evaluate(() => (window as any).__swEvents || ['activated']);
// Service worker should transition through these states
const validStates = ['installed', 'activating', 'activated'];
for (const state of events) {
expect(validStates).toContain(state);
}
});
test('should unregister service worker cleanly', async ({ page }) => {
await page.goto('/');
const unregistered = await page.evaluate(async () => {
const registrations = await navigator.serviceWorker.getRegistrations();
const results = await Promise.all(registrations.map((r) => r.unregister()));
return results.every((r) => r === true);
});
expect(unregistered).toBe(true);
// Verify no service worker is controlling the page after reload
await page.reload();
const controlled = await page.evaluate(() => !!navigator.serviceWorker.controller);
expect(controlled).toBe(false);
});
});
import { test, expect } from '@playwright/test';
test.describe('Service Worker Update Flow', () => {
test('should detect new service worker version', async ({ page }) => {
await page.goto('/');
// Wait for initial SW to activate
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
});
// Trigger update check
const updateFound = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
return new Promise<boolean>((resolve) => {
registration.addEventListener('updatefound', () => {
resolve(true);
});
// Force update check
registration.update().catch(() => resolve(false));
// Timeout fallback
setTimeout(() => resolve(false), 5000);
});
});
// This test validates the update mechanism works -- actual update depends on server returning new SW
expect(typeof updateFound).toBe('boolean');
});
test('should notify user when update is available', async ({ page }) => {
await page.goto('/');
// Simulate a service worker update notification
await page.evaluate(() => {
// Trigger the app's update notification UI
window.dispatchEvent(new CustomEvent('sw-update-available'));
});
// Verify update notification appears
const updateBanner = page.getByTestId('sw-update-banner');
await expect(updateBanner).toBeVisible();
await expect(updateBanner).toContainText('new version');
// Click update button
await page.getByRole('button', { name: 'Update now' }).click();
// Page should reload with new version
await page.waitForLoadState('domcontentloaded');
});
test('should handle skipWaiting correctly', async ({ page }) => {
await page.goto('/');
const skipWaitingResult = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
if (registration.waiting) {
// Tell waiting SW to skip waiting
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
return new Promise<boolean>((resolve) => {
navigator.serviceWorker.addEventListener('controllerchange', () => {
resolve(true);
});
setTimeout(() => resolve(false), 5000);
});
}
return null; // No waiting SW
});
// If there was a waiting SW, it should have taken control
if (skipWaitingResult !== null) {
expect(skipWaitingResult).toBe(true);
}
});
});
import { test, expect } from '@playwright/test';
test.describe('Cache-First Strategy', () => {
test('should serve static assets from cache', async ({ page }) => {
// First visit -- populates cache
await page.goto('/');
await page.waitForLoadState('networkidle');
// Record cached resources
const cachedResources = await page.evaluate(async () => {
const cache = await caches.open('static-assets-v1');
const keys = await cache.keys();
return keys.map((r) => new URL(r.url).pathname);
});
expect(cachedResources.length).toBeGreaterThan(0);
// Go offline
await page.context().setOffline(true);
// Static assets should still load from cache
await page.reload();
await expect(page.locator('body')).toBeVisible();
// Restore connection
await page.context().setOffline(false);
});
test('should cache CSS and JS bundles', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const cachedTypes = await page.evaluate(async () => {
const cacheNames = await caches.keys();
const results: { cacheName: string; urls: string[] }[] = [];
for (const name of cacheNames) {
const cache = await caches.open(name);
const keys = await cache.keys();
results.push({
cacheName: name,
urls: keys.map((r) => r.url),
});
}
return results;
});
const allCachedUrls = cachedTypes.flatMap((c) => c.urls);
const hasCSSCached = allCachedUrls.some((url) => url.endsWith('.css'));
const hasJSCached = allCachedUrls.some((url) => url.endsWith('.js'));
expect(hasCSSCached).toBe(true);
expect(hasJSCached).toBe(true);
});
});
test.describe('Network-First Strategy', () => {
test('should fetch fresh API data when online', async ({ page }) => {
await page.goto('/');
// Monitor network requests
const apiRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('/api/')) {
apiRequests.push(request.url());
}
});
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.waitForLoadState('networkidle');
// Should have made real network requests
expect(apiRequests.length).toBeGreaterThan(0);
});
test('should fall back to cached API responses when offline', async ({ page }) => {
// Load page online first to populate cache
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
// Capture the data displayed
const onlineData = await page.getByTestId('dashboard-data').textContent();
// Go offline
await page.context().setOffline(true);
// Reload page
await page.reload();
// Should show cached data (may be stale)
const offlineData = await page.getByTestId('dashboard-data').textContent();
expect(offlineData).toBeTruthy();
expect(offlineData).toBe(onlineData); // Same as cached version
// Should show offline indicator
await expect(page.getByTestId('offline-indicator')).toBeVisible();
await page.context().setOffline(false);
});
});
test.describe('Stale-While-Revalidate Strategy', () => {
test('should serve stale content and update in background', async ({ page }) => {
// First visit
await page.goto('/');
await page.waitForLoadState('networkidle');
// Second visit -- should get cached version immediately
const navigationStart = Date.now();
await page.reload();
await page.waitForLoadState('domcontentloaded');
const loadTime = Date.now() - navigationStart;
// Should load very fast from cache
expect(loadTime).toBeLessThan(1000);
// Wait for background revalidation to complete
await page.waitForTimeout(2000);
// Cache should now contain updated content
const cacheUpdated = await page.evaluate(async () => {
const cache = await caches.open('pages-v1');
const response = await cache.match('/');
if (!response) return false;
const cacheDate = response.headers.get('date');
return !!cacheDate;
});
expect(cacheUpdated).toBe(true);
});
});
import { test, expect } from '@playwright/test';
test.describe('Offline Mode', () => {
test('should display offline fallback page for uncached routes', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Go offline
await page.context().setOffline(true);
// Navigate to a page that was not cached
await page.goto('/never-visited-page');
// Should show offline fallback
await expect(page.getByTestId('offline-fallback')).toBeVisible();
await expect(page.getByText('You are offline')).toBeVisible();
await page.context().setOffline(false);
});
test('should queue form submissions when offline', async ({ page }) => {
await page.goto('/feedback');
await page.waitForLoadState('networkidle');
// Go offline
await page.context().setOffline(true);
// Fill and submit form
await page.getByLabel('Name').fill('Test User');
await page.getByLabel('Message').fill('This is an offline submission');
await page.getByRole('button', { name: 'Submit' }).click();
// Should show queued confirmation
await expect(page.getByText('saved offline')).toBeVisible();
// Go back online
await page.context().setOffline(false);
// Wait for background sync to submit the form
await page.waitForResponse('**/api/feedback', { timeout: 10000 });
// Should show success confirmation
await expect(page.getByText('submitted successfully')).toBeVisible();
});
test('should indicate offline status to the user', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Online indicator should show
await expect(page.getByTestId('connection-status')).toContainText('Online');
// Go offline
await page.context().setOffline(true);
// Should detect offline and update UI
await expect(page.getByTestId('connection-status')).toContainText('Offline');
// Go back online
await page.context().setOffline(false);
// Should detect online and update UI
await expect(page.getByTestId('connection-status')).toContainText('Online');
});
test('should serve cached images when offline', async ({ page }) => {
// Visit page with images to cache them
await page.goto('/gallery');
await page.waitForLoadState('networkidle');
// Verify images loaded
const imageCount = await page.getByRole('img').count();
expect(imageCount).toBeGreaterThan(0);
// Go offline
await page.context().setOffline(true);
// Reload gallery
await page.reload();
// Images should still be visible from cache
const offlineImageCount = await page.getByRole('img').count();
expect(offlineImageCount).toBe(imageCount);
// Verify images actually loaded (not broken)
const brokenImages = await page.evaluate(() => {
const images = document.querySelectorAll('img');
return Array.from(images).filter((img) => !img.complete || img.naturalWidth === 0).length;
});
expect(brokenImages).toBe(0);
await page.context().setOffline(false);
});
test('should handle intermittent connectivity', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Simulate flaky connection
for (let i = 0; i < 3; i++) {
await page.context().setOffline(true);
await page.waitForTimeout(500);
await page.context().setOffline(false);
await page.waitForTimeout(500);
}
// App should remain functional
await expect(page.locator('body')).toBeVisible();
const hasError = await page.getByTestId('error-boundary').isVisible().catch(() => false);
expect(hasError).toBe(false);
});
});
import { test, expect } from '@playwright/test';
test.describe('Web App Manifest Validation', () => {
test('should have a valid manifest link in the HTML head', async ({ page }) => {
await page.goto('/');
const manifestLink = await page.evaluate(() => {
const link = document.querySelector('link[rel="manifest"]');
return link ? link.getAttribute('href') : null;
});
expect(manifestLink).toBeTruthy();
expect(manifestLink).toContain('manifest');
});
test('should have all required manifest fields', async ({ page, request }) => {
await page.goto('/');
const manifestHref = await page.evaluate(() => {
const link = document.querySelector('link[rel="manifest"]');
return link?.getAttribute('href') || '';
});
const manifestUrl = new URL(manifestHref, page.url()).toString();
const response = await request.get(manifestUrl);
expect(response.ok()).toBe(true);
const manifest = await response.json();
// Required fields for PWA installability
expect(manifest.name).toBeTruthy();
expect(manifest.short_name).toBeTruthy();
expect(manifest.start_url).toBeTruthy();
expect(manifest.display).toBeTruthy();
expect(['standalone', 'fullscreen', 'minimal-ui']).toContain(manifest.display);
expect(manifest.icons).toBeDefined();
expect(manifest.icons.length).toBeGreaterThan(0);
});
test('should have required icon sizes for installability', async ({ page, request }) => {
await page.goto('/');
const manifestHref = await page.evaluate(() => {
const link = document.querySelector('link[rel="manifest"]');
return link?.getAttribute('href') || '';
});
const manifestUrl = new URL(manifestHref, page.url()).toString();
const response = await request.get(manifestUrl);
const manifest = await response.json();
const iconSizes = manifest.icons.map((icon: { sizes: string }) => icon.sizes);
// Must have at least 192x192 and 512x512 icons
expect(iconSizes).toContain('192x192');
expect(iconSizes).toContain('512x512');
// Verify icons are accessible
for (const icon of manifest.icons) {
const iconUrl = new URL(icon.src, page.url()).toString();
const iconResponse = await request.get(iconUrl);
expect(iconResponse.ok(), `Icon ${icon.src} should be accessible`).toBe(true);
const contentType = iconResponse.headers()['content-type'] || '';
expect(contentType).toMatch(/image\/(png|svg|webp)/);
}
});
test('should have matching theme and background colors', async ({ page, request }) => {
await page.goto('/');
const manifestHref = await page.evaluate(() => {
const link = document.querySelector('link[rel="manifest"]');
return link?.getAttribute('href') || '';
});
const manifestUrl = new URL(manifestHref, page.url()).toString();
const response = await request.get(manifestUrl);
const manifest = await response.json();
expect(manifest.theme_color).toBeTruthy();
expect(manifest.background_color).toBeTruthy();
// Theme color should match meta tag
const metaThemeColor = await page.evaluate(() => {
const meta = document.querySelector('meta[name="theme-color"]');
return meta?.getAttribute('content');
});
if (metaThemeColor) {
expect(manifest.theme_color.toLowerCase()).toBe(metaThemeColor.toLowerCase());
}
});
test('should have correct start_url and scope', async ({ page, request }) => {
await page.goto('/');
const manifestHref = await page.evaluate(() => {
const link = document.querySelector('link[rel="manifest"]');
return link?.getAttribute('href') || '';
});
const manifestUrl = new URL(manifestHref, page.url()).toString();
const response = await request.get(manifestUrl);
const manifest = await response.json();
// start_url should be accessible
const startUrlResponse = await request.get(new URL(manifest.start_url, page.url()).toString());
expect(startUrlResponse.ok()).toBe(true);
// Scope should be defined
if (manifest.scope) {
expect(manifest.start_url).toContain(manifest.scope.replace(/\/$/, ''));
}
});
});
import { test, expect } from '@playwright/test';
test.describe('Push Notification Testing', () => {
test('should request notification permission', async ({ browser }) => {
const context = await browser.newContext({
permissions: ['notifications'],
});
const page = await context.newPage();
await page.goto('/');
const permissionState = await page.evaluate(async () => {
const result = await Notification.requestPermission();
return result;
});
expect(permissionState).toBe('granted');
await context.close();
});
test('should subscribe to push notifications', async ({ browser }) => {
const context = await browser.newContext({
permissions: ['notifications'],
});
const page = await context.newPage();
await page.goto('/settings/notifications');
// Enable push notifications
await page.getByRole('switch', { name: 'Push notifications' }).click();
const subscription = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: 'BEl62iUYgU...' // VAPID public key
});
return {
endpoint: sub.endpoint,
keys: {
p256dh: !!sub.getKey('p256dh'),
auth: !!sub.getKey('auth'),
},
};
});
expect(subscription.endpoint).toContain('https://');
expect(subscription.keys.p256dh).toBe(true);
expect(subscription.keys.auth).toBe(true);
await context.close();
});
test('should handle notification permission denied', async ({ browser }) => {
const context = await browser.newContext({
permissions: [], // No notification permission
});
const page = await context.newPage();
await page.goto('/settings/notifications');
// Try to enable push notifications
await page.getByRole('switch', { name: 'Push notifications' }).click();
// Should show permission denied message
await expect(page.getByText('notification permission')).toBeVisible();
await expect(page.getByText('enable notifications in your browser')).toBeVisible();
await context.close();
});
test('should display notification with correct content', async ({ browser }) => {
const context = await browser.newContext({
permissions: ['notifications'],
});
const page = await context.newPage();
await page.goto('/');
// Simulate receiving a push notification via service worker
const notificationData = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
// Trigger notification from service worker
await registration.showNotification('Test Notification', {
body: 'This is a test notification body',
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
tag: 'test-notification',
data: { url: '/notifications/123' },
});
// Get displayed notifications
const notifications = await registration.getNotifications({ tag: 'test-notification' });
return notifications.map((n) => ({
title: n.title,
body: n.body,
tag: n.tag,
}));
});
expect(notificationData.length).toBe(1);
expect(notificationData[0].title).toBe('Test Notification');
expect(notificationData[0].body).toBe('This is a test notification body');
await context.close();
});
});
import { test, expect } from '@playwright/test';
test.describe('Background Sync', () => {
test('should register a background sync event', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const syncRegistered = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
// Register a sync event
await registration.sync.register('outbox-sync');
// Verify registration
const tags = await registration.sync.getTags();
return tags.includes('outbox-sync');
});
expect(syncRegistered).toBe(true);
});
test('should sync pending data when connection restores', async ({ page }) => {
await page.goto('/notes');
await page.waitForLoadState('networkidle');
// Go offline
await page.context().setOffline(true);
// Create a note while offline
await page.getByLabel('Note title').fill('Offline Note');
await page.getByLabel('Note content').fill('Created while offline');
await page.getByRole('button', { name: 'Save' }).click();
// Note should be saved locally
await expect(page.getByText('Saved offline')).toBeVisible();
// Verify note is in pending sync queue
const pendingCount = await page.evaluate(async () => {
const db = await new Promise<IDBDatabase>((resolve) => {
const req = indexedDB.open('outbox', 1);
req.onsuccess = () => resolve(req.result);
});
const tx = db.transaction('pending', 'readonly');
const store = tx.objectStore('pending');
const count = await new Promise<number>((resolve) => {
const req = store.count();
req.onsuccess = () => resolve(req.result);
});
return count;
});
expect(pendingCount).toBeGreaterThan(0);
// Go back online -- background sync should trigger
await page.context().setOffline(false);
// Wait for sync to complete
await page.waitForResponse('**/api/notes', { timeout: 10000 });
// Note should now show as synced
await expect(page.getByText('Synced')).toBeVisible();
});
});
import { test, expect } from '@playwright/test';
test.describe('App Shell Architecture', () => {
test('should load app shell from cache instantly', async ({ page }) => {
// First visit to cache the shell
await page.goto('/');
await page.waitForLoadState('networkidle');
// Go offline
await page.context().setOffline(true);
// Measure reload time
const startTime = Date.now();
await page.reload();
await page.waitForLoadState('domcontentloaded');
const loadTime = Date.now() - startTime;
// App shell should load in under 1 second from cache
expect(loadTime).toBeLessThan(1000);
// Shell elements should be present
await expect(page.getByTestId('app-header')).toBeVisible();
await expect(page.getByTestId('app-nav')).toBeVisible();
await expect(page.getByTestId('app-footer')).toBeVisible();
await page.context().setOffline(false);
});
test('should stream content into the app shell', async ({ page }) => {
await page.goto('/');
// Shell should appear before content
await expect(page.getByTestId('app-header')).toBeVisible();
// Content area should show loading state then actual content
const contentArea = page.getByTestId('content-area');
await expect(contentArea).toBeVisible();
// Wait for dynamic content to load
await page.waitForLoadState('networkidle');
await expect(page.getByTestId('dynamic-content')).toBeVisible();
});
});
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import { readFileSync } from 'fs';
test.describe('Lighthouse PWA Audit', () => {
test('should pass Lighthouse PWA audit', async () => {
const url = process.env.BASE_URL || 'http://localhost:3000';
// Run Lighthouse CLI
execSync(
`npx lighthouse ${url} --only-categories=pwa --output=json --output-path=./lighthouse-pwa.json --chrome-flags="--headless --no-sandbox"`,
{ timeout: 60000 }
);
const report = JSON.parse(readFileSync('./lighthouse-pwa.json', 'utf-8'));
const pwaCategory = report.categories.pwa;
// PWA score should be at least 90
expect(pwaCategory.score * 100).toBeGreaterThanOrEqual(90);
// Check individual audits
const audits = report.audits;
// Installability
expect(audits['installable-manifest'].score).toBe(1);
// Service worker
expect(audits['service-worker'].score).toBe(1);
// HTTPS (or localhost)
expect(audits['is-on-https'].score).toBe(1);
// Viewport
expect(audits['viewport'].score).toBe(1);
// Content sized correctly for viewport
expect(audits['content-width'].score).toBe(1);
});
test('should have all required PWA manifest properties', async () => {
const url = process.env.BASE_URL || 'http://localhost:3000';
execSync(
`npx lighthouse ${url} --only-audits=installable-manifest --output=json --output-path=./lighthouse-manifest.json --chrome-flags="--headless --no-sandbox"`,
{ timeout: 60000 }
);
const report = JSON.parse(readFileSync('./lighthouse-manifest.json', 'utf-8'));
const audit = report.audits['installable-manifest'];
expect(audit.score).toBe(1);
// Should not have any failure reasons
if (audit.details?.debugData?.failures) {
expect(audit.details.debugData.failures).toHaveLength(0);
}
});
});
import { test, expect } from '@playwright/test';
test.describe('Cache Invalidation', () => {
test('should clear old caches when service worker updates', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Check current cache names
const initialCaches = await page.evaluate(async () => {
return await caches.keys();
});
expect(initialCaches.length).toBeGreaterThan(0);
// Simulate SW update by evaluating cache cleanup logic
const remainingCaches = await page.evaluate(async () => {
const CURRENT_VERSION = 'v2';
const cacheNames = await caches.keys();
// Delete caches that do not match current version
await Promise.all(
cacheNames
.filter((name) => !name.includes(CURRENT_VERSION))
.map((name) => caches.delete(name))
);
return await caches.keys();
});
// Only current version caches should remain
for (const name of remainingCaches) {
expect(name).toContain('v2');
}
});
test('should respect cache-control headers', async ({ page }) => {
const responses: { url: string; cacheControl: string | null }[] = [];
page.on('response', (response) => {
responses.push({
url: response.url(),
cacheControl: response.headers()['cache-control'] || null,
});
});
await page.goto('/');
await page.waitForLoadState('networkidle');
// HTML should have no-cache or short max-age
const htmlResponse = responses.find((r) => r.url.endsWith('/') || r.url.endsWith('.html'));
if (htmlResponse?.cacheControl) {
expect(htmlResponse.cacheControl).toMatch(/no-cache|max-age=0|must-revalidate/);
}
// Static assets should have long cache duration
const staticAssets = responses.filter(
(r) => r.url.match(/\.(js|css|png|jpg|svg|woff2?)(\?.*)?$/)
);
for (const asset of staticAssets) {
if (asset.cacheControl) {
// Static assets should be cached for at least a day
const maxAgeMatch = asset.cacheControl.match(/max-age=(\d+)/);
if (maxAgeMatch) {
expect(parseInt(maxAgeMatch[1])).toBeGreaterThanOrEqual(86400);
}
}
}
});
});
page.context().setOffline(true) but also test with network throttling to simulate real mobile conditions.navigator.serviceWorker.ready before testing cache-dependent features.npx playwright test tests/pwa/npx playwright test tests/pwa/service-worker/npx playwright test tests/pwa/offline/npx playwright test tests/pwa/manifest/npx lighthouse http://localhost:3000 --only-categories=pwanpx playwright test tests/pwa/ --headednpx playwright test tests/pwa/ --update-snapshots- name: Install QA Skills
run: npx @qaskills/cli add pwa-testing12 of 29 agents supported