562 lines
22 KiB
TypeScript
562 lines
22 KiB
TypeScript
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
|
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
|
|
|
const response = (data: unknown) => ({ result: { data } });
|
|
const artifactRoot = process.env.GATEWAY_STATUS_ARTIFACT_DIR;
|
|
|
|
if (artifactRoot) {
|
|
mkdirSync(artifactRoot, { recursive: true });
|
|
}
|
|
|
|
const operationNames = (route: Route): string[] => {
|
|
const url = new URL(route.request().url());
|
|
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
|
};
|
|
|
|
const fulfillTrpc = async (route: Route, results: unknown[]): Promise<void> => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
headers: {
|
|
'access-control-allow-origin': '*',
|
|
},
|
|
body: JSON.stringify(results),
|
|
});
|
|
};
|
|
|
|
type LobbyFixtureOptions = {
|
|
authenticated?: boolean;
|
|
roles?: string[];
|
|
kakaoVerified?: boolean;
|
|
canCreateGeneral?: boolean;
|
|
requiresKakaoVerification?: boolean;
|
|
specialAccess?: {
|
|
kind: 'OPERATOR' | 'TESTER' | 'RECOVERY' | 'OTHER';
|
|
grantId: string | null;
|
|
expiresAt: string | null;
|
|
allowsGeneralCreation: boolean;
|
|
} | null;
|
|
myGeneral?: {
|
|
name: string;
|
|
picture: string;
|
|
imageServer: number;
|
|
} | null;
|
|
selectionPoolEnabled?: boolean;
|
|
npcPossessionEnabled?: boolean;
|
|
userCnt?: number;
|
|
maxUserCnt?: number;
|
|
nationCnt?: number;
|
|
isUnited?: number;
|
|
starttime?: string;
|
|
opentime?: string;
|
|
turntime?: string;
|
|
lobbyBundleFailures?: number;
|
|
profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
|
|
};
|
|
|
|
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
|
|
const {
|
|
authenticated = true,
|
|
roles = ['user'],
|
|
kakaoVerified = true,
|
|
canCreateGeneral = true,
|
|
requiresKakaoVerification = false,
|
|
specialAccess = null,
|
|
myGeneral = {
|
|
name: '선택장수',
|
|
picture: 'users/core2026/account-hash.png',
|
|
imageServer: 1,
|
|
},
|
|
selectionPoolEnabled = true,
|
|
npcPossessionEnabled = false,
|
|
userCnt = 1,
|
|
maxUserCnt = 500,
|
|
nationCnt = 0,
|
|
isUnited = 0,
|
|
starttime = '2026-07-30 00:00:00',
|
|
opentime = '2026-07-30 00:00:00',
|
|
turntime = '2026-07-30 00:05:00',
|
|
lobbyBundleFailures = 0,
|
|
profileStatus = 'RUNNING',
|
|
} = options;
|
|
let remainingLobbyBundleFailures = lobbyBundleFailures;
|
|
const gameOperations: Array<{ operation: string; authorization: string | undefined }> = [];
|
|
if (authenticated) {
|
|
await page.addInitScript(() => {
|
|
window.localStorage.setItem('sammo-session-token', 'gateway-lobby-session');
|
|
});
|
|
}
|
|
await page.route('**/gateway/api/trpc/**', async (route) => {
|
|
const results = operationNames(route).map((operation) => {
|
|
if (operation === 'me') {
|
|
return response(
|
|
authenticated
|
|
? {
|
|
id: 'lobby-user',
|
|
username: 'lobby-user',
|
|
displayName: '로비사용자',
|
|
roles,
|
|
kakaoVerified,
|
|
createdAt: '2026-07-30T00:00:00.000Z',
|
|
}
|
|
: null
|
|
);
|
|
}
|
|
if (operation === 'lobby.notice') {
|
|
return response('');
|
|
}
|
|
if (operation === 'lobby.profiles') {
|
|
return response([
|
|
{
|
|
profileName: 'hwe:903',
|
|
profile: 'hwe',
|
|
instanceKey: '903',
|
|
currentScenario: '903',
|
|
scenario: '903',
|
|
status: profileStatus,
|
|
lifecycle: {
|
|
runtimeExpected: profileStatus !== 'STOPPED',
|
|
userAccessible: profileStatus !== 'STOPPED',
|
|
turnsRunning: profileStatus === 'RUNNING',
|
|
operatorResumable: profileStatus === 'PAUSED' || profileStatus === 'STOPPED',
|
|
dataInitialized: true,
|
|
},
|
|
apiPort: 15015,
|
|
runtime: {
|
|
apiRunning: true,
|
|
daemonRunning: true,
|
|
auctionRunning: true,
|
|
battleSimRunning: true,
|
|
tournamentRunning: true,
|
|
},
|
|
korName: 'hwe',
|
|
color: '#ffffff',
|
|
localAccountPolicy: {
|
|
accessAllowed: true,
|
|
canCreateGeneral,
|
|
requiresKakaoVerification,
|
|
graceEndsAt: null,
|
|
specialAccess,
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
if (operation === 'auth.issueGameSession') {
|
|
return response({
|
|
profile: 'hwe:903',
|
|
gameToken: 'encrypted-gateway-game-token',
|
|
expiresAt: '2026-07-30T01:00:00.000Z',
|
|
});
|
|
}
|
|
throw new Error(`Unhandled gateway tRPC operation: ${operation}`);
|
|
});
|
|
await fulfillTrpc(route, results);
|
|
});
|
|
await page.route('**/hwe/api/trpc/**', async (route) => {
|
|
expect(new URL(route.request().url()).pathname).toContain('/hwe/api/trpc/');
|
|
const authorization = route.request().headers().authorization;
|
|
const operations = operationNames(route);
|
|
if (operations.includes('lobby.info') && remainingLobbyBundleFailures > 0) {
|
|
remainingLobbyBundleFailures -= 1;
|
|
gameOperations.push(...operations.map((operation) => ({ operation, authorization })));
|
|
await route.fulfill({
|
|
status: 502,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ error: 'profile runtime is switching' }),
|
|
});
|
|
return;
|
|
}
|
|
const results = operations.map((operation) => {
|
|
gameOperations.push({ operation, authorization });
|
|
if (operation === 'auth.exchangeGatewayToken') {
|
|
return response({
|
|
accessToken: 'ga_lobby-access-token',
|
|
profile: 'hwe:903',
|
|
expiresAt: '2026-07-30T01:00:00.000Z',
|
|
});
|
|
}
|
|
if (operation === 'lobby.info') {
|
|
return response({
|
|
year: 180,
|
|
month: 1,
|
|
userCnt,
|
|
maxUserCnt,
|
|
npcCnt: 0,
|
|
nationCnt,
|
|
turnTerm: 5,
|
|
fictionMode: '가상',
|
|
starttime,
|
|
opentime,
|
|
turntime,
|
|
otherTextInfo: '',
|
|
isUnited,
|
|
selectionPoolEnabled,
|
|
npcPossessionEnabled,
|
|
myGeneral,
|
|
});
|
|
}
|
|
if (operation === 'public.getMapLayout') {
|
|
return response({ mapName: 'che', cityList: [] });
|
|
}
|
|
if (operation === 'public.getCachedMap') {
|
|
return response({ year: 180, month: 1, cityList: [], nationList: [] });
|
|
}
|
|
throw new Error(`Unhandled game tRPC operation: ${operation}`);
|
|
});
|
|
await fulfillTrpc(route, results);
|
|
});
|
|
await page.route('https://sam-image.hided.net/icons/users/core2026/account-hash.png', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'image/png',
|
|
body: Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
|
'base64'
|
|
),
|
|
});
|
|
});
|
|
return gameOperations;
|
|
};
|
|
|
|
test('exchanges the gateway token before loading authenticated lobby general data', async ({ page }) => {
|
|
const gameOperations = await installFixture(page);
|
|
|
|
await page.goto('lobby');
|
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
|
await expect(row).toContainText('선택장수');
|
|
await expect(row.getByRole('button', { name: '입장' })).toBeVisible();
|
|
const portrait = row.locator('img');
|
|
await expect(portrait).toHaveAttribute('src', 'https://sam-image.hided.net/icons/users/core2026/account-hash.png');
|
|
await expect.poll(() => portrait.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1);
|
|
|
|
expect(gameOperations.find(({ operation }) => operation === 'auth.exchangeGatewayToken')).toEqual({
|
|
operation: 'auth.exchangeGatewayToken',
|
|
authorization: undefined,
|
|
});
|
|
expect(gameOperations.find(({ operation }) => operation === 'lobby.info')).toEqual({
|
|
operation: 'lobby.info',
|
|
authorization: 'Bearer ga_lobby-access-token',
|
|
});
|
|
});
|
|
|
|
test('loads and labels a PAUSED profile whose runtime remains available', async ({ page }, testInfo) => {
|
|
const gameOperations = await installFixture(page, { profileStatus: 'PAUSED' });
|
|
await page.setViewportSize({ width: 1365, height: 900 });
|
|
|
|
await page.goto('lobby');
|
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
|
const pausedStatus = row.getByTestId('profile-paused-status');
|
|
await expect(pausedStatus).toHaveText('턴 일시정지 · 조회/예약턴 가능');
|
|
await expect(pausedStatus).toHaveCSS('color', 'oklch(0.879 0.169 91.605)');
|
|
await expect(row).toContainText('선택장수');
|
|
await expect(row).not.toContainText('정보를 불러오는 중');
|
|
await expect(row.getByRole('button', { name: '입장' })).toBeVisible();
|
|
expect(gameOperations.some(({ operation }) => operation === 'lobby.info')).toBe(true);
|
|
await expect(page.getByRole('tab', { name: 'hwe섭' })).toBeVisible();
|
|
await page.screenshot({ path: testInfo.outputPath('gateway-paused-profile-lobby.png'), fullPage: true });
|
|
});
|
|
|
|
test('does not contact a STOPPED game runtime and labels it inaccessible', async ({ page }, testInfo) => {
|
|
const gameOperations = await installFixture(page, { profileStatus: 'STOPPED' });
|
|
|
|
await page.goto('lobby');
|
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
|
await expect(row).toContainText('서버 중지 · 접근 불가');
|
|
await expect(row).not.toContainText('정보를 불러오는 중');
|
|
await expect(row.getByRole('button', { name: '입장' })).toHaveCount(0);
|
|
await expect(page.getByRole('tab', { name: 'hwe섭' })).toHaveCount(0);
|
|
expect(gameOperations).toEqual([]);
|
|
await page.screenshot({ path: testInfo.outputPath('gateway-stopped-profile-lobby.png'), fullPage: true });
|
|
});
|
|
|
|
test('automatically recovers profile details after a transient update outage', async ({ page }) => {
|
|
const gameOperations = await installFixture(page, { lobbyBundleFailures: 1 });
|
|
|
|
await page.goto('lobby');
|
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
|
await expect(row.getByTestId('profile-info-retrying')).toContainText('서버 응답을 기다리고 있습니다.');
|
|
await expect(row.getByRole('button', { name: '지금 다시 확인' })).toBeVisible();
|
|
await expect(row).toContainText('선택장수', { timeout: 8_000 });
|
|
expect(gameOperations.filter(({ operation }) => operation === 'lobby.info')).toHaveLength(2);
|
|
});
|
|
|
|
test('offers a keyboard-accessible immediate retry without mobile overflow', async ({ page }, testInfo) => {
|
|
await installFixture(page, { lobbyBundleFailures: 1 });
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
|
|
await page.goto('lobby');
|
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
|
const retry = row.getByRole('button', { name: '지금 다시 확인' });
|
|
const tableScroll = page.getByTestId('profile-table-scroll');
|
|
await expect(retry).toBeVisible();
|
|
await retry.focus();
|
|
await expect(retry).toBeFocused();
|
|
const geometry = await tableScroll.evaluate((scrollElement) => {
|
|
const row = scrollElement.querySelector('tbody tr');
|
|
const button = row?.querySelector('button');
|
|
if (!row) throw new Error('expected profile row');
|
|
if (!button) throw new Error('expected profile retry button');
|
|
const scrollRect = scrollElement.getBoundingClientRect();
|
|
const rowRect = row.getBoundingClientRect();
|
|
const buttonRect = button.getBoundingClientRect();
|
|
const style = getComputedStyle(button);
|
|
return {
|
|
pageScrollWidth: document.documentElement.scrollWidth,
|
|
scroll: {
|
|
left: scrollRect.left,
|
|
right: scrollRect.right,
|
|
clientWidth: scrollElement.clientWidth,
|
|
scrollWidth: scrollElement.scrollWidth,
|
|
},
|
|
row: { left: rowRect.left, right: rowRect.right, width: rowRect.width },
|
|
button: { left: buttonRect.left, right: buttonRect.right, width: buttonRect.width },
|
|
viewportWidth: window.innerWidth,
|
|
outlineStyle: style.outlineStyle,
|
|
outlineWidth: style.outlineWidth,
|
|
};
|
|
});
|
|
expect(geometry.pageScrollWidth).toBe(geometry.viewportWidth);
|
|
expect(geometry.scroll.clientWidth).toBeLessThanOrEqual(geometry.viewportWidth);
|
|
expect(geometry.scroll.scrollWidth).toBe(760);
|
|
expect(geometry.row.width).toBe(760);
|
|
expect(geometry.button.left).toBeGreaterThanOrEqual(geometry.scroll.left);
|
|
expect(geometry.button.right).toBeLessThanOrEqual(geometry.scroll.right);
|
|
expect(geometry.outlineStyle).toBe('solid');
|
|
expect(geometry.outlineWidth).toBe('2px');
|
|
await page.screenshot({ path: testInfo.outputPath('gateway-profile-retry-mobile.png'), fullPage: true });
|
|
|
|
await retry.click();
|
|
await expect(row).toContainText('선택장수');
|
|
await expect(row.getByTestId('profile-info-retrying')).toHaveCount(0);
|
|
});
|
|
|
|
test('applies the signed general-acquisition policy to both create and possession actions', async ({ page }) => {
|
|
await installFixture(page, {
|
|
canCreateGeneral: false,
|
|
myGeneral: null,
|
|
selectionPoolEnabled: false,
|
|
npcPossessionEnabled: true,
|
|
});
|
|
|
|
await page.goto('lobby');
|
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
|
await expect(row.getByRole('button', { name: '인증 필요' })).toBeDisabled();
|
|
await expect(row.getByRole('button', { name: '장수빙의' })).toBeDisabled();
|
|
});
|
|
|
|
test('shows the Kakao verification banner when a profile still requires verification', async ({ page }) => {
|
|
await installFixture(page, {
|
|
kakaoVerified: false,
|
|
requiresKakaoVerification: true,
|
|
});
|
|
|
|
await page.goto('lobby');
|
|
await expect(page.getByText('카카오 인증이 필요합니다.')).toBeVisible();
|
|
});
|
|
|
|
test('hides the Kakao verification banner for operator special access', async ({ page }) => {
|
|
await installFixture(page, {
|
|
roles: ['superuser'],
|
|
kakaoVerified: false,
|
|
specialAccess: {
|
|
kind: 'OPERATOR',
|
|
grantId: null,
|
|
expiresAt: null,
|
|
allowsGeneralCreation: true,
|
|
},
|
|
});
|
|
|
|
await page.goto('lobby');
|
|
await expect(page.getByText('특수 접근 · OPERATOR')).toBeVisible();
|
|
await expect(page.getByText('카카오 인증이 필요합니다.')).toHaveCount(0);
|
|
});
|
|
|
|
test('hides the Kakao verification banner when a grant removes the remaining verification requirement', async ({
|
|
page,
|
|
}) => {
|
|
await installFixture(page, {
|
|
kakaoVerified: false,
|
|
specialAccess: {
|
|
kind: 'RECOVERY',
|
|
grantId: '11111111-1111-4111-8111-111111111111',
|
|
expiresAt: '2026-08-20T00:00:00.000Z',
|
|
allowsGeneralCreation: true,
|
|
},
|
|
});
|
|
|
|
await page.goto('lobby');
|
|
await expect(page.getByText('특수 접근 · RECOVERY')).toBeVisible();
|
|
await expect(page.getByText('카카오 인증이 필요합니다.')).toHaveCount(0);
|
|
});
|
|
|
|
test('opens the profile root without profile or game token query parameters', async ({ page }) => {
|
|
await installFixture(page);
|
|
await page.route('**/hwe/', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'text/html',
|
|
body: '<title>Clean profile target</title>',
|
|
});
|
|
});
|
|
|
|
await page.goto('lobby');
|
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
|
await row.getByRole('button', { name: '입장', exact: true }).click();
|
|
|
|
await expect(page).toHaveURL(/\/hwe\/$/);
|
|
const target = new URL(page.url());
|
|
expect(target.search).toBe('');
|
|
expect(
|
|
await page.evaluate(() => JSON.parse(window.sessionStorage.getItem('sammo-pending-game-session') ?? 'null'))
|
|
).toEqual({
|
|
profile: 'hwe:903',
|
|
gatewayToken: 'encrypted-gateway-game-token',
|
|
});
|
|
});
|
|
|
|
test('opens the mode-1 possession route with a fresh gateway game token outside the URL', async ({ page }) => {
|
|
await installFixture(page, {
|
|
myGeneral: null,
|
|
selectionPoolEnabled: false,
|
|
npcPossessionEnabled: true,
|
|
});
|
|
await page.route('**/hwe/join?**', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'text/html',
|
|
body: '<title>NPC possession target</title>',
|
|
});
|
|
});
|
|
|
|
await page.goto('lobby');
|
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
|
await expect(row.getByRole('button', { name: '장수빙의' })).toBeEnabled();
|
|
await row.getByRole('button', { name: '장수빙의' }).click();
|
|
|
|
await expect(page).toHaveURL(/\/hwe\/join\?tab=possess$/);
|
|
const target = new URL(page.url());
|
|
expect(target.searchParams.get('tab')).toBe('possess');
|
|
expect(target.searchParams.has('profile')).toBe(false);
|
|
expect(target.searchParams.has('gameToken')).toBe(false);
|
|
expect(
|
|
await page.evaluate(() => JSON.parse(window.sessionStorage.getItem('sammo-pending-game-session') ?? 'null'))
|
|
).toEqual({
|
|
profile: 'hwe:903',
|
|
gatewayToken: 'encrypted-gateway-game-token',
|
|
});
|
|
});
|
|
|
|
test('shows registration closed instead of acquisition actions at the Ref capacity boundary', async ({ page }) => {
|
|
await installFixture(page, {
|
|
myGeneral: null,
|
|
selectionPoolEnabled: false,
|
|
npcPossessionEnabled: true,
|
|
userCnt: 300,
|
|
maxUserCnt: 300,
|
|
});
|
|
|
|
await page.goto('lobby');
|
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
|
await expect(row).toContainText('장수 등록 마감');
|
|
await expect(row.getByRole('button', { name: '장수생성' })).toHaveCount(0);
|
|
await expect(row.getByRole('button', { name: '장수빙의' })).toHaveCount(0);
|
|
});
|
|
|
|
for (const season of [
|
|
{
|
|
name: 'competition',
|
|
isUnited: 0,
|
|
opentime: '2000-01-01 00:00:00',
|
|
label: '<4국 경쟁중>',
|
|
period: '2026-07-30 00:00:00 ~',
|
|
},
|
|
{
|
|
name: 'preopen',
|
|
isUnited: 0,
|
|
opentime: '2099-01-01 00:00:00',
|
|
label: '-가오픈 중-',
|
|
period: '2026-07-30 00:00:00 ~',
|
|
},
|
|
{
|
|
name: 'event running',
|
|
isUnited: 1,
|
|
opentime: '2099-01-01 00:00:00',
|
|
label: '§이벤트 진행중§',
|
|
period: '2026-07-30 00:00:00 ~',
|
|
},
|
|
{
|
|
name: 'united',
|
|
isUnited: 2,
|
|
opentime: '2099-01-01 00:00:00',
|
|
label: '§천하통일§',
|
|
period: '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00',
|
|
},
|
|
{
|
|
name: 'event finished',
|
|
isUnited: 3,
|
|
opentime: '2099-01-01 00:00:00',
|
|
label: '§이벤트 종료§',
|
|
period: '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00',
|
|
},
|
|
] as const) {
|
|
test(`renders the Ref ${season.name} season status without changing entry actions`, async ({ page }) => {
|
|
await installFixture(page, {
|
|
isUnited: season.isUnited,
|
|
opentime: season.opentime,
|
|
nationCnt: 4,
|
|
});
|
|
|
|
await page.goto('lobby');
|
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
|
const status = row.getByText(season.label, { exact: true });
|
|
await expect(status).toBeVisible();
|
|
await expect(row.locator('td').first().locator('[title]')).toHaveAttribute('title', season.period);
|
|
await expect(row.getByRole('button', { name: '입장' })).toBeVisible();
|
|
|
|
if (artifactRoot) {
|
|
const [viewport, rowGeometry, statusGeometry] = await Promise.all([
|
|
page.evaluate(() => ({
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
devicePixelRatio: window.devicePixelRatio,
|
|
})),
|
|
row.evaluate((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
}),
|
|
status.evaluate((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
const style = getComputedStyle(element);
|
|
return {
|
|
x: rect.x,
|
|
y: rect.y,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
color: style.color,
|
|
fontFamily: style.fontFamily,
|
|
fontSize: style.fontSize,
|
|
lineHeight: style.lineHeight,
|
|
textAlign: style.textAlign,
|
|
};
|
|
}),
|
|
]);
|
|
const geometry = { viewport, row: rowGeometry, status: statusGeometry };
|
|
const slug = season.name.replaceAll(' ', '-');
|
|
writeFileSync(resolve(artifactRoot, `gateway-${slug}.json`), `${JSON.stringify(geometry, null, 2)}\n`);
|
|
await page.screenshot({ path: resolve(artifactRoot, `gateway-${slug}.png`), fullPage: true });
|
|
}
|
|
});
|
|
}
|
|
|
|
test('renders the same Ref season status on the public gateway page', async ({ page }) => {
|
|
await installFixture(page, { authenticated: false, isUnited: 3, nationCnt: 4 });
|
|
|
|
await page.goto('');
|
|
const status = page.locator('.season-status');
|
|
await expect(status).toHaveText('§이벤트 종료§');
|
|
await expect(status).toHaveAttribute('title', '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00');
|
|
await expect(page.getByRole('button', { name: '현황 새로고침' })).toBeEnabled();
|
|
});
|