불변 profileName은 라우팅과 저장 경계에 유지하고 사용자 출력은 공통 표시명을 사용한다. 기본 인스턴스 suffix를 숨기고 비기본 인스턴스만 사람이 읽을 수 있게 구분한다.
160 lines
6.7 KiB
TypeScript
160 lines
6.7 KiB
TypeScript
import { expect, test, type Page, type Route } from '@playwright/test';
|
|
|
|
const response = (data: unknown) => ({ result: { data } });
|
|
|
|
const operationNames = (route: Route): string[] => {
|
|
const url = new URL(route.request().url());
|
|
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
|
};
|
|
|
|
const inputAt = (route: Route, index: number): Record<string, unknown> => {
|
|
const body = JSON.parse(route.request().postData() ?? '{}') as Record<
|
|
string,
|
|
{ json?: Record<string, unknown> } | Record<string, unknown>
|
|
>;
|
|
const input = body[String(index)] ?? ({} as Record<string, unknown>);
|
|
return ('json' in input && input.json ? input.json : input) as Record<string, unknown>;
|
|
};
|
|
|
|
const installFixture = async (page: Page) => {
|
|
const saved: Record<string, unknown>[] = [];
|
|
await page.addInitScript(() => {
|
|
window.localStorage.setItem('sammo-session-token', 'web-push-session');
|
|
});
|
|
await page.route('**/gateway/api/trpc/**', async (route) => {
|
|
const results = operationNames(route).map((operation, index) => {
|
|
if (operation === 'account.get') {
|
|
return response({
|
|
id: '11111111-1111-4111-8111-111111111111',
|
|
username: 'push-user',
|
|
displayName: '알림 사용자',
|
|
roles: ['user'],
|
|
oauthType: 'NONE',
|
|
createdAt: '2026-08-23T00:00:00.000Z',
|
|
iconUrl: null,
|
|
icons: [],
|
|
preferredPicture: 'default.jpg',
|
|
maxActiveIcons: 5,
|
|
nextUploadAt: null,
|
|
nextRetireAt: null,
|
|
thirdPartyUse: false,
|
|
deleteAfter: null,
|
|
});
|
|
}
|
|
if (operation === 'account.notifications.get') {
|
|
return response({
|
|
capability: { enabled: false, publicKey: null },
|
|
eventTypes: [
|
|
'TROOP_ANNIHILATED',
|
|
'PRIVATE_MESSAGE_RECEIVED',
|
|
'AUTONOMOUS_ACTION_ENDED',
|
|
'RESERVED_TURNS_ENDED',
|
|
'PROFILE_PREOPENED',
|
|
'PROFILE_OPEN_SCHEDULED',
|
|
'PROFILE_OPENED',
|
|
'NATION_DESTROYED',
|
|
'TARGET_DATE_REACHED',
|
|
],
|
|
profiles: [
|
|
{
|
|
profileName: 'hwe:default',
|
|
profile: 'hwe',
|
|
instanceKey: 'default',
|
|
displayName: '훼',
|
|
currentScenario: 'default',
|
|
status: 'RUNNING',
|
|
},
|
|
],
|
|
preferences: [],
|
|
subscriptionCount: 0,
|
|
currentDeviceSubscribed: false,
|
|
});
|
|
}
|
|
if (operation === 'account.notifications.setPreference') {
|
|
saved.push(inputAt(route, index));
|
|
return response({ ok: true });
|
|
}
|
|
throw new Error(`Unhandled gateway tRPC operation: ${operation}`);
|
|
});
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(results),
|
|
});
|
|
});
|
|
return saved;
|
|
};
|
|
|
|
test('web push settings are default-off and remain configurable while delivery is disabled', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const saved = await installFixture(page);
|
|
await page.setViewportSize({ width: 1280, height: 900 });
|
|
await page.goto('/gateway/account');
|
|
|
|
const table = page.locator('#notification-table');
|
|
await expect(table).toBeVisible();
|
|
await expect(table).toContainText('준비됨 · 운영 비활성');
|
|
await expect(table).toContainText('훼 · default');
|
|
await expect(table).not.toContainText('hwe:default');
|
|
await expect(page.getByRole('button', { name: '이 기기 알림 켜기' })).toBeDisabled();
|
|
const checkboxes = table.getByRole('checkbox');
|
|
await expect(checkboxes).toHaveCount(9);
|
|
for (let index = 0; index < 9; index += 1) await expect(checkboxes.nth(index)).not.toBeChecked();
|
|
|
|
await table.getByRole('checkbox', { name: '알림 받기' }).nth(1).check();
|
|
await expect.poll(() => saved.length).toBe(1);
|
|
expect(saved[0]).toMatchObject({
|
|
profileName: 'hwe:default',
|
|
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
|
enabled: true,
|
|
});
|
|
|
|
const profileSelect = table.locator('select');
|
|
await profileSelect.focus();
|
|
expect(await profileSelect.evaluate((element) => getComputedStyle(element).outlineStyle)).not.toBe('none');
|
|
const bounds = await table.boundingBox();
|
|
expect(bounds?.width).toBe(550);
|
|
const serviceWorkerScope = await page.evaluate(async () => (await navigator.serviceWorker.ready).scope);
|
|
expect(serviceWorkerScope).toBe('http://127.0.0.1:15130/gateway/');
|
|
const pwaAssets = await page.evaluate(async () => {
|
|
const [manifest, worker] = await Promise.all([fetch('/gateway/manifest.webmanifest'), fetch('/gateway/sw.js')]);
|
|
return {
|
|
manifestStatus: manifest.status,
|
|
manifestType: manifest.headers.get('content-type'),
|
|
manifestBody: await manifest.json(),
|
|
workerStatus: worker.status,
|
|
workerType: worker.headers.get('content-type'),
|
|
};
|
|
});
|
|
expect(pwaAssets).toMatchObject({
|
|
manifestStatus: 200,
|
|
manifestBody: { start_url: './', scope: './', display: 'standalone' },
|
|
workerStatus: 200,
|
|
});
|
|
expect(pwaAssets.manifestType).toContain('application/manifest+json');
|
|
expect(pwaAssets.workerType).toContain('javascript');
|
|
await page.screenshot({ path: testInfo.outputPath('web-push-settings-desktop.png'), fullPage: true });
|
|
});
|
|
|
|
test('web push settings fit a mobile Chromium viewport and show the iPhone install prerequisite', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(navigator, 'userAgent', {
|
|
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
|
|
configurable: true,
|
|
});
|
|
});
|
|
await installFixture(page);
|
|
await page.goto('/gateway/account');
|
|
|
|
const table = page.locator('#notification-table');
|
|
await expect(table).toContainText('홈 화면에 추가');
|
|
const bounds = await table.boundingBox();
|
|
expect(bounds?.x).toBe(0);
|
|
expect(bounds?.width).toBeLessThanOrEqual(390);
|
|
await page.screenshot({ path: testInfo.outputPath('web-push-settings-mobile.png'), fullPage: true });
|
|
});
|