feat(pwa): installable PWA + mobile responsive chrome
PWA対応とモバイルUX改善。 - app/manifest.ts: Nextファイルベースでマニフェスト生成(name/short_name/display:standalone/icons 192+512 any+maskable + svg) - public/icon.svg + 生成PNG(192/512): scripts/generate-icons.mjs で sharp を用いてSVGをラスタライズ - public/sw.js: アプリシェルのプリキャッシュ、ナビゲーションはネットワーク優先(オフラインはキャッシュ/'/'にフォールバック)、静的アセットはキャッシュ優先。API/非GET/クロスオリジンはキャッシュせず、res.ok&&basic でエラー/リダイレクト結果を弾く - components/pwa/ServiceWorkerRegister: 本番のみ /sw.js を登録(開発HMRとの衝突回避) - app/layout.tsx: export const viewport(device-width, cover, themeColor) + metadata.icons/appleWebApp - middleware: sw.js/manifest/icon を認証ガードから除外(ログイン前でも取得可能) - ProjectNav: 横スクロールStrip(折り返さない)、Header: モバイルでダッシュボードテキストリンクを非表示
This commit is contained in:
75
tests/e2e/pwa.spec.ts
Normal file
75
tests/e2e/pwa.spec.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
function unique(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
|
||||
async function setupOwner(page: Page): Promise<number> {
|
||||
const email = unique('owner') + '@example.com';
|
||||
await page.goto('/login');
|
||||
await page.getByRole('button', { name: '新規登録はこちら' }).click();
|
||||
await page.getByLabel('表示名').fill('Owner');
|
||||
await page.getByLabel('メールアドレス').fill(email);
|
||||
await page.getByLabel('パスワード').fill('password123');
|
||||
await page.getByRole('button', { name: '登録する' }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await page.getByLabel('プロジェクト名').fill(unique('Proj'));
|
||||
await page.getByRole('button', { name: '新規プロジェクト' }).click();
|
||||
await expect(page).toHaveURL(/\/projects\/\d+$/);
|
||||
return Number(page.url().match(/\/projects\/(\d+)/)![1]);
|
||||
}
|
||||
|
||||
test.describe('pwa', () => {
|
||||
test('manifest, service worker and icons are publicly served', async ({
|
||||
request,
|
||||
}) => {
|
||||
const manifestRes = await request.get('/manifest.webmanifest');
|
||||
expect(manifestRes.ok()).toBeTruthy();
|
||||
const manifest = (await manifestRes.json()) as {
|
||||
name: string;
|
||||
short_name: string;
|
||||
display: string;
|
||||
start_url: string;
|
||||
icons: { sizes: string; type: string; purpose?: string }[];
|
||||
};
|
||||
expect(manifest.name).toBe('Groupware');
|
||||
expect(manifest.short_name).toBe('Groupware');
|
||||
expect(manifest.display).toBe('standalone');
|
||||
expect(manifest.start_url).toBe('/');
|
||||
expect(manifest.icons.length).toBeGreaterThanOrEqual(2);
|
||||
expect(manifest.icons.some((i) => i.sizes === '192x192')).toBeTruthy();
|
||||
expect(manifest.icons.some((i) => i.sizes === '512x512')).toBeTruthy();
|
||||
expect(manifest.icons.some((i) => i.purpose === 'maskable')).toBeTruthy();
|
||||
|
||||
const swRes = await request.get('/sw.js');
|
||||
expect(swRes.ok()).toBeTruthy();
|
||||
const swText = await swRes.text();
|
||||
expect(swText).toContain('fetch');
|
||||
expect(swText).toContain('install');
|
||||
|
||||
expect((await request.get('/icon-192.png')).ok()).toBeTruthy();
|
||||
expect((await request.get('/icon-512.png')).ok()).toBeTruthy();
|
||||
expect((await request.get('/icon.svg')).ok()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('project nav scrolls horizontally on mobile width (no wrap)', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const ctx = await browser.newContext({
|
||||
storageState: 'tests/e2e/locale-ja.json',
|
||||
viewport: { width: 375, height: 812 },
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
await setupOwner(page);
|
||||
|
||||
const nav = page.getByTestId('project-nav');
|
||||
await expect(nav).toBeVisible();
|
||||
const scrollable = await nav.evaluate((el) => ({
|
||||
scrollWidth: el.scrollWidth,
|
||||
clientWidth: el.clientWidth,
|
||||
}));
|
||||
// ナビ項目が多数あるため、375px では横スクロールが発生する(折り返さない)
|
||||
expect(scrollable.scrollWidth).toBeGreaterThan(scrollable.clientWidth);
|
||||
await ctx.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user