Skip to content

근태 현황 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-19 develop 기준 재확인 완료) #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

스펙 대비 이행 편차 (기록용):

  1. §5.3 네이밍 예시 att-tab-work — 상단 탭·서브탭·LNB는 공유 컴포넌트(TabNav/SubTab/LnbSidebar)가 동적으로 렌더하므로 도메인 접두를 붙일 수 없다. 컴포넌트 네임스페이스 동적 testid(tabnav-<id>, subtab-<id>, lnb-<id>)로 대체 — 근태 페이지에서는 tabnav-work 등으로 소비. att- 접두는 admin 뷰에 직접 박는 도메인 요소에만 사용.
  2. E1-4 "수리 또는 삭제" — LNB 개편(develop)으로 "연차 관리" 항목이 제거됐음을 확인(현 LNB 근태 관리 = 스케줄러/근태 현황뿐). TABS 케이스를 LNB에 실존하고 VALID_TABS에도 있는 "역할 관리"(roles)로 교체 (3탭 커버리지 유지).
  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로.)

bash
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 필수)
bash
pnpm --filter @hereby/test exec playwright test --project=admin --project=employee-team-flow

Expected: 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의 "연차 관리" 행 교체

ts
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 확인
bash
pnpm --filter @hereby/test exec playwright test --project=admin

Expected: admin project 전부 PASS (routing 3케이스 + add-employee).

  • [ ] Step 3: Commit
bash
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: 파일 생성

jsonc
// 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를 버리고 자립 구성으로 전환한다:

jsonc
{
  "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인지 확인
bash
pnpm --filter @hereby/test exec tsc --noEmit -p tsconfig.e2e.json

Expected: 에러 0. 기존 e2e 파일에서 에러가 나오면 그 자리에서 수정(신규 게이트 도입 비용) — 단 src/tests가 끌려 들어온 것이면 include가 잘못된 것.

  • [ ] Step 3: Commit
bash
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 항목 뒤에 추가:

ts
    {
      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
bash
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 ... 태그에 각각 추가:

html
        :data-testid="'lnb-' + entry.item.id"
html
        :data-testid="'lnb-' + child.id"

(:57 쪽 바인딩 변수명은 파일에서 확인해 그 스코프의 item 변수로 맞춘다 — child/sub 등.)

tab-nav.html:12 <button 태그에 추가:

html
        :data-testid="'tabnav-' + t.id"

sub-tab.html:11 <button 태그에 추가:

html
      :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 부근)에:

html
<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" 목록 끝에 추가:

markdown
- **`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
bash
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): string
    • rejectLeaveRequest(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

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 래퍼 추가 (파일 끝에)
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
bash
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: 진입 헬퍼

ts
// 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
ts
// 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
bash
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>:

html
<tr data-testid="att-leave-row" :data-id="item.id">

관리 열 [보기] 버튼(:257):

html
<button data-testid="att-leave-view" @click="openLeaveDetail(item)" class="ds-btn ds-btn--line ds-btn--sm">보기</button>

휴가 상세 모달 푸터(:367-368):

html
<button data-testid="att-leave-reject" @click="openLeaveReject(leaveDetailRecord.id)" ...>반려</button>
<button data-testid="att-leave-approve" @click="approveLeaveDetail()" ...>승인</button>

반려 사유 모달(:387, :392):

html
<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 작성
ts
// 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
bash
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.html

  • Modify: apps/admin/src/views/modals/confirm-action.html:29

  • Create: packages/test/e2e/tests/apps/admin-attendance-offsite.spec.ts

  • [ ] Step 1: testid 부여

행 template <tr>(외근 목록 x-for)에:

html
<tr data-testid="att-offsite-row" :data-id="r.id">

체크박스(:125 헤더, :142 행):

html
<th ...><input data-testid="att-offsite-check-all" type="checkbox" @change="toggleAllOffsiteRec($event)"></th>
html
<input data-testid="att-offsite-row-check" type="checkbox" :value="r.id" x-model="selectedOffsiteRecIds"
  :disabled="!offsiteRecRowSelectable(r)">

일괄 액션 바(:76 대기 승인, :82 보고서 승인):

html
<button data-testid="att-offsite-bulk-approve" @click="bulkApproveOffsiteRec()" ...>승인</button>
<button data-testid="att-offsite-bulk-approve-report" @click="bulkApproveOffsiteRec()" ...>보고서 승인</button>

반려 모달(:378, :385):

html
<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):

html
<button data-testid="admin-confirm-ok" type="button" @click="executeConfirm()" ...>
  • [ ] Step 2: spec 작성
ts
// 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
bash
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: 전체 실행
bash
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 build

Expected: 전부 green (admin project: routing 3 + add-employee + attendance 신규 3 spec).

  • [ ] Step 2: Push + PR
bash
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.get input 형태(id vs {id}), listBalances 응답 래핑 — 첫 실행에서 관찰해 확정하라는 지시를 해당 Step에 명시. E1-2 잔여 반영 타이밍도 동일.