51147a1429
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
Make the web SPA an installable PWA and surface it to users: - manifest.webmanifest + an install-only service worker + 192/512/maskable icons (ui/public); PWA head tags in index.html; the .webmanifest MIME type registered in the gateway (the distroless image has no /etc/mime.types). - Platform-adaptive install CTA (components/InstallApp.svelte + lib/pwa): one-tap on Chromium, manual Add-to-Home-Screen instructions on iOS Safari, hidden elsewhere / once installed / inside a Mini App. Shown under the logged-out login card and at the bottom of Settings. - The landing gains a third entry linking /app/ (the brand tile), with a caption under all three (Telegram / VK / Веб-версия). The service worker is navigation-only (network-first, cached-shell fallback); hashed assets and the Connect stream are untouched. It exists to satisfy Chromium's installability requirement and is the single growth point for a future opt-in offline mode. Tests: pwa.ts unit tests; a webui probe (manifest/sw.js content-type + the SPA-fallback-to-HTML trap); e2e for the one-tap CTA, the iOS instructions modal and the landing web entry. Docs: ARCHITECTURE §13, FUNCTIONAL (+ru), gateway README.
97 lines
3.5 KiB
TypeScript
97 lines
3.5 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { isIosSafari, isStandalone, resolveInstallMode } from './pwa';
|
|
|
|
describe('resolveInstallMode', () => {
|
|
const base = { deferredAvailable: false, standalone: false, iosSafari: false, inMiniApp: false };
|
|
|
|
it('hides inside a Mini App even with a captured prompt', () => {
|
|
expect(resolveInstallMode({ ...base, inMiniApp: true, deferredAvailable: true })).toBe('hidden');
|
|
});
|
|
|
|
it('hides once the app is installed (standalone)', () => {
|
|
expect(resolveInstallMode({ ...base, standalone: true, deferredAvailable: true })).toBe('hidden');
|
|
});
|
|
|
|
it('offers one-tap when a Chromium prompt was captured', () => {
|
|
expect(resolveInstallMode({ ...base, deferredAvailable: true })).toBe('oneTap');
|
|
});
|
|
|
|
it('offers iOS instructions on iOS Safari without a prompt', () => {
|
|
expect(resolveInstallMode({ ...base, iosSafari: true })).toBe('iosInstructions');
|
|
});
|
|
|
|
it('prefers one-tap over iOS instructions when both apply', () => {
|
|
expect(resolveInstallMode({ ...base, deferredAvailable: true, iosSafari: true })).toBe('oneTap');
|
|
});
|
|
|
|
it('hides on an unsupported browser (no prompt, not iOS)', () => {
|
|
expect(resolveInstallMode(base)).toBe('hidden');
|
|
});
|
|
});
|
|
|
|
describe('isStandalone', () => {
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
it('is true when the display-mode media query matches', () => {
|
|
vi.stubGlobal('window', {
|
|
matchMedia: (q: string) => ({ matches: q.includes('standalone') }),
|
|
navigator: {},
|
|
});
|
|
expect(isStandalone()).toBe(true);
|
|
});
|
|
|
|
it('is true for the iOS navigator.standalone flag', () => {
|
|
vi.stubGlobal('window', {
|
|
matchMedia: () => ({ matches: false }),
|
|
navigator: { standalone: true },
|
|
});
|
|
expect(isStandalone()).toBe(true);
|
|
});
|
|
|
|
it('is false in an ordinary browser tab', () => {
|
|
vi.stubGlobal('window', {
|
|
matchMedia: () => ({ matches: false }),
|
|
navigator: { standalone: false },
|
|
});
|
|
expect(isStandalone()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isIosSafari', () => {
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
const IPHONE_SAFARI =
|
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
|
const IPHONE_CHROME =
|
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0 Mobile/15E148 Safari/604.1';
|
|
const ANDROID_CHROME =
|
|
'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36';
|
|
const IPAD_SAFARI =
|
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
|
|
|
|
it('is true for Safari on iPhone', () => {
|
|
vi.stubGlobal('navigator', { userAgent: IPHONE_SAFARI, maxTouchPoints: 5 });
|
|
expect(isIosSafari()).toBe(true);
|
|
});
|
|
|
|
it('is true for Safari on iPadOS (Mac UA + touch points)', () => {
|
|
vi.stubGlobal('navigator', { userAgent: IPAD_SAFARI, maxTouchPoints: 5 });
|
|
expect(isIosSafari()).toBe(true);
|
|
});
|
|
|
|
it('is false for a desktop Mac (no touch points)', () => {
|
|
vi.stubGlobal('navigator', { userAgent: IPAD_SAFARI, maxTouchPoints: 0 });
|
|
expect(isIosSafari()).toBe(false);
|
|
});
|
|
|
|
it('is false for Chrome on iOS (CriOS)', () => {
|
|
vi.stubGlobal('navigator', { userAgent: IPHONE_CHROME, maxTouchPoints: 5 });
|
|
expect(isIosSafari()).toBe(false);
|
|
});
|
|
|
|
it('is false for Android Chrome', () => {
|
|
vi.stubGlobal('navigator', { userAgent: ANDROID_CHROME, maxTouchPoints: 5 });
|
|
expect(isIosSafari()).toBe(false);
|
|
});
|
|
});
|