근태 현황 E2E 네이티브 트랙 (PR-E1) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: admin 근태 현황 페이지의 네이티브 탭(연차·외근) 결재 여정과 4탭 스모크를 Playwright E2E로 커버하고, 그 선행 인프라(webServer·타입 게이트·testid·날짜/시드 헬퍼)를 구축한다.
Architecture: 스펙 docs/superpowers/specs/2026-07-03-attendance-frontend-tests-design.md §5.1 Track E / PR-E1. 패턴은 leave-approval.spec.ts가 확립한 API 시드 → UI 결재 → API 검증(expect.poll). 셀렉터는 §5.3 결정대로 data-testid 1순위 — 동적 탭류는 공유 컴포넌트에 동적 testid를 심고, 도메인 요소는 att- 접두로 뷰에 직접 부여한다. iframe 내부 진입은 PR-E2 소관 — E1은 iframe 존재·src 파라미터까지만 어서션한다.
Tech Stack: Playwright(packages/test), 실행 전제 로컬 make dev + 시드 DB (global-setup이 5개 origin에 로그인 상태를 심으므로 admin/tm/employee-app/GA 서버가 떠 있어야 한다). CI 연동은 범위 외 (스펙 §2).
Global Constraints
- 검증 러너:
pnpm --filter @hereby/test exec playwright test --project=admin(+ E1-4는--project=admin에 포함). 수집 확인:... exec playwright test --list --project=admin. - 타입 게이트:
pnpm --filter @hereby/test exec tsc --noEmit -p tsconfig.e2e.json(Task 2에서 생성 — packages/test에는 build 스크립트가 없어 루트pnpm build가 e2e 스펙을 타입체크하지 않는다. 이 명령이 유일한 타입 게이트). - 신규 spec 파일명은 반드시
admin-attendance-*.spec.ts— admin project의 testMatch/apps\/admin-.*\.spec\.ts/에 안 걸리면 조용히 수집 제외된다 (playwright.config.ts:70). - 해시 딥링크로 근태 현황 진입 금지 —
VALID_TABS에attendance가 없어(KI-2,apps/admin/src/router.ts:2-19develop 기준 재확인 완료)#attendance는 employees로 폴백. 진입은 LNB 클릭 경로만. - admin init 레이스: 모든 spec은 첫 상호작용 전
lnb-employees가시성 대기 (기존 spec의 '직원 관리' 버튼 대기와 동일 원리 — testid 버전). - 시드 날짜는 kr-holidays 지원 범위(2024–2027) 내 +
e2e/helpers/dates.ts의 org-tz 안전 헬퍼만 사용.new Date().toISOString()패턴 복사 금지 (KST 00–09시 하루 밀림 —leave-approval.spec.ts:66-71이 나쁜 선례). - data-testid는 README 프로토콜대로
packages/test/e2e/README.md의 "data-testid additions" 절에 등록 (선례:add-employee-*46건). attribute-only 변경 — 동작 무영향. - 발견 버그는 고치지 않는다 — KI 번호 주석 + characterization 어서션 (스펙 §2 결정). KI-4(leave-management.html:4 stale 주석)도 수정하지 않는다.
- 커밋: Conventional Commits 영어 제목 + 한국어 본문. 태스크마다 커밋. 커밋 전 해당 파일 lint는 훅(lint-staged biome)이 처리.
File Structure
packages/test/
tsconfig.e2e.json Task 2 e2e 전용 타입 게이트 (신규)
playwright.config.ts Task 3 webServer에 admin(:4001) 추가
e2e/helpers/dates.ts Task 5 org-tz 안전 날짜 헬퍼 (신규)
e2e/helpers/cross-app.ts Task 5 외근 시드 4종 + 연차 반려/잔여 래퍼 추가
e2e/helpers/admin-attendance.ts Task 6 openAttendanceTab 진입 헬퍼 (신규)
e2e/tests/apps/admin-tab-routing.spec.ts Task 1 stale '연차 관리' 케이스 교체
e2e/tests/apps/admin-attendance-tabs.spec.ts Task 6 E1-1 스모크 (신규)
e2e/tests/apps/admin-attendance-leave.spec.ts Task 7 E1-2 연차 결재 여정 (신규)
e2e/tests/apps/admin-attendance-offsite.spec.ts Task 8 E1-3 외근 결재 여정 (신규)
e2e/README.md Task 4 data-testid additions 등록
packages/components/html/
lnb-sidebar.html Task 4 동적 lnb-<id> testid
tab-nav.html Task 4 동적 tabnav-<id> testid
sub-tab.html Task 4 동적 subtab-<id> testid
apps/admin/src/views/
tabs/attendance.html Task 4 att-panel-*, att-embed-* testid
tabs/leave-management.html Task 7 att-leave-* testid
tabs/offsite-management.html Task 8 att-offsite-* testid
modals/confirm-action.html Task 8 admin-confirm-ok testid스펙 대비 이행 편차 (기록용):
- §5.3 네이밍 예시
att-tab-work— 상단 탭·서브탭·LNB는 공유 컴포넌트(TabNav/SubTab/LnbSidebar)가 동적으로 렌더하므로 도메인 접두를 붙일 수 없다. 컴포넌트 네임스페이스 동적 testid(tabnav-<id>,subtab-<id>,lnb-<id>)로 대체 — 근태 페이지에서는tabnav-work등으로 소비.att-접두는 admin 뷰에 직접 박는 도메인 요소에만 사용. - E1-4 "수리 또는 삭제" — LNB 개편(develop)으로 "연차 관리" 항목이 제거됐음을 확인(현 LNB 근태 관리 = 스케줄러/근태 현황뿐). TABS 케이스를 LNB에 실존하고
VALID_TABS에도 있는 "역할 관리"(roles)로 교체 (3탭 커버리지 유지). - E1-2 승인은 확인 모달이 없다 —
approveLeaveDetail → approveLeaveById가 즉시 sdk 호출(코드 확인). 외근 일괄 승인만 공용 confirm 모달(confirm-action.html)을 거친다.
Task 0: 브랜치 준비 + 베이스라인 기록
Files: 없음 (git + 실행만)
- [ ] Step 1: develop에서 분기 + 문서 이월
(계획 작성 세션에서 이미 수행된 경우 skip — git branch --show-current가 test/attendance-e2e-native면 Step 2로.)
git checkout develop && git pull origin develop
git checkout -b test/attendance-e2e-native
git checkout test/attendance-unit -- \
docs/superpowers/specs/2026-07-03-attendance-frontend-tests-design.md
# 이 계획 문서가 untracked면 함께 스테이징
git add docs/superpowers/plans/2026-07-06-attendance-e2e-native.md
git commit -m "docs: carry attendance spec and PR-E1 plan onto e2e branch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"- [ ] Step 2: 베이스라인 실행 (make dev + 시드 DB 필수)
pnpm --filter @hereby/test exec playwright test --project=admin --project=employee-team-flowExpected: admin-tab-routing의 "연차 관리" 케이스만 red (LNB에서 제거된 항목 — 타임아웃). admin-add-employee·leave-approval·overtime-approval green. 그 외 red가 있으면 별도 이슈로 기록하고 중단 — 이후 실패의 회귀 여부를 판별할 수 없다.
Task 1: E1-4 — stale 탭 라우팅 케이스 교체 (베이스라인 green 먼저)
Files:
Modify:
packages/test/e2e/tests/apps/admin-tab-routing.spec.ts:17-23[ ] Step 1: TABS의 "연차 관리" 행 교체
const TABS: { label: string; hash: string; isDefault?: boolean }[] = [
// "employees" is DEFAULT_TAB: initial URL has no hash, navigateTab sets /#employees
// only after clicking another tab and coming back. Refresh of "/" → tab=employees (no hash).
{ label: "직원 관리", hash: "employees", isDefault: true },
{ label: "팀 관리", hash: "organization" },
// "연차 관리"는 LNB 개편(근태 현황 상단 탭으로 흡수)으로 제거됨 — 근태 현황(#attendance)은
// VALID_TABS에 없어 이 루프의 refresh-restore 어서션을 통과할 수 없으므로(KI-2, 스펙 §8)
// admin-attendance-tabs.spec.ts의 별도 describe가 담당한다. 대체로 역할 관리를 커버.
{ label: "역할 관리", hash: "roles" },
];- [ ] Step 2: 실행 — 베이스라인 green 확인
pnpm --filter @hereby/test exec playwright test --project=adminExpected: admin project 전부 PASS (routing 3케이스 + add-employee).
- [ ] Step 3: Commit
git add packages/test/e2e/tests/apps/admin-tab-routing.spec.ts
git commit -m "test(e2e): replace stale leave tab-routing case with roles
LNB 개편으로 '연차 관리' 항목이 제거돼 타임아웃하던 케이스를 실존하는
'역할 관리'(roles)로 교체. 근태 현황(#attendance)은 VALID_TABS 부재(KI-2)로
이 루프의 refresh 어서션을 통과할 수 없어 신규 attendance spec이 별도 담당.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 2: tsconfig.e2e.json — e2e 전용 타입 게이트
packages/test의 구형 src/tests는 타입에러 수백 건이라 패키지 전체 tsc가 불가능하다 (스펙 §7). e2e 스펙만 분리해 타입체크한다. esign 계획(test/esign-e2e-automation 브랜치, 미머지)과 동일 파일·동일 내용 — 이후 머지 시 충돌 무해.
Files:
Create:
packages/test/tsconfig.e2e.json[ ] Step 1: 파일 생성
// packages/test/tsconfig.e2e.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"]
},
"include": ["e2e/**/*.ts", "playwright.config.ts"]
}주의: extends 대상 tsconfig.json의 옵션이 e2e와 충돌하면(예: rootDir/composite) extends를 버리고 자립 구성으로 전환한다:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"types": ["node"]
},
"include": ["e2e/**/*.ts", "playwright.config.ts"]
}- [ ] Step 2: 게이트 실행 — 기존 e2e 파일이 이미 green인지 확인
pnpm --filter @hereby/test exec tsc --noEmit -p tsconfig.e2e.jsonExpected: 에러 0. 기존 e2e 파일에서 에러가 나오면 그 자리에서 수정(신규 게이트 도입 비용) — 단 src/tests가 끌려 들어온 것이면 include가 잘못된 것.
- [ ] Step 3: Commit
git add packages/test/tsconfig.e2e.json
git commit -m "test(e2e): add dedicated tsconfig gate for e2e specs
packages/test 구형 src/tests의 타입에러와 분리해 e2e 스펙만 타입체크하는
게이트 추가 (루트 build가 이 패키지 스펙을 검사하지 않는 공백을 메움).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 3: playwright.config.ts — admin webServer 등록
Files:
Modify:
packages/test/playwright.config.ts:96-115(webServer 배열)[ ] Step 1: webServer에 admin 항목 추가
기존 hr-system 항목 뒤에 추가:
{
command: "pnpm --filter @hereby/admin dev",
cwd: resolve(import.meta.dirname, "../.."),
url: ADMIN_BASE_URL,
reuseExistingServer: true,
timeout: 180_000,
stdout: "pipe",
stderr: "pipe",
},(reuseExistingServer: true라 make dev로 이미 떠 있으면 재사용 — 이중 기동 없음. global-setup이 tm(:4004)·employee-app(:4002)·GA(:4005)에도 접속하므로 make dev 전제는 그대로 유지된다. tm webServer 등록은 PR-E2 소관.)
- [ ] Step 2: 검증 + Commit
pnpm --filter @hereby/test exec tsc --noEmit -p tsconfig.e2e.json
pnpm --filter @hereby/test exec playwright test --project=admin
git add packages/test/playwright.config.ts
git commit -m "test(e2e): boot admin dev server from playwright webServer
admin(:4001)을 webServer에 등록 — make dev 없이 admin project 단독 실행 시에도
서버가 뜬다 (reuseExistingServer라 기존 서버 재사용).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 4: 셀렉터 기반 — 공유 컴포넌트 동적 testid + 근태 패널 testid + README 등록
Files:
- Modify:
packages/components/html/lnb-sidebar.html:30, :57 - Modify:
packages/components/html/tab-nav.html:12 - Modify:
packages/components/html/sub-tab.html:11 - Modify:
apps/admin/src/views/tabs/attendance.html(패널 4 + iframe 2) - Modify:
packages/test/e2e/README.md("data-testid additions" 절)
Interfaces:
Produces (E2E 셀렉터 계약):
lnb-<itemId>(예:lnb-attendance,lnb-employees),tabnav-<tabId>(tabnav-work|leave|overtime|offsite),subtab-<tabId>(subtab-balances|approval),att-panel-work|leave|overtime|offsite,att-embed-work,att-embed-overtime[ ] Step 1: 공유 컴포넌트 3곳에 동적 testid
lnb-sidebar.html:30 (top-level menu 버튼)과 :57(submenu-item 버튼)의 <button ... 태그에 각각 추가:
:data-testid="'lnb-' + entry.item.id" :data-testid="'lnb-' + child.id"(:57 쪽 바인딩 변수명은 파일에서 확인해 그 스코프의 item 변수로 맞춘다 — child/sub 등.)
tab-nav.html:12 <button 태그에 추가:
:data-testid="'tabnav-' + t.id"sub-tab.html:11 <button 태그에 추가:
:data-testid="'subtab-' + t.id"- [ ] Step 2: attendance.html 패널·iframe testid
4개 탭 패널 div(x-show="attendanceTab === '...'", :68/:79 및 leave/offsite include 래퍼)와 iframe 2개(:70/:81 부근)에:
<div x-show="attendanceTab === 'work'" x-cloak data-testid="att-panel-work" ...>
<iframe data-testid="att-embed-work" ... />
<div x-show="attendanceTab === 'overtime'" x-cloak data-testid="att-panel-overtime" ...>
<iframe data-testid="att-embed-overtime" ... />
<div x-show="attendanceTab === 'leave'" ... data-testid="att-panel-leave" ...> <!-- leave-management.html 루트 div -->
<div x-show="attendanceTab === 'offsite'" ... data-testid="att-panel-offsite" ...> <!-- offsite-management.html 루트 div -->(leave/offsite 패널 루트는 각 include 파일의 최상단 x-show="attendanceTab === '...'" div — leave-management.html:7, offsite-management.html 상단.)
- [ ] Step 3: README 등록
packages/test/e2e/README.md "data-testid additions" 목록 끝에 추가:
- **`lnb-<id>` / `tabnav-<id>` / `subtab-<id>`** (shared `lnb-sidebar.html`,
`tab-nav.html`, `sub-tab.html`) — dynamic ids from the nav config. Added for
the attendance suite: LNB labels and tab captions are the highest-churn copy
in the repo, so role/text locators broke on wording-only changes. Label
correctness is covered separately by unit enum→label contract tests.
- **`att-*`** (admin attendance views) — attendance-domain hooks, kebab-case,
`att-<area>-<element>`: `att-panel-<tab>`, `att-embed-work|overtime`,
`att-leave-row|view|approve|reject|reject-reason|reject-submit`,
`att-offsite-row|row-check|check-all|bulk-approve|bulk-approve-report|reject-reason|reject-submit`.
Dynamic rows carry `data-id="<record id>"` for seed-id targeting.
- **`admin-confirm-ok`** (`apps/admin/src/views/modals/confirm-action.html`) —
the shared confirm modal's primary button; its label is dynamic
(`confirmActionLabel()`), so text locators are unstable.- [ ] Step 4: 검증 + Commit
pnpm --filter @hereby/components build && pnpm --filter @hereby/admin build
pnpm --filter @hereby/test exec playwright test --project=admin
git add packages/components/html apps/admin/src/views/tabs/attendance.html packages/test/e2e/README.md
git commit -m "test(e2e): add attendance testid hooks (shared nav + panels)
LNB/TabNav/SubTab 공유 컴포넌트에 동적 testid, 근태 4탭 패널·iframe에
att-* testid 부여 (attribute-only, 동작 무영향). README 레지스트리 등록 —
문구 정확성은 단위 enum→라벨 계약이 전담하는 분업(스펙 §5.3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"주의: 컴포넌트 HTML은 각 앱 빌드가 include로 인라인한다 — dev 서버가 변경을 못 집으면 admin dev 재시작. 기존 스펙(role/text 기반)은 영향 없음(attribute 추가만).
Task 5: 날짜 헬퍼 + 시드 래퍼 확장
Files:
- Create:
packages/test/e2e/helpers/dates.ts - Modify:
packages/test/e2e/helpers/cross-app.ts(래퍼 6종 추가)
Interfaces:
Produces:
todayLocalISO(tz?: string): string/addDaysISO(iso: string, days: number): stringrejectLeaveRequest(token, id, reason): Promise<void>getLeaveBalance(token, employeeId, year): Promise<{ used: number; remaining: number } | null>submitOffsiteRequest(token, opts): Promise<string>—{ workDate, startTime?, endTime?, workHours, location, reason }approveOffsiteRequests(token, ids: string[]): Promise<void>submitOffsiteReport(token, opts): Promise<void>—{ id, actualHours, summary }getOffsiteStatus(token, id): Promise<string>
[ ] Step 1: dates.ts
// packages/test/e2e/helpers/dates.ts
/**
* org-타임존 안전 날짜 헬퍼 — 시드 조직은 Asia/Seoul.
* `new Date().toISOString()`은 UTC라 KST 00~09시에 실행하면 하루가 밀린다
* (leave-approval.spec.ts:66-71의 기존 패턴 — 복사 금지, 이 헬퍼로 대체).
*/
/** 오늘 날짜 "YYYY-MM-DD" — tz 기준 wall-clock. */
export function todayLocalISO(tz = "Asia/Seoul"): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
}
/** ISO 날짜 문자열에 일수 가감 — UTC 산술이라 tz 무관 안전. */
export function addDaysISO(iso: string, days: number): string {
const [y, m, d] = iso.split("-").map(Number);
const t = new Date(Date.UTC(y, m - 1, d + days));
return t.toISOString().slice(0, 10);
}- [ ] Step 2: cross-app.ts 래퍼 추가 (파일 끝에)
/** Reject a leave request by ID (leaveRequest.reject) — 사유 필수. */
export async function rejectLeaveRequest(token: string, id: string, reason: string): Promise<void> {
await trpcMutate("leaveRequest.reject", { id, reason }, token);
}
/**
* 직원의 해당 연도 연차 잔여 조회 (leaveRequest.listBalances).
* E1-2의 "승인 → 잔여 반영" 어서션용. 목록에 없으면 null.
*/
export async function getLeaveBalance(
token: string,
employeeId: string,
year: number,
): Promise<{ used: number; remaining: number } | null> {
const body = await trpcQuery("leaveRequest.listBalances", { year }, token);
const payload = body.result?.data?.json as
| { items?: { employeeId?: string; used?: number; remaining?: number }[] }
| { employeeId?: string; used?: number; remaining?: number }[]
| undefined;
const items = Array.isArray(payload) ? payload : (payload?.items ?? []);
const row = items.find((b) => b.employeeId === employeeId);
return row ? { used: Number(row.used ?? 0), remaining: Number(row.remaining ?? 0) } : null;
}
/**
* 외근 신청 시드 (offsiteWork.request). Returns the new record ID.
* requestOffsiteWorkSchema: workDate(coerce.date)·location(min 1)·reason(min 1) 필수.
*/
export async function submitOffsiteRequest(
token: string,
opts: {
workDate: string; // "YYYY-MM-DD"
startTime?: string; // "HH:mm"
endTime?: string;
workHours?: number;
location: string;
reason: string;
},
): Promise<string> {
const { workDate, startTime = "10:00", endTime = "16:00", workHours = 6, location, reason } = opts;
const body = await trpcMutate(
"offsiteWork.request",
{ workDate, startTime, endTime, workHours, location, reason },
token,
);
const payload = body.result?.data?.json as { id?: string; data?: { id?: string } } | undefined;
const id = payload?.id ?? payload?.data?.id;
if (!id) {
throw new Error(`[cross-app] submitOffsiteRequest: no id in response: ${JSON.stringify(body)}`);
}
return id;
}
/** 외근 신청 일괄 승인 (offsiteWork.approve — { ids }). */
export async function approveOffsiteRequests(token: string, ids: string[]): Promise<void> {
await trpcMutate("offsiteWork.approve", { ids }, token);
}
/** 외근 결과 보고 제출 (offsiteWork.submitReport) — report_pending 단계 시드용. */
export async function submitOffsiteReport(
token: string,
opts: { id: string; actualHours: number; summary: string },
): Promise<void> {
await trpcMutate("offsiteWork.submitReport", opts, token);
}
/** 외근 기록 상태 조회 (offsiteWork.get). */
export async function getOffsiteStatus(token: string, id: string): Promise<string> {
const body = await trpcQuery("offsiteWork.get", id, token);
const record = body.result?.data?.json as { status?: string } | undefined;
if (!record?.status) {
throw new Error(`[cross-app] getOffsiteStatus: no status in response: ${JSON.stringify(body)}`);
}
return record.status;
}주의 — offsiteWork.get의 input이 { id } 객체면(레포에서 get: ...input( 시그니처 확인) trpcQuery("offsiteWork.get", { id }, token)으로 조정. listBalances의 응답 형태(items 래핑 여부)도 첫 실행에서 관찰해 파서를 한쪽으로 확정하고 union 처리를 제거한다.
- [ ] Step 3: 검증 + Commit
pnpm --filter @hereby/test exec tsc --noEmit -p tsconfig.e2e.json
git add packages/test/e2e/helpers/dates.ts packages/test/e2e/helpers/cross-app.ts
git commit -m "test(e2e): add org-tz date helpers and offsite/leave seed wrappers
KST 자정 밀림을 피하는 todayLocalISO/addDaysISO 추가, 외근 신청/승인/보고/
상태조회 + 연차 반려/잔여 조회 tRPC 래퍼 확장 (E1-2·E1-3 시드용).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"(행동 검증은 Task 7·8의 spec이 담당 — 래퍼 단독 실행 단계는 두지 않는다.)
Task 6: 진입 헬퍼 + E1-1 근태 탭 스모크
Files:
- Create:
packages/test/e2e/helpers/admin-attendance.ts - Create:
packages/test/e2e/tests/apps/admin-attendance-tabs.spec.ts
Interfaces:
Produces:
openAttendanceTab(page: Page, tab?: "work" | "leave" | "overtime" | "offsite"): Promise<void>— admin 루트 진입(init 레이스 대기) → LNB 근태 현황 클릭 → 필요 시 상단 탭 클릭 → 패널 가시성 대기. Task 7·8이 consume.[ ] Step 1: 진입 헬퍼
// packages/test/e2e/helpers/admin-attendance.ts
import { expect, type Page } from "@playwright/test";
export type AttendanceTab = "work" | "leave" | "overtime" | "offsite";
/**
* admin 근태 현황 진입 — 유일하게 안전한 경로는 LNB 클릭이다.
* (#attendance 해시 딥링크는 VALID_TABS 부재로 employees 폴백 — KI-2, 스펙 §3.1)
* LNB 재진입 시 상단 탭은 항상 work로 리셋되므로 tab 인자로 목적 탭까지 이동한다.
*/
export async function openAttendanceTab(page: Page, tab: AttendanceTab = "work"): Promise<void> {
await page.goto("/");
// init 레이스 가드: LNB 직원 관리 항목 가시성 = isSystemAdmin 판정 + Alpine init 완료
await expect(page.getByTestId("lnb-employees")).toBeVisible({ timeout: 20_000 });
await page.getByTestId("lnb-attendance").click();
await expect(page).toHaveURL(/#attendance/);
await expect(page.getByTestId("att-panel-work")).toBeVisible();
if (tab !== "work") {
await page.getByTestId(`tabnav-${tab}`).click();
await expect(page.getByTestId(`att-panel-${tab}`)).toBeVisible();
}
}- [ ] Step 2: E1-1 스모크 spec
// packages/test/e2e/tests/apps/admin-attendance-tabs.spec.ts
/**
* E1-1 근태 현황 4탭 스모크 (스펙 §5.1)
* - LNB 진입 → URL #attendance → 4탭 순회, 각 패널 로드
* - iframe 탭(work/overtime)은 존재 + src 파라미터까지만 (내부 진입은 PR-E2)
* - F5 폴백은 KI-2 현행 계약의 characterization — 고치면 이 테스트가 깨져 의도 확인 강제
*/
import { expect, test } from "@playwright/test";
import { openAttendanceTab } from "../../helpers/admin-attendance";
test.describe("Admin attendance tabs smoke", () => {
test("LNB entry lands on work tab with the attOnly iframe", async ({ page }) => {
await openAttendanceTab(page, "work");
const iframe = page.getByTestId("att-embed-work");
await expect(iframe).toBeVisible();
// iframe URL 계약: tm(:4004) + att=1 + embedded=1 (admin embedRegistry ↔ tm 파서)
await expect(iframe).toHaveAttribute("src", /:4004\/\?att=1/);
await expect(iframe).toHaveAttribute("src", /embedded=1/);
});
test("overtime tab embeds /work with noOffsite=1 and NO mode param (§3.1 결재 모드 계약)", async ({
page,
}) => {
await openAttendanceTab(page, "overtime");
const iframe = page.getByTestId("att-embed-overtime");
await expect(iframe).toBeVisible();
await expect(iframe).toHaveAttribute("src", /:4004\/work\?noOffsite=1/);
// mode 파라미터가 없어야 tm 기본값 approval — 결재 UI가 뜨는 현행 계약
const src = await iframe.getAttribute("src");
expect(src).not.toContain("mode=");
});
test("leave and offsite tabs render native panels", async ({ page }) => {
await openAttendanceTab(page, "leave");
await expect(page.getByTestId("subtab-balances")).toBeVisible();
await page.getByTestId("tabnav-offsite").click();
await expect(page.getByTestId("att-panel-offsite")).toBeVisible();
});
test("F5 falls back to employees (KI-2 characterization — 현행 계약)", async ({ page }) => {
await openAttendanceTab(page, "work");
await page.reload();
await expect(page.getByTestId("lnb-employees")).toBeVisible({ timeout: 20_000 });
// characterization: locks current behavior, not proven-correct output
// VALID_TABS에 attendance가 없어 #attendance 해시가 employees로 폴백한다 (KI-2).
// 이 어서션이 깨지면 = 누군가 라우팅을 고친 것 → 스펙 §8 KI-2 갱신 후 기대값 교체.
await expect(page.getByTestId("att-panel-work")).toBeHidden();
});
});- [ ] Step 3: 수집 확인 → 실행 → Commit
pnpm --filter @hereby/test exec playwright test --list --project=admin | grep admin-attendance-tabs
pnpm --filter @hereby/test exec playwright test --project=admin admin-attendance-tabs
pnpm --filter @hereby/test exec tsc --noEmit -p tsconfig.e2e.json
git add packages/test/e2e/helpers/admin-attendance.ts packages/test/e2e/tests/apps/admin-attendance-tabs.spec.ts
git commit -m "test(e2e): attendance 4-tab smoke with LNB entry helper (E1-1)
openAttendanceTab 진입 헬퍼(딥링크 불가 계약 반영) + 4탭 스모크. iframe은
src 파라미터 계약(att=1/noOffsite=1/mode 부재)까지만, F5 employees 폴백은
KI-2 characterization으로 고정.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 7: E1-2 연차 결재 여정 (+ leave-management testid)
Files:
Modify:
apps/admin/src/views/tabs/leave-management.html(approval 표 행·보기·모달 버튼)Create:
packages/test/e2e/tests/apps/admin-attendance-leave.spec.ts[ ] Step 1: testid 부여
approval 표의 행 template(x-for="item in leaveApprovals" :232 부근)의 <tr>:
<tr data-testid="att-leave-row" :data-id="item.id">관리 열 [보기] 버튼(:257):
<button data-testid="att-leave-view" @click="openLeaveDetail(item)" class="ds-btn ds-btn--line ds-btn--sm">보기</button>휴가 상세 모달 푸터(:367-368):
<button data-testid="att-leave-reject" @click="openLeaveReject(leaveDetailRecord.id)" ...>반려</button>
<button data-testid="att-leave-approve" @click="approveLeaveDetail()" ...>승인</button>반려 사유 모달(:387, :392):
<textarea data-testid="att-leave-reject-reason" x-model="leaveRejectReason" ...>
<button data-testid="att-leave-reject-submit" @click="submitLeaveReject()" :disabled="!leaveRejectReason.trim()" ...>확인</button>- [ ] Step 2: spec 작성
// packages/test/e2e/tests/apps/admin-attendance-leave.spec.ts
/**
* E1-2 연차 결재 여정 (스펙 §5.1): API 시드 → UI 결재(승인/반려) → API 검증(expect.poll)
* - 승인은 확인 모달 없이 즉시 실행(approveLeaveById), 반려는 사유 필수 모달
* - 행 특정은 시드가 반환한 id ↔ data-id (텍스트 비의존, 스펙 §5.3)
*/
import { expect, test } from "@playwright/test";
import { openAttendanceTab } from "../../helpers/admin-attendance";
import {
getLeaveBalance,
getLeaveRequestStatus,
getMyEmployeeId,
loginAndGetToken,
submitLeaveRequest,
} from "../../helpers/cross-app";
import { addDaysISO, todayLocalISO } from "../../helpers/dates";
const ADMIN_EMAIL = process.env.E2E_ADMIN_IDENTIFIER || "admin@hereby.com";
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
function leaveRow(page: import("@playwright/test").Page, id: string) {
return page.locator(`[data-testid="att-leave-row"][data-id="${id}"]`);
}
test.describe("Admin attendance leave approval journey", () => {
test("approve: seeded pending request → 보기 → 승인 → API approved + 잔여 반영", async ({
page,
}) => {
const token = await loginAndGetToken(ADMIN_EMAIL, ADMIN_PASSWORD);
const employeeId = await getMyEmployeeId(token);
const year = Number(todayLocalISO().slice(0, 4));
const before = await getLeaveBalance(token, employeeId, year);
const start = addDaysISO(todayLocalISO(), 7);
const id = await submitLeaveRequest(token, {
leaveType: "annual",
startDate: start,
endDate: addDaysISO(start, 1), // 2일 신청
reason: `e2e-att-leave-approve-${Date.now()}`,
});
await openAttendanceTab(page, "leave");
await page.getByTestId("subtab-approval").click();
const row = leaveRow(page, id);
await expect(row).toBeVisible({ timeout: 10_000 });
await row.getByTestId("att-leave-view").click();
await page.getByTestId("att-leave-approve").click();
await expect
.poll(async () => getLeaveRequestStatus(token, id), {
timeout: 8_000,
intervals: [500, 1_000, 2_000],
})
.toBe("approved");
// 잔여 반영 — used가 차감 일수(2)만큼 증가. 승인 시점 차감이 아닐 경우(백엔드 정책)
// 첫 실행에서 관찰해 이 블록을 상태 검증만으로 축소하고 사유를 주석으로 남긴다.
if (before) {
await expect
.poll(async () => (await getLeaveBalance(token, employeeId, year))?.used ?? -1, {
timeout: 8_000,
intervals: [500, 1_000, 2_000],
})
.toBe(before.used + 2);
}
});
test("reject: 반려는 사유 필수 — 빈 사유 비활성 → 입력 → API rejected", async ({ page }) => {
const token = await loginAndGetToken(ADMIN_EMAIL, ADMIN_PASSWORD);
const start = addDaysISO(todayLocalISO(), 14);
const id = await submitLeaveRequest(token, {
leaveType: "annual",
startDate: start,
endDate: start,
reason: `e2e-att-leave-reject-${Date.now()}`,
});
await openAttendanceTab(page, "leave");
await page.getByTestId("subtab-approval").click();
const row = leaveRow(page, id);
await expect(row).toBeVisible({ timeout: 10_000 });
await row.getByTestId("att-leave-view").click();
await page.getByTestId("att-leave-reject").click();
// 사유 필수 계약: 빈 사유면 확인 비활성
await expect(page.getByTestId("att-leave-reject-submit")).toBeDisabled();
// 예외 규칙(§5.3): 사용자 입력값 반영 자체가 검증 대상 — 입력 텍스트 어서션 허용
await page.getByTestId("att-leave-reject-reason").fill("e2e 반려 사유");
await page.getByTestId("att-leave-reject-submit").click();
await expect
.poll(async () => getLeaveRequestStatus(token, id), {
timeout: 8_000,
intervals: [500, 1_000, 2_000],
})
.toBe("rejected");
});
});(반려는 UI로 수행하므로 rejectLeaveRequest wrapper는 이 spec에서 import하지 않는다 — Task 5의 wrapper는 PR-E2/후속 시드용으로 남는다.)
- [ ] Step 3: 실행 → Commit
pnpm --filter @hereby/test exec playwright test --project=admin admin-attendance-leave
pnpm --filter @hereby/test exec tsc --noEmit -p tsconfig.e2e.json
git add apps/admin/src/views/tabs/leave-management.html packages/test/e2e/tests/apps/admin-attendance-leave.spec.ts packages/test/e2e/helpers/cross-app.ts
git commit -m "test(e2e): leave approval journey on the attendance page (E1-2)
시드→보기 모달→승인/반려(사유 필수 비활성 가드)→API poll 검증. 행 특정은
data-id, 조작은 att-leave-* testid — 문구 변경에 면역.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 8: E1-3 외근 결재 여정 (+ offsite testid + 공용 confirm testid)
커버리지 0인 유일 도메인. 일괄 결재 + 단일 단계 선택 규칙 배선을 검증한다 (규칙 산수 자체는 PR-U attendance/offsite.test.ts 소관 — e2e는 disabled 배선까지만).
Files:
Modify:
apps/admin/src/views/tabs/offsite-management.htmlModify:
apps/admin/src/views/modals/confirm-action.html:29Create:
packages/test/e2e/tests/apps/admin-attendance-offsite.spec.ts[ ] Step 1: testid 부여
행 template <tr>(외근 목록 x-for)에:
<tr data-testid="att-offsite-row" :data-id="r.id">체크박스(:125 헤더, :142 행):
<th ...><input data-testid="att-offsite-check-all" type="checkbox" @change="toggleAllOffsiteRec($event)"></th><input data-testid="att-offsite-row-check" type="checkbox" :value="r.id" x-model="selectedOffsiteRecIds"
:disabled="!offsiteRecRowSelectable(r)">일괄 액션 바(:76 대기 승인, :82 보고서 승인):
<button data-testid="att-offsite-bulk-approve" @click="bulkApproveOffsiteRec()" ...>승인</button>
<button data-testid="att-offsite-bulk-approve-report" @click="bulkApproveOffsiteRec()" ...>보고서 승인</button>반려 모달(:378, :385):
<textarea data-testid="att-offsite-reject-reason" x-model="offsiteRejectForm.reason" ...>
<button data-testid="att-offsite-reject-submit" @click="confirmOffsiteReject()" :disabled="!offsiteRejectForm.reason.trim()" ...>공용 confirm 모달 primary 버튼(confirm-action.html:29):
<button data-testid="admin-confirm-ok" type="button" @click="executeConfirm()" ...>- [ ] Step 2: spec 작성
// packages/test/e2e/tests/apps/admin-attendance-offsite.spec.ts
/**
* E1-3 외근 결재 여정 (스펙 §5.1) — 커버리지 0이던 도메인.
* 시드 2건(pending) + 1건(report_pending) → 단일 단계 선택 규칙(타 단계 체크박스
* disabled 배선) → 일괄 승인(공용 confirm 모달) → API poll 검증.
* 규칙 산수는 단위(attendance/offsite.test.ts) 소관 — 여기서는 배선만.
*/
import { expect, test } from "@playwright/test";
import { openAttendanceTab } from "../../helpers/admin-attendance";
import {
approveOffsiteRequests,
getOffsiteStatus,
loginAndGetToken,
submitOffsiteReport,
submitOffsiteRequest,
} from "../../helpers/cross-app";
import { addDaysISO, todayLocalISO } from "../../helpers/dates";
const ADMIN_EMAIL = process.env.E2E_ADMIN_IDENTIFIER || "admin@hereby.com";
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
function offsiteRow(page: import("@playwright/test").Page, id: string) {
return page.locator(`[data-testid="att-offsite-row"][data-id="${id}"]`);
}
test.describe("Admin attendance offsite bulk approval journey", () => {
test("bulk approve two pending rows; other-step row stays unselectable", async ({ page }) => {
const token = await loginAndGetToken(ADMIN_EMAIL, ADMIN_PASSWORD);
const stamp = Date.now();
const workDate = addDaysISO(todayLocalISO(), 3);
// pending 2건
const idA = await submitOffsiteRequest(token, {
workDate,
location: `e2e-외근-A-${stamp}`,
reason: `e2e-offsite-approve-${stamp}-A`,
});
const idB = await submitOffsiteRequest(token, {
workDate: addDaysISO(workDate, 1),
location: `e2e-외근-B-${stamp}`,
reason: `e2e-offsite-approve-${stamp}-B`,
});
// report_pending 1건 (신청→승인→보고 제출)
const idR = await submitOffsiteRequest(token, {
workDate: addDaysISO(workDate, 2),
location: `e2e-외근-R-${stamp}`,
reason: `e2e-offsite-report-${stamp}`,
});
await approveOffsiteRequests(token, [idR]);
await submitOffsiteReport(token, { id: idR, actualHours: 5, summary: `e2e 보고 ${stamp}` });
await openAttendanceTab(page, "offsite");
// 시드 3건 모두 목록에 존재
await expect(offsiteRow(page, idA)).toBeVisible({ timeout: 10_000 });
await expect(offsiteRow(page, idB)).toBeVisible();
await expect(offsiteRow(page, idR)).toBeVisible();
// pending 2건 선택
await offsiteRow(page, idA).getByTestId("att-offsite-row-check").check();
await offsiteRow(page, idB).getByTestId("att-offsite-row-check").check();
// 단일 단계 규칙 배선: pending이 선택된 동안 report_pending 행은 체크 불가
await expect(offsiteRow(page, idR).getByTestId("att-offsite-row-check")).toBeDisabled();
// 일괄 승인 → 공용 confirm 모달 → 확인
await page.getByTestId("att-offsite-bulk-approve").click();
await page.getByTestId("admin-confirm-ok").click();
await expect
.poll(async () => getOffsiteStatus(token, idA), { timeout: 8_000, intervals: [500, 1_000, 2_000] })
.toBe("approved");
await expect
.poll(async () => getOffsiteStatus(token, idB), { timeout: 8_000, intervals: [500, 1_000, 2_000] })
.toBe("approved");
// report 단계 건은 건드리지 않았어야 한다
expect(await getOffsiteStatus(token, idR)).toBe("report_pending");
});
});주의 — 목록 필터: 외근 목록의 기본 기간/상태 필터가 시드 건을 가리면(미래 workDate가 기본 기간 밖) 첫 실행에서 관찰해 진입 직후 상태 필터를 '전체'로, 기간을 초기화하는 조작(해당 select/버튼에 testid 추가)을 spec 앞부분에 넣는다 — 스펙 §3.1 "빈 목록 ≠ 실패" 함정이므로 행 가시성 어서션 전에 page.waitForResponse("**/trpc/offsiteWork.list*")를 병행해도 좋다.
- [ ] Step 3: 실행 → Commit
pnpm --filter @hereby/test exec playwright test --project=admin admin-attendance-offsite
pnpm --filter @hereby/test exec tsc --noEmit -p tsconfig.e2e.json
git add apps/admin/src/views/tabs/offsite-management.html apps/admin/src/views/modals/confirm-action.html packages/test/e2e/tests/apps/admin-attendance-offsite.spec.ts
git commit -m "test(e2e): offsite bulk-approval journey with step-rule wiring (E1-3)
커버리지 0이던 외근 도메인 — pending 2건 일괄 승인(공용 confirm 경유),
타 단계(report_pending) 체크박스 disabled 배선, API poll 검증.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 9: 최종 게이트 + PR
- [ ] Step 1: 전체 실행
pnpm --filter @hereby/test exec playwright test --project=admin --project=employee-team-flow
pnpm --filter @hereby/test exec tsc --noEmit -p tsconfig.e2e.json
pnpm lint:check && pnpm buildExpected: 전부 green (admin project: routing 3 + add-employee + attendance 신규 3 spec).
- [ ] Step 2: Push + PR
git push -u origin test/attendance-e2e-native
gh pr create --base develop --title "test(e2e): attendance native-tab journeys + e2e infra (PR-E1)" --body "$(cat <<'EOF'
## Summary
- Add attendance E2E infra: admin webServer, dedicated `tsconfig.e2e.json` type gate, org-tz-safe date helpers, offsite/leave seed wrappers
- Add testid hooks (dynamic `lnb-/tabnav-/subtab-<id>` on shared nav components, `att-*` on attendance views, `admin-confirm-ok`) — registered in the e2e README registry; attribute-only, no behavior change
- New journeys: 4-tab smoke incl. iframe src contract + KI-2 F5-fallback characterization (E1-1), leave approve/reject with reason-required guard (E1-2), offsite bulk approval with step-rule wiring — first coverage for this domain (E1-3)
- Replace the stale "연차 관리" tab-routing case (LNB rework removed it) with "역할 관리" (E1-4)
## 요약
- 근태 현황 네이티브 탭(연차·외근) 결재 여정 E2E — 스펙 §5.1 PR-E1. 패턴은 API 시드 → UI 결재 → `expect.poll` API 검증
- 셀렉터는 data-testid 1순위(스펙 §5.3) — 행 특정은 `data-id`, 문구 정확성은 PR-U 단위 계약 테스트가 전담
- 실행 전제: 로컬 `make dev` + 시드 DB (CI 연동은 범위 외)
## Test plan
- [ ] `pnpm --filter @hereby/test exec playwright test --project=admin` green (기존 2 spec + 신규 3 spec)
- [ ] `pnpm --filter @hereby/test exec tsc --noEmit -p tsconfig.e2e.json` green
- [ ] `pnpm lint:check && pnpm build` green
- [ ] testid 추가가 화면에 영향 없는지 admin 근태 현황 수동 스모크
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"Self-Review 체크 결과 (계획 작성 시점)
- 스펙 커버리지: §5.1 선행 인프라 1→Task 0/1, 2→Task 3(+파일명 규칙은 Global Constraints), 3→Task 5(dates), 4→Task 5(래퍼), 5→Task 6(헬퍼). E1-1→Task 6, E1-2→Task 7, E1-3→Task 8, E1-4→Task 1. §5.3 testid→Task 4/7/8 + README 등록. §7 함정(tsconfig 분리→Task 2, init 레이스→헬퍼, 날짜 함정→dates.ts, 빈 목록 삼킴→Task 8 주의).
- 명시된 편차 3건: File Structure 절에 기록 (공유 컴포넌트 동적 testid 네이밍, E1-4 교체 대상 확정, 연차 승인 무모달).
- 사실 검증 기준일: 2026-07-06 develop(847afcbd) 정독 — LNB 구성(:207-257), approval 서브탭 admin 네이티브(:166), approveLeaveById 무모달(:6945), offsite bulk UI(:71-143), confirm-action.html, requestOffsiteWorkSchema, README testid 레지스트리 실존(46건 선례).
- 관찰-확정 항목 2건:
offsiteWork.getinput 형태(id vs {id}),listBalances응답 래핑 — 첫 실행에서 관찰해 확정하라는 지시를 해당 Step에 명시. E1-2 잔여 반영 타이밍도 동일.