Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/admin/src/reporting/failures-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ describe.skipIf(연결 === undefined)('실패 카드 통로', () => {
await 항목(끝남, 'XFCR-001', 'mobile', 'FAIL');
await 항목(끝남, 'XFCR-002', 'mobile', 'FAIL');
await 항목(끝남, 'XFCR-003', 'desktop', 'PASS');
const 앱 = await 실행만들기('앱', 'FINISHED');
await 항목(앱, 'XFCR-004', 'android', 'FAIL');
await pool.query(`UPDATE run_item SET device = $2 WHERE run_id = $1`, [
앱,
JSON.stringify({ model: 'SM-S911N', osVersion: '14', appVersion: null, appBuild: '241' }),
]);
await 실행만들기('도는중', 'RUNNING');
await 실행만들기('시나리오', 'FINISHED', 시나리오번호);

Expand Down Expand Up @@ -137,6 +143,14 @@ describe.skipIf(연결 === undefined)('실패 카드 통로', () => {
expect(body.items.map((c: { tcId: string }) => c.tcId)).toEqual(['XFCR-001', 'XFCR-002']);
});

it('디바이스 칸에 그 회차를 돌린 폰과 앱의 판을 싣고 없으면 null 이다', async () => {
const 앱 = (await 라우트앱.inject({ method: 'GET', url: 주소(실행.앱!) })).json();
expect(앱.items[0].devices[0].device).toEqual({ model: 'SM-S911N', osVersion: '14', appVersion: null, appBuild: '241' });

const 웹 = (await 라우트앱.inject({ method: 'GET', url: 주소(실행.끝남!) })).json();
expect(웹.items[0].devices[0].device).toBeNull();
});

it('도는 실행은 409 RUN_NOT_FINISHED 다', async () => {
const res = await 라우트앱.inject({ method: 'GET', url: 주소(실행.도는중!) });

Expand Down
13 changes: 12 additions & 1 deletion apps/admin/src/reporting/failures.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// 실행 결과 화면의 실패 케이스 카드 질의 — 실패 · 변화 · 최근 흐름 · 처음 실패한 회차의 항목 상세 (도메인/리포팅 §7)

import type { ItemStatus, Platform } from '@platform/kit';
import type { DeviceInfo, ItemStatus, Platform } from '@platform/kit';

import { 팜증적목록, type 팜파일 } from '../execution/farmEvidence.js';
import { findItem } from '../execution/queries.js';
Expand All @@ -20,6 +20,8 @@ export interface FailureDevice extends Omit<실패디바이스, 'firstFailedHist
farm: 팜파일[];
/** 한 건 실행이 덮은 디바이스면 그 한 건 · 원래 결과 (도메인/실행 §7 `GET /api/runs/:runId` items[].rerun) */
rerun: 다시실행칸 | null;
/** 그 회차를 돌린 폰과 앱의 판 — android 만 있다. 증거 칸 「기기 정보」 탭이 읽는다 (리포팅 §7) */
device: DeviceInfo | null;
}

export interface FailureCase {
Expand Down Expand Up @@ -109,6 +111,14 @@ export async function 실패카드(runId: number, page: number, platform?: Platf
const tcIds = 쪽.items.map((c) => c.tcId);
const [요구표, 판정표] = await Promise.all([카드요구들(runId, tcIds), 판정들(runId, tcIds)]);

// 폰 · 앱 판은 쪽의 회차를 한 번에 읽는다 — history_id 는 실행을 넘어 하나라 한 건 실행이 덮은 회차도 그대로 찾는다
const 회차들 = 쪽.items.flatMap((c) => c.devices.map((d) => d.firstFailedHistoryId));
const 기기들 = new Map(
(await pool.query<{ history_id: string; device: DeviceInfo | null }>('SELECT history_id, device FROM run_item WHERE history_id = ANY($1)', [회차들])).rows.map(
(r) => [Number(r.history_id), r.device],
),
);

// ponytail: 쪽 크기만큼 묻는다 — 쪽이 커지면 한 질의로 모은다
const items = await Promise.all(
쪽.items.map(async (c): Promise<FailureCase> => ({
Expand All @@ -130,6 +140,7 @@ export async function 실패카드(runId: number, page: number, platform?: Platf
item,
farm: await 팜증적목록(실행번호, firstFailedHistoryId),
rerun,
device: 기기들.get(firstFailedHistoryId) ?? null,
};
}),
),
Expand Down
3 changes: 2 additions & 1 deletion apps/admin/src/web/ItemTrace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { use말 } from './i18n.js';
type 탭 = 'network' | 'actions' | 'console';
type 네트워크 = ItemTrace['network'][number];

const 실패요청 = (n: 네트워크) => n.failure !== null || (n.status !== null && n.status >= 400);
/** 끊겼거나 4xx · 5xx 인 요청 — 네트워크 표 · 실패만 거르개 · 시간축 눈금이 같이 쓴다 */
export const 실패요청 = (n: 네트워크) => n.failure !== null || (n.status !== null && n.status >= 400);
const 실패조작 = (a: ItemTrace['actions'][number]) => a.error !== null;
// 잡히지 않은 오류는 pageerror 로 온다 (traceParse.ts)
const 실패콘솔 = (c: ItemTrace['console'][number]) => c.level === 'error' || c.level === 'pageerror';
Expand Down
71 changes: 71 additions & 0 deletions apps/admin/src/web/Notice.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,77 @@ describe('읽음 목록 정리', () => {
});
});

describe('토스트', () => {
const 토스트 = () => document.querySelector<HTMLElement>('.toast');
async function 다시묻는다(답: NoticeResponse, 부름: ReturnType<typeof 띄운다>): Promise<void> {
부름.mockResolvedValue(답);
await act(async () => {
window.dispatchEvent(new Event(알림바뀜));
});
}

it('화면을 연 뒤 새로 끝난 실행 하나를 아래에 띄우고 3초 뒤 흐려져 사라진다. 열 때 이미 있던 것은 안 띄운다', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const 부름 = 띄운다({ running: [], recent: [끝난실행(1)] });
await act(async () => {});
expect(토스트()).toBeNull();
expect(screen.getByRole('status').textContent).toBe('');

await 다시묻는다({ running: [], recent: [끝난실행(2), 끝난실행(1)] }, 부름);
expect(screen.getByRole('status').textContent).toMatch(/^RUN 2 실행이 끝났습니다/);
const 뜬것 = 토스트()!;
expect(뜬것.className).toBe('toast');
expect(within(뜬것).getByText(/^RUN 2 실행이 끝났습니다/)).toBeTruthy();
expect(within(뜬것).getByRole('link', { name: '결과 보기' }).getAttribute('href')).toBe('#/runs/2');

await act(async () => {
vi.advanceTimersByTime(3000);
});
expect(토스트()!.className).toBe('toast out');
await act(async () => {
vi.advanceTimersByTime(250);
});
expect(토스트()).toBeNull();
});

it('마우스를 올린 동안은 사라지지 않고 떼면 다시 3초를 센다', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const 부름 = 띄운다({ running: [], recent: [] });
await act(async () => {});
await 다시묻는다({ running: [], recent: [끝난실행(3)] }, 부름);

fireEvent.mouseEnter(토스트()!);
await act(async () => {
vi.advanceTimersByTime(10_000);
});
expect(토스트()!.className).toBe('toast');

fireEvent.mouseLeave(토스트()!);
await act(async () => {
vi.advanceTimersByTime(3250);
});
expect(토스트()).toBeNull();
});

it('고리를 누르면 읽음이 되고 토스트가 닫힌다', async () => {
const 부름 = 띄운다({ running: [], recent: [] });
await act(async () => {});
await 다시묻는다({ running: [], recent: [끝난실행(7)] }, 부름);

fireEvent.click(within(토스트()!).getByRole('link', { name: '결과 보기' }));
expect(토스트()).toBeNull();
expect(읽음들().has('run:7')).toBe(true);
});

it('지금 결과를 보고 있는 실행이 끝나면 토스트를 안 띄운다', async () => {
보는실행알림(9);
const 부름 = 띄운다({ running: [도는실행(9)], recent: [] });
await act(async () => {});
await 다시묻는다({ running: [], recent: [끝난실행(9)] }, 부름);
expect(토스트()).toBeNull();
});
});

describe('받아 오기', () => {
it('서버가 403 을 주면(실행 칸을 볼 수 있는 애플리케이션이 없다) 버튼도 줄도 안 그리고 다시 묻지도 않는다', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
Expand Down
61 changes: 59 additions & 2 deletions apps/admin/src/web/Notice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';

import { api, ApiError, 알림바뀜, type NoticeResponse } from './api.js';
import { api, ApiError, 알림바뀜, type NoticeRecent, type NoticeResponse } from './api.js';
import { use말, use언어 } from './i18n.js';
import { 아이콘 } from './icons.js';
import { NoticeDrawer } from './NoticeDrawer.js';
import { 읽음들, 읽음바뀜, 읽음으로적는다, 읽음정리 } from './noticeRead.js';
import { 끝난글, 보일줄들, 안읽은수, 읽음열쇠, type 끝난줄, type 도는줄, type 줄 as 줄타입 } from './noticeView.js';
import { 끝난글, 끝난줄만들기, 보일줄들, 새소식, 안읽은수, 읽음열쇠, type 끝난줄, type 도는줄, type 줄 as 줄타입 } from './noticeView.js';
import { use지켜봄 } from './runWatchStore.js';
import { use보는실행 } from './viewedRun.js';

/**
Expand Down Expand Up @@ -136,6 +137,46 @@ function 끝난줄그림({ 줄, on읽음, 바꿈 }: { 줄: 끝난줄; on읽음:
);
}

/**
* 새로 끝난 실행 · 작성 하나를 아래에서 띄워 3초 뒤 흐려지며 치운다 (DESIGN.md 「창이 아닌 떠 있는 것」 「토스트」 · 원칙 4 「알림 도착」).
* 포커스를 가져가지 않는다. 마우스를 올리거나 고리에 포커스가 있는 동안은 멈춘다 — 누르려는 사이에 사라지지 않게
*/
function 토스트({ 줄, on닫음, 바꿈 }: { 줄: 끝난줄; on닫음: (값: null) => void; 바꿈: 서비스바꿈 }) {
const [나감, set나감] = useState(false);
const [멈춤, set멈춤] = useState(false);
useEffect(() => {
if (멈춤) return;
set나감(false);
const 흐림 = setTimeout(() => set나감(true), 3000);
const 치움 = setTimeout(() => on닫음(null), 3200);
return () => {
clearTimeout(흐림);
clearTimeout(치움);
};
}, [on닫음, 멈춤]);
return (
<div
className={나감 ? 'toast out' : 'toast'}
onMouseEnter={() => set멈춤(true)}
onMouseLeave={() => set멈춤(false)}
onFocus={() => set멈춤(true)}
onBlur={() => set멈춤(false)}
>
<span className="toast-text">{끝난글(줄)}</span>
<a
href={줄.열기}
onClick={() => {
if (줄.종류 === 'authoring') 바꿈(줄.접두사);
읽음으로적는다([줄.열쇠]);
on닫음(null);
}}
>
{줄.열기라벨}
</a>
</div>
);
}

function 한줄({ 줄, 바꿈 }: { 줄: 줄타입; 바꿈: 서비스바꿈 }) {
return 줄.도는중 ? <도는줄그림 줄={줄} 바꿈={바꿈} /> : <끝난줄그림 줄={줄} on읽음={읽음으로적는다} 바꿈={바꿈} />;
}
Expand All @@ -153,6 +194,17 @@ export function Notice({ 줄숨김, 서비스바꿈, 버튼자리 }: { 줄숨김
// 결과 화면이 지금 그리는 실행 — 그 실행의 도는 줄을 줄에서 뺀다 (점검 2.23)
const 보는실행 = use보는실행();
const 버튼 = useRef<HTMLButtonElement>(null);
const 지켜봄 = use지켜봄();
const 앞소식 = useRef<NoticeRecent[] | null>(null);
const [토스트줄, set토스트줄] = useState<끝난줄 | null>(null);

// 답이 올 때마다 앞 답과 견줘 새로 끝난 것 하나를 토스트로 — 완료 창(진행 알약)과 결과 화면이 알리는 실행은 겹쳐 띄우지 않는다
useEffect(() => {
if (답 === null) return;
const 새 = 새소식(앞소식.current, 답.recent, 읽음들(), [보는실행, 지켜봄?.runId ?? null]);
앞소식.current = 답.recent;
if (새 !== null) set토스트줄(끝난줄만들기(새, 언어));
}, [답, 보는실행, 지켜봄, 언어]);

// 같은 탭의 실행 결과 화면이 읽음으로 적어도(`runState.ts`), 다른 탭에서 적어도 바로 따라온다
useEffect(() => {
Expand Down Expand Up @@ -216,6 +268,11 @@ export function Notice({ 줄숨김, 서비스바꿈, 버튼자리 }: { 줄숨김
{/* 자리가 아직 안 붙었으면(첫 그림) 다음 그림에 붙는다 — 그 한 번은 버튼이 없다 */}
{버튼자리 === undefined ? 알림버튼 : 버튼자리 === null ? null : createPortal(알림버튼, 버튼자리)}
</div>
{/* 읽어 주는 자리는 늘 두고 글만 바꾼다 — 글을 품은 채 새로 생긴 자리는 화면 읽기 프로그램이 흔히 안 읽는다 */}
<p className="sr-only" role="status">
{토스트줄 === null ? '' : 끝난글(토스트줄)}
</p>
{토스트줄 === null ? null : <토스트 key={토스트줄.열쇠} 줄={토스트줄} on닫음={set토스트줄} 바꿈={서비스바꿈} />}
{!열림 || 답 === null ? null : <NoticeDrawer recent={답.recent} 읽음={읽음} 지금={지금} onClose={닫는다} on읽음={읽음으로적는다} 서비스바꿈={서비스바꿈} />}
</>
);
Expand Down
8 changes: 5 additions & 3 deletions apps/admin/src/web/PerfChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,11 @@ export function PerfChart({
<div className="perf-chart-plot">
<svg viewBox={`0 0 ${String(너비)} ${String(높이)}`} preserveAspectRatio="none" className="perf-graph" style={{ height: 높이 }} role="img" aria-label={라벨}>
{목표y === null ? null : <line x1="0" y1={목표y} x2={너비} y2={목표y} className="perf-goal" vectorEffect="non-scaling-stroke" />}
{선들.map((s) => (
<polyline key={s.이름} points={선점(s.점들, 최대X, s.최대Y, 너비, 높이)} style={{ stroke: s.색, strokeWidth: s.굵게 === true ? 2.5 : 2 }} vectorEffect="non-scaling-stroke" />
))}
<g className="perf-lines">
{선들.map((s) => (
<polyline key={s.이름} points={선점(s.점들, 최대X, s.최대Y, 너비, 높이)} style={{ stroke: s.색, strokeWidth: s.굵게 === true ? 2.5 : 2 }} vectorEffect="non-scaling-stroke" />
))}
</g>
{세로선 === undefined ? null : (
<line x1={Math.round((세로선.x / Math.max(1, 최대X)) * 너비)} y1="0" x2={Math.round((세로선.x / Math.max(1, 최대X)) * 너비)} y2={높이} style={{ stroke: 세로선.색 }} vectorEffect="non-scaling-stroke" />
)}
Expand Down
69 changes: 69 additions & 0 deletions apps/admin/src/web/RunEvidence.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,73 @@ describe('실패 카드의 시간축 증거 (WEB-F7-13)', () => {
fireEvent.click(screen.getByRole('button', { name: '1단계 토큰을 받는다' }));
expect(screen.getByText('이 단계는 기대값과 다른 곳이 없습니다')).toBeTruthy();
});

it('앱 항목은 판을 읽었을 때만 「기기 정보」 탭이 서고 못 읽은 칸은 「모름」이다', async () => {
const 판 = { model: 'SM-S911N', osVersion: '14', appVersion: null, appBuild: '241' };
그리기(쪽([케이스('ZZI-0001', '주소 검색', [장치('android', 11, { device: 판 }, { steps: 화면단계들() })])]), [줄(11, 'ZZI-0001', 'android', 'FAIL')]);

fireEvent.click(await screen.findByRole('tab', { name: '기기 정보' }));
expect(screen.getByRole('tabpanel').textContent).toBe('기기SM-S911NAndroid14앱 버전빌드 241');
});

it('판을 못 읽은 앱 항목 · 웹 항목에는 「기기 정보」 탭이 없다', async () => {
그리기(
쪽([
케이스('ZZI-0001', '주소 검색', [장치('android', 11, { device: null }, { steps: 화면단계들() })]),
케이스('ZZI-0002', '장바구니', [장치('desktop', 12, {}, { steps: 화면단계들() })]),
]),
[줄(11, 'ZZI-0001', 'android', 'FAIL'), 줄(12, 'ZZI-0002', 'desktop', 'FAIL')],
);

await screen.findAllByRole('group', { name: '단계 고르기' });
expect(screen.queryByRole('tab', { name: '기기 정보' })).toBeNull();
});

it('같은 단계를 다시 눌러도 단계 칸이 다시 깜박이고 포커스는 그 칸에 남는다', async () => {
그리기(쪽([케이스('ZZI-0001', '장바구니', [장치('desktop', 11, {}, { steps: 화면단계들() })])]), [줄(11, 'ZZI-0001', 'desktop', 'FAIL')]);

const 칸 = within(await screen.findByRole('group', { name: '단계 고르기' })).getByRole('button', { name: '1단계 장바구니 화면을 연다' });
칸.focus();
fireEvent.click(칸);
expect(칸.className).toBe('ev-seg flash-a');
fireEvent.click(칸);
expect(칸.className).toBe('ev-seg flash-b');
expect(document.activeElement).toBe(칸);
});

it('카드를 펼치면 항목 상세를 한 번 읽어 단계 칸 아래 요청마다 눈금을 긋고 실패한 요청은 따로 표시한다', async () => {
const 기록들 = 기록();
기록들.network.push({ ...기록들.network[1]!, atMs: 1520, status: 500 });
const 읽기 = vi.spyOn(api, 'item').mockResolvedValue({ ...상세(11, 'desktop', { steps: 화면단계들() }), trace: 기록들 });
그리기(쪽([케이스('ZZI-0001', '장바구니', [장치('desktop', 11, {}, { steps: 화면단계들() })])]), [줄(11, 'ZZI-0001', 'desktop', 'FAIL')]);

const 접이 = (await screen.findByRole('group', { name: '단계 고르기' })).closest('details')!;
접이.open = true;
fireEvent(접이, new Event('toggle'));

expect(await screen.findByText('실패한 요청')).toBeTruthy();
const 칸들 = [...document.querySelectorAll('.ev-ticks span')];
expect(칸들.map((칸) => 칸.querySelectorAll('i').length)).toEqual([1, 2]);
const 둘째 = [...칸들[1]!.querySelectorAll('i')];
expect(둘째.map((눈금) => [눈금.className, (눈금 as HTMLElement).style.left])).toEqual([
['', '0%'],
['bad', '50%'],
]);

fireEvent.click(screen.getByRole('tab', { name: '네트워크' }));
expect(await screen.findAllByText('/api/cart/items/88')).toHaveLength(2);
expect(읽기).toHaveBeenCalledTimes(1);
});

it('API · 앱 케이스는 카드를 펼쳐도 항목 상세를 읽지 않고 눈금도 없다', async () => {
const 읽기 = vi.spyOn(api, 'item');
그리기(쪽([케이스('ZZI-0001', '주소 검색', [장치('android', 11, {}, { steps: 화면단계들() })])]), [줄(11, 'ZZI-0001', 'android', 'FAIL')]);

const 접이 = (await screen.findByRole('group', { name: '단계 고르기' })).closest('details')!;
접이.open = true;
fireEvent(접이, new Event('toggle'));

expect(읽기).not.toHaveBeenCalled();
expect(document.querySelector('.ev-ticks')).toBeNull();
});
});
Loading