Files
reader/web/test/pwa.test.ts
hectorqinandClaude Opus 5.5 6514387f9e fix(web): PWA orientation, media swipes and follow-finger page turns
- Manifest: drop orientation "any" (Chrome maps it to the full sensor, so the
  installed app ignored the phone's rotation lock); the service worker serves
  the manifest network-first so installed apps can pick up manifest changes.
- Media: derive the channel from the path for static routes (演播者 showed 影视);
  keep the tab bar on audiobook/narrators; a channel with no library swipes a
  whole channel at a time.
- Reader (paged mode): the column strip follows the finger; release turns on a
  flick or past 30% of the width, otherwise springs back; chapter edges fall
  through to the normal turn.
- PWA smoke test stubs the current window.__readerFlush hook.
- Docs: ui.md §3.8 drag-follow and media swipe rules; pwa.zh-CN.md orientation
  and manifest caching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:05:18 +00:00

156 lines
6.6 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { registerPwa, watchPwaUpdates } from '../src/core/pwa.ts';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
let registration: ServiceWorkerRegistration;
let serviceWorker: ServiceWorkerContainer;
let dispose: (() => void) | undefined;
const register = vi.fn();
beforeEach(() => {
registration = Object.assign(new EventTarget(), { waiting: null, installing: null, update: vi.fn().mockResolvedValue(undefined) }) as unknown as ServiceWorkerRegistration;
serviceWorker = Object.assign(new EventTarget(), { register, controller: {} }) as unknown as ServiceWorkerContainer;
register.mockResolvedValue(registration);
vi.useFakeTimers();
vi.stubEnv('PROD', true);
vi.stubEnv('BASE_URL', './');
vi.stubGlobal('isSecureContext', true);
vi.stubGlobal('navigator', { serviceWorker });
vi.spyOn(document, 'readyState', 'get').mockReturnValue('complete');
});
afterEach(() => {
dispose?.(); dispose = undefined;
document.body.replaceChildren();
vi.clearAllTimers();
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
register.mockClear();
});
function pendingUpdate() {
const postMessage = vi.fn();
Object.defineProperty(registration, 'waiting', { value: { postMessage }, configurable: true });
return postMessage;
}
it('waits for successful persistence before activating and reloads only once after control changes', async () => {
const postMessage = pendingUpdate();
let saved!: () => void;
const save = vi.fn(() => new Promise<void>((resolve) => { saved = resolve; }));
const reload = vi.fn();
dispose = watchPwaUpdates(registration, save, reload);
document.querySelector<HTMLButtonElement>('.pwa-update button')!.click();
expect(postMessage).not.toHaveBeenCalled();
saved(); await Promise.resolve();
expect(postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' });
expect(reload).not.toHaveBeenCalled();
serviceWorker.dispatchEvent(new Event('controllerchange'));
serviceWorker.dispatchEvent(new Event('controllerchange'));
expect(reload).toHaveBeenCalledTimes(1);
});
it('keeps the old page when saving fails and allows retry', async () => {
const postMessage = pendingUpdate();
const save = vi.fn().mockRejectedValueOnce(new Error('disk full')).mockResolvedValue(undefined);
const reload = vi.fn();
dispose = watchPwaUpdates(registration, save, reload);
const button = document.querySelector<HTMLButtonElement>('.pwa-update button')!;
button.click(); await Promise.resolve();
expect(postMessage).not.toHaveBeenCalled();
expect(reload).not.toHaveBeenCalled();
expect(button.disabled).toBe(false);
button.click(); await Promise.resolve();
expect(postMessage).toHaveBeenCalledTimes(1);
});
it('offers updates that finish downloading after registration', () => {
dispose = watchPwaUpdates(registration);
expect(document.querySelector('.pwa-update')).toBeNull();
const worker = new EventTarget();
Object.defineProperty(registration, 'installing', { value: worker });
registration.dispatchEvent(new Event('updatefound'));
pendingUpdate();
worker.dispatchEvent(new Event('statechange'));
expect(document.querySelector('.pwa-update button')?.textContent).toBe('立即更新');
});
it('does not reload when another tab activates, and saves before manual refresh', async () => {
pendingUpdate();
const save = vi.fn().mockResolvedValue(undefined), reload = vi.fn();
dispose = watchPwaUpdates(registration, save, reload);
serviceWorker.dispatchEvent(new Event('controllerchange'));
expect(reload).not.toHaveBeenCalled();
document.querySelector<HTMLButtonElement>('.pwa-update button')!.click();
await Promise.resolve();
expect(save).toHaveBeenCalledTimes(1);
expect(reload).toHaveBeenCalledTimes(1);
});
it('allows retry if worker activation times out', async () => {
pendingUpdate();
dispose = watchPwaUpdates(registration, async () => {}, vi.fn());
const button = document.querySelector<HTMLButtonElement>('.pwa-update button')!;
button.click(); await Promise.resolve();
vi.advanceTimersByTime(15_000);
expect(button.disabled).toBe(false);
expect(button.textContent).toBe('重试更新');
});
it('registers the worker relative to the deployed document and bypasses the HTTP cache', () => {
registerPwa();
const base = new URL('./', document.baseURI);
expect(register).toHaveBeenCalledWith(new URL('sw.js', base), {
scope: base.pathname, updateViaCache: 'none',
});
});
it('does not register in development', () => {
vi.stubEnv('PROD', false);
registerPwa();
expect(register).not.toHaveBeenCalled();
});
it('does not register in the Android shell', () => {
vi.stubGlobal('ReaderAndroid', {});
registerPwa();
expect(register).not.toHaveBeenCalled();
});
it('keeps ordinary HTTP and unsupported browsers usable', () => {
vi.stubGlobal('isSecureContext', false);
registerPwa();
expect(register).not.toHaveBeenCalled();
vi.stubGlobal('isSecureContext', true);
vi.stubGlobal('navigator', {});
expect(registerPwa).not.toThrow();
});
it('ships standalone launch metadata and a first-paint startup shell', () => {
const manifest = JSON.parse(readFileSync(resolve(process.cwd(), 'public/manifest.webmanifest'), 'utf8'));
expect(manifest.display).toBe('standalone');
expect(manifest.display_override).toContain('standalone');
// The page draws nothing in a title-bar area, so the overlay would hide the drag
// region and put the window controls over the content.
expect(manifest.display_override).not.toContain('window-controls-overlay');
// Any `orientation` overrides the phone's rotation lock: Chrome maps `any` to the full
// sensor. Without the key the installed app follows the system auto-rotate switch.
expect(manifest).not.toHaveProperty('orientation');
// Android masks launcher icons; without a maskable one it shrinks ours into a disc.
expect(manifest.icons.some((icon: { purpose?: string }) => icon.purpose === 'maskable')).toBe(true);
expect(manifest.launch_handler.client_mode).toBe('navigate-existing');
const html = readFileSync(resolve(process.cwd(), 'index.html'), 'utf8');
expect(html).toContain('id="app-startup"');
expect(html).toContain('apple-touch-startup-image');
});
it('reports failed registration without an unhandled rejection', async () => {
const error = new Error('storage unavailable');
register.mockRejectedValueOnce(error);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
registerPwa();
await vi.waitFor(() => expect(warn).toHaveBeenCalledWith('离线应用缓存注册失败', error));
});