Skip to content

프론트엔드 테스트 도입 구현 계획 ​

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: 조용한 깨짐(silent breakage) 위험이 큰 프론트엔드 로직에 유닛 테스트 21파일 + Playwright e2e 5 spec을 추가하고, 8개 패키지에서 pnpm test로 실행 가능하게 만든다.

Architecture: 각 패키지에 vitest를 zero-config(devDep + test 스크립트만, 기본 env=node)로 추가하고 테스트를 소스 옆에 코로케이션(*.test.ts). e2e는 기존 packages/test의 Playwright 설정에 앱별 project를 추가해 확장. 비공개 헬퍼는 export만 추가(동작 무변경), Messenger는 순수 헬퍼 추출.

Tech Stack: vitest 2.x (node env), @playwright/test 1.x, pnpm workspace, TypeScript strict.

참조 문서:

  • 설계: docs/superpowers/specs/2026-06-10-frontend-testing-design.md
  • 작성 룰: .claude/rules/frontend/testing.md (네이밍 it("should …"), AAA, 모킹 제한, characterization 주석 규칙)

공통 규약 (모든 태스크 적용):

  1. Characterization 프로토콜: 단언값이 불확실한 테스트는 ① 최선의 추정값으로 작성 → ② 실행 → ③ 실패 시 실제 출력으로 단언값을 보정하고 // characterization: locks current behavior, not proven-correct output 주석을 단다. 동작이 버그로 보이면 고치지 말고 최종 보고에 기록한다.
  2. 커밋 메시지는 Conventional Commits(영문 제목), 본문에 한글 설명. 푸시는 하지 않는다.
  3. 각 태스크 완료 시 해당 패키지에서 pnpm --filter <pkg> test가 green이어야 커밋한다.
  4. 작업 브랜치: docs/frontend-testing-design에서 분기한 test/frontend-wave1 (Task 1에서 생성).

Phase A — 러너 셋업 ​

Task 1: 8개 패키지에 vitest 추가 ​

Files:

  • Modify: apps/hr-system/package.json, apps/importer/package.json, apps/admin/package.json, apps/team-manager/package.json, apps/employee-app/package.json, apps/general-affairs/package.json, apps/management-dashboard/package.json, packages/components/package.json

  • [ ] Step 1: 브랜치 생성

bash
git switch docs/frontend-testing-design && git switch -c test/frontend-wave1
  • [ ] Step 2: 8개 package.json 각각에 scripts와 devDependency 추가

각 파일의 "scripts"에 두 줄 추가 (기존 키 유지):

json
"test": "vitest run",
"test:watch": "vitest"

"devDependencies"에 추가 (다른 패키지와 동일 버전대):

json
"vitest": "^2.1.8"
  • [ ] Step 3: 설치 및 동작 확인

Run: pnpm install Run: pnpm --filter @hereby/hr-system test Expected: No test files found 류 메시지와 함께 종료 (exit code 1이어도 무방 — 테스트 파일이 아직 없음). vitest 바이너리가 실행되는지만 확인.

  • [ ] Step 4: Commit
bash
git add apps/*/package.json packages/components/package.json pnpm-lock.yaml
git commit -m "chore(test): add vitest runner to 8 frontend packages

프론트엔드 유닛 테스트 도입을 위해 8개 패키지에 vitest devDep과
test/test:watch 스크립트를 추가 (zero-config, node env)."

Phase B — hr-system 유닛 테스트 (6파일) ​

Task 2: escape-html 테스트 ​

Files:

  • Test: apps/hr-system/src/sheet/escape-html.test.ts

  • 대상: apps/hr-system/src/sheet/escape-html.ts (순수, 임포트 0)

  • [ ] Step 1: 테스트 작성

ts
import { describe, expect, it } from "vitest";
import { escapeHtml } from "./escape-html";

describe("escapeHtml", () => {
  it("should escape all five special characters", () => {
    expect(escapeHtml(`&<>"'`)).toBe("&amp;&lt;&gt;&quot;&#39;");
  });

  it("should pass plain strings through unchanged", () => {
    expect(escapeHtml("홍길동 123 abc")).toBe("홍길동 123 abc");
  });

  it("should return empty string for null and undefined", () => {
    expect(escapeHtml(null)).toBe("");
    expect(escapeHtml(undefined)).toBe("");
  });

  it("should stringify non-string input before escaping", () => {
    expect(escapeHtml(1234)).toBe("1234");
    expect(escapeHtml(0)).toBe("0");
    expect(escapeHtml(false)).toBe("false");
  });

  it("should neutralize a script tag", () => {
    expect(escapeHtml('<script>alert("x")</script>')).toBe(
      "&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;",
    );
  });

  it("should escape repeated specials in long strings", () => {
    expect(escapeHtml("a&b&c")).toBe("a&amp;b&amp;c");
  });
});
  • [ ] Step 2: 실행 확인

Run: pnpm --filter @hereby/hr-system test -- src/sheet/escape-html.test.ts Expected: 6 passed

  • [ ] Step 3: Commit
bash
git add apps/hr-system/src/sheet/escape-html.test.ts
git commit -m "test(hr-system): cover escapeHtml XSS escaping

모든 셀 렌더러가 쓰는 수제 이스케이프 함수의 5개 특수문자,
null/비문자열 입력, script 태그 무력화를 검증."

Task 3: selection 테스트 ​

Files:

  • Test: apps/hr-system/src/sheet/selection.test.ts

  • 대상: apps/hr-system/src/sheet/selection.ts

  • [ ] Step 1: 테스트 작성

ts
import { describe, expect, it } from "vitest";
import type { ColumnDef } from "./types";
import {
  cellKey,
  clearSelection,
  emptySelection,
  extendToRange,
  selectionRect,
  selectionToTsv,
  selectSingle,
  toggleCell,
} from "./selection";

const ROWS = ["r1", "r2", "r3"];
const COLS = ["a", "b", "c"];

describe("selection state machine", () => {
  it("should replace selection on selectSingle and bump version", () => {
    const s = emptySelection();

    selectSingle(s, { rowKey: "r1", colKey: "a" });
    selectSingle(s, { rowKey: "r2", colKey: "b" });

    expect([...s.selected]).toEqual(["r2:b"]);
    expect(s.anchor).toEqual({ rowKey: "r2", colKey: "b" });
    expect(s.version).toBe(2);
  });

  it("should toggle a cell in and out", () => {
    const s = emptySelection();

    toggleCell(s, { rowKey: "r1", colKey: "a" });
    expect(s.selected.has("r1:a")).toBe(true);
    toggleCell(s, { rowKey: "r1", colKey: "a" });
    expect(s.selected.has("r1:a")).toBe(false);
  });

  it("should extend to a rectangular range from the anchor", () => {
    const s = emptySelection();
    selectSingle(s, { rowKey: "r1", colKey: "a" });

    extendToRange(s, { rowKey: "r2", colKey: "b" }, ROWS, COLS);

    expect([...s.selected].sort()).toEqual(["r1:a", "r1:b", "r2:a", "r2:b"]);
  });

  it("should fall back to selectSingle when no anchor", () => {
    const s = emptySelection();

    extendToRange(s, { rowKey: "r3", colKey: "c" }, ROWS, COLS);

    expect([...s.selected]).toEqual(["r3:c"]);
  });

  it("should keep selection unchanged for unknown keys", () => {
    const s = emptySelection();
    selectSingle(s, { rowKey: "r1", colKey: "a" });
    const before = s.version;

    extendToRange(s, { rowKey: "missing", colKey: "b" }, ROWS, COLS);

    expect([...s.selected]).toEqual(["r1:a"]);
    expect(s.version).toBe(before);
  });

  it("should not bump version when clearing an already-empty selection", () => {
    const s = emptySelection();
    clearSelection(s);
    expect(s.version).toBe(0);
  });
});

describe("selectionRect / selectionToTsv", () => {
  function makeCol(key: string): ColumnDef {
    return {
      key,
      label: key,
      type: "t",
      width: 100,
      minWidth: 50,
      visible: true,
      align: "left",
      headerBg: "",
      cellBg: "",
    } as ColumnDef;
  }

  it("should return rect keys in natural order regardless of insertion order", () => {
    const s = emptySelection();
    toggleCell(s, { rowKey: "r3", colKey: "c" });
    toggleCell(s, { rowKey: "r1", colKey: "a" });

    const rect = selectionRect(s, ROWS, COLS);

    expect(rect.rowKeys).toEqual(["r1", "r3"]);
    expect(rect.colKeys).toEqual(["a", "c"]);
  });

  it("should return empty rect for empty selection", () => {
    expect(selectionRect(emptySelection(), ROWS, COLS)).toEqual({ rowKeys: [], colKeys: [] });
  });

  it("should build TSV with tab-joined cells and newline-joined rows", () => {
    const rect = { rowKeys: ["r1", "r2"], colKeys: ["a", "b"] };
    const rows = new Map([
      ["r1", { a: "1", b: "2" }],
      ["r2", { a: "3", b: "4" }],
    ]);
    const cols = new Map([
      ["a", makeCol("a")],
      ["b", makeCol("b")],
    ]);

    const tsv = selectionToTsv(rect, rows, cols, (row, col) => row[col.key as "a" | "b"]);

    expect(tsv).toBe("1\t2\n3\t4");
  });

  it("should emit empty line for a missing row and empty cell for a missing column", () => {
    const rect = { rowKeys: ["r1", "gone"], colKeys: ["a", "x"] };
    const rows = new Map([["r1", { a: "1" }]]);
    const cols = new Map([["a", makeCol("a")]]);

    const tsv = selectionToTsv(rect, rows, cols, (row, col) => row[col.key as "a"] ?? "");

    expect(tsv).toBe("1\t\n");
  });
});
  • [ ] Step 2: 실행 확인 — Run: pnpm --filter @hereby/hr-system test -- src/sheet/selection.test.ts / Expected: 10 passed. ColumnDef의 필수 필드가 더 있어 컴파일 에러가 나면 makeCol에 해당 필드를 추가.

  • [ ] Step 3: Commit

bash
git add apps/hr-system/src/sheet/selection.test.ts
git commit -m "test(hr-system): cover cell selection state machine and TSV export

선택 교체/토글/범위확장/클리어의 상태 전이와 버전 증가,
클립보드 TSV 생성의 결손 행·열 처리를 검증."

Task 4: history 테스트 ​

Files:

  • Test: apps/hr-system/src/sheet/history.test.ts

  • 대상: apps/hr-system/src/sheet/history.ts

  • [ ] Step 1: 테스트 작성

ts
import { describe, expect, it } from "vitest";
import { SheetHistory, type Transaction } from "./history";

function tx(kind: string, n = 1): Transaction {
  return {
    kind,
    changes: Array.from({ length: n }, (_, i) => ({
      rowKey: `r${i}`,
      colKey: "a",
      oldValue: "old",
      newValue: "new",
    })),
  };
}

describe("SheetHistory", () => {
  it("should report canUndo after push and canRedo after undo round-trip", () => {
    const h = new SheetHistory();

    expect(h.canUndo).toBe(false);
    h.push(tx("edit"));
    expect(h.canUndo).toBe(true);

    const popped = h.popForUndo();
    expect(popped?.kind).toBe("edit");
    h.pushToRedo(popped as Transaction);
    expect(h.canRedo).toBe(true);
  });

  it("should ignore empty transactions", () => {
    const h = new SheetHistory();
    h.push({ kind: "noop", changes: [] });
    expect(h.canUndo).toBe(false);
  });

  it("should clear the redo stack on a new push", () => {
    const h = new SheetHistory();
    h.push(tx("a"));
    h.pushToRedo(h.popForUndo() as Transaction);
    expect(h.canRedo).toBe(true);

    h.push(tx("b"));

    expect(h.canRedo).toBe(false);
  });

  it("should drop the oldest transaction beyond maxDepth", () => {
    const h = new SheetHistory(2);
    h.push(tx("first"));
    h.push(tx("second"));
    h.push(tx("third"));

    expect(h.popForUndo()?.kind).toBe("third");
    expect(h.popForUndo()?.kind).toBe("second");
    expect(h.popForUndo()).toBeNull();
  });

  it("should return null when popping empty stacks", () => {
    const h = new SheetHistory();
    expect(h.popForUndo()).toBeNull();
    expect(h.popForRedo()).toBeNull();
  });

  it("should empty both stacks on clear", () => {
    const h = new SheetHistory();
    h.push(tx("a"));
    h.pushToRedo(tx("b"));

    h.clear();

    expect(h.canUndo).toBe(false);
    expect(h.canRedo).toBe(false);
  });
});
  • [ ] Step 2: 실행 확인 — Run: pnpm --filter @hereby/hr-system test -- src/sheet/history.test.ts / Expected: 6 passed

  • [ ] Step 3: Commit

bash
git add apps/hr-system/src/sheet/history.test.ts
git commit -m "test(hr-system): cover undo/redo transaction stack

push/undo/redo 왕복, 빈 트랜잭션 무시, redo 무효화,
maxDepth 초과 시 최고(最古) 드롭을 검증."

Task 5: styles 순수 함수 테스트 ​

Files:

  • Test: apps/hr-system/src/sheet/styles.test.ts

  • 대상: apps/hr-system/src/sheet/styles.ts의 buildSelectionCss/buildAnchorCss (DOM 쓰는 createSelectionStyleManager는 제외)

  • [ ] Step 1: 테스트 작성

ts
import { describe, expect, it } from "vitest";
import { buildAnchorCss, buildSelectionCss } from "./styles";

describe("buildSelectionCss", () => {
  it("should return empty string for empty selection", () => {
    expect(buildSelectionCss(new Set())).toBe("");
  });

  it("should emit one attribute selector per cell, comma-joined", () => {
    const css = buildSelectionCss(new Set(["r1:a", "r2:b"]));

    expect(css).toContain('td[data-row-key="r1"][data-col-key="a"]');
    expect(css).toContain('td[data-row-key="r2"][data-col-key="b"]');
    expect(css.match(/td\[data-row-key/g)).toHaveLength(2);
    expect(css).toContain("var(--ds-selection-tint-18)");
  });

  it("should skip malformed keys without a colon", () => {
    expect(buildSelectionCss(new Set(["no-colon"]))).toBe("");
  });

  it("should escape double quotes and backslashes in keys", () => {
    const css = buildSelectionCss(new Set(['r"1:a\\b']));

    expect(css).toContain('td[data-row-key="r\\"1"][data-col-key="a\\\\b"]');
  });
});

describe("buildAnchorCss", () => {
  it("should return empty string for null anchor", () => {
    expect(buildAnchorCss(null)).toBe("");
  });

  it("should target the anchor cell with a 2px inset outline", () => {
    const css = buildAnchorCss({ rowKey: "r1", colKey: "a" });

    expect(css).toBe(
      'td[data-row-key="r1"][data-col-key="a"]{box-shadow:inset 0 0 0 2px var(--color-blue-500)!important;}',
    );
  });
});
  • [ ] Step 2: 실행 확인 — Run: pnpm --filter @hereby/hr-system test -- src/sheet/styles.test.ts / Expected: 6 passed

  • [ ] Step 3: Commit

bash
git add apps/hr-system/src/sheet/styles.test.ts
git commit -m "test(hr-system): cover selection CSS rule builders

선택/앵커 CSS 문자열 생성과 키 이스케이프, 비정상 키 스킵을 검증."

Task 6: router 테스트 ​

Files:

  • Test: apps/hr-system/src/router.test.ts

  • 대상: apps/hr-system/src/router.ts — stripBasePath/withBasePath. BASE_URL은 모듈 로드 시 import.meta.env.BASE_URL로 고정되므로, 비-루트 base 테스트는 vi.stubEnv + 동적 import + vi.resetModules로 수행.

  • [ ] Step 1: 테스트 작성

ts
import { afterEach, describe, expect, it, vi } from "vitest";

afterEach(() => {
  vi.unstubAllEnvs();
  vi.resetModules();
});

async function loadRouter() {
  return await import("./router");
}

describe("stripBasePath / withBasePath with BASE_URL='/'", () => {
  it("should pass paths through unchanged at root base", async () => {
    vi.stubEnv("BASE_URL", "/");
    const { stripBasePath, withBasePath } = await loadRouter();

    expect(stripBasePath("/payroll")).toBe("/payroll");
    expect(stripBasePath("")).toBe("/");
    expect(withBasePath("/sheets/abc")).toBe("/sheets/abc");
    expect(withBasePath("payroll")).toBe("/payroll");
  });
});

describe("stripBasePath / withBasePath with BASE_URL='/hr/'", () => {
  it("should strip the base prefix and restore '/' for the bare base path", async () => {
    vi.stubEnv("BASE_URL", "/hr/");
    const { stripBasePath } = await loadRouter();

    expect(stripBasePath("/hr/payroll")).toBe("/payroll");
    expect(stripBasePath("/hr")).toBe("/");
    expect(stripBasePath("/hr/")).toBe("/");
    expect(stripBasePath("/unrelated")).toBe("/unrelated");
  });

  it("should prepend the base and keep round-trip identity", async () => {
    vi.stubEnv("BASE_URL", "/hr/");
    const { stripBasePath, withBasePath } = await loadRouter();

    expect(withBasePath("/payroll")).toBe("/hr/payroll");
    expect(withBasePath("/")).toBe("/hr/");
    expect(stripBasePath(withBasePath("/sheets/u-1"))).toBe("/sheets/u-1");
  });

  it("should normalize a base url missing slashes", async () => {
    vi.stubEnv("BASE_URL", "hr");
    const { withBasePath } = await loadRouter();

    expect(withBasePath("/payroll")).toBe("/hr/payroll");
  });
});
  • [ ] Step 2: 실행 확인 — Run: pnpm --filter @hereby/hr-system test -- src/router.test.ts / Expected: 4 passed. (참고: stripBasePath("/hr/") 동작은 코드상 /hr/가 ${basePath}/로 시작 → "/" 반환 예상. 실패 시 characterization 프로토콜 적용.)

  • [ ] Step 3: Commit

bash
git add apps/hr-system/src/router.test.ts
git commit -m "test(hr-system): cover base-path URL helpers

탭 라우팅 버그군의 진원인 base path strip/prepend 로직을
루트/비루트/비정규 BASE_URL 세 케이스로 검증."

Task 7: layout-persist 테스트 ​

Files:

  • Test: apps/hr-system/src/sheet/layout-persist.test.ts

  • 대상: apps/hr-system/src/sheet/layout-persist.ts. node env에는 localStorage가 없으므로 인메모리 스텁을 globalThis에 주입(룰상 storage 모킹 허용).

  • [ ] Step 1: 테스트 작성

ts
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ColumnDef } from "./types";
import { clearLayout, layoutStorageKey, loadLayout, saveLayout } from "./layout-persist";

function makeCol(key: string, width = 100): ColumnDef {
  return {
    key,
    label: key,
    type: "t",
    width,
    minWidth: 50,
    visible: true,
    align: "left",
    headerBg: "",
    cellBg: "",
  } as ColumnDef;
}

const store = new Map<string, string>();

beforeEach(() => {
  store.clear();
  vi.stubGlobal("localStorage", {
    getItem: (k: string) => store.get(k) ?? null,
    setItem: (k: string, v: string) => void store.set(k, v),
    removeItem: (k: string) => void store.delete(k),
  });
});

afterEach(() => {
  vi.unstubAllGlobals();
});

describe("loadLayout", () => {
  it("should return cloned defaults when nothing is saved", () => {
    const defaults = [makeCol("a"), makeCol("b")];

    const loaded = loadLayout("s1", defaults);

    expect(loaded).toEqual(defaults);
    expect(loaded[0]).not.toBe(defaults[0]); // 복제본이어야 함
  });

  it("should fall back to defaults on corrupt JSON", () => {
    store.set(layoutStorageKey("s1"), "{not json");

    expect(loadLayout("s1", [makeCol("a")])).toEqual([makeCol("a")]);
  });

  it("should apply saved width/visible/frozen over defaults", () => {
    const defaults = [makeCol("a"), makeCol("b")];
    saveLayout("s1", [{ ...makeCol("a", 250), visible: false, frozen: true }, makeCol("b")]);

    const loaded = loadLayout("s1", defaults);

    expect(loaded[0]).toMatchObject({ key: "a", width: 250, visible: false, frozen: true });
  });

  it("should drop saved columns the adapter no longer defines", () => {
    saveLayout("s1", [makeCol("ghost"), makeCol("a")]);

    const loaded = loadLayout("s1", [makeCol("a")]);

    expect(loaded.map((c) => c.key)).toEqual(["a"]);
  });

  it("should insert newly-added default columns at their default position, not the end", () => {
    // 저장 시점에는 b, c만 존재 → 이후 어댑터가 맨 앞에 a를 추가한 상황
    saveLayout("s1", [makeCol("b"), makeCol("c")]);
    const defaults = [makeCol("a"), makeCol("b"), makeCol("c")];

    const loaded = loadLayout("s1", defaults);

    expect(loaded.map((c) => c.key)).toEqual(["a", "b", "c"]);
  });

  it("should preserve user reordering of known columns", () => {
    saveLayout("s1", [makeCol("c"), makeCol("a")]);
    const defaults = [makeCol("a"), makeCol("b"), makeCol("c")];

    const loaded = loadLayout("s1", defaults);

    // 사용자가 정한 c,a 순서 유지 + 신규 b는 기본 위치 규칙으로 삽입
    // characterization: locks current behavior, not proven-correct output
    expect(loaded.map((c) => c.key)).toEqual(["c", "a", "b"]);
  });
});

describe("clearLayout", () => {
  it("should remove the saved layout so defaults load next time", () => {
    saveLayout("s1", [makeCol("a", 999)]);
    clearLayout("s1");

    expect(store.has(layoutStorageKey("s1"))).toBe(false);
  });
});
  • [ ] Step 2: 실행 확인 — Run: pnpm --filter @hereby/hr-system test -- src/sheet/layout-persist.test.ts / Expected: 7 passed. 순서 단언 실패 시 characterization 프로토콜.

  • [ ] Step 3: Commit

bash
git add apps/hr-system/src/sheet/layout-persist.test.ts
git commit -m "test(hr-system): cover column layout persistence merge

저장 레이아웃과 기본 컬럼의 병합(신규 컬럼 위치 삽입, 제거 컬럼 드롭,
사용자 순서 유지)과 손상 데이터 폴백을 검증."

Phase C — importer 유닛 테스트 (5파일) ​

Task 8: excel-reader 파서 함수 테스트 ​

Files:

  • Test: apps/importer/src/utils/excel-reader.test.ts

  • 대상: excelDateToDate/parseString/parseNumber (fs를 타는 readWorkbook은 OUT)

  • [ ] Step 1: 테스트 작성

ts
import { describe, expect, it } from "vitest";
import { excelDateToDate, parseNumber, parseString } from "./excel-reader";

describe("parseNumber", () => {
  it("should return 0 for null, undefined, empty string and dash", () => {
    expect(parseNumber(null)).toBe(0);
    expect(parseNumber(undefined)).toBe(0);
    expect(parseNumber("")).toBe(0);
    expect(parseNumber("-")).toBe(0);
  });

  it("should pass numbers through", () => {
    expect(parseNumber(1234.5)).toBe(1234.5);
    expect(parseNumber(0)).toBe(0);
  });

  it("should strip commas, whitespace and won sign", () => {
    expect(parseNumber("1,234,567")).toBe(1234567);
    expect(parseNumber("₩1,234")).toBe(1234);
    expect(parseNumber(" 1 234 ")).toBe(1234);
  });

  it("should parse negative and decimal strings", () => {
    expect(parseNumber("-1,000")).toBe(-1000);
    expect(parseNumber("12.5")).toBe(12.5);
  });

  it("should silently return 0 for unparseable strings", () => {
    // characterization: locks current behavior, not proven-correct output
    // 회계식 음수 "(1,000)"도 0이 된다 — 조용한 데이터 손실 가능성으로 보고됨.
    expect(parseNumber("(1,000)")).toBe(0);
    expect(parseNumber("abc")).toBe(0);
  });
});

describe("parseString", () => {
  it("should return empty string for null, undefined and ''", () => {
    expect(parseString(null)).toBe("");
    expect(parseString(undefined)).toBe("");
    expect(parseString("")).toBe("");
  });

  it("should trim and stringify values", () => {
    expect(parseString("  홍길동  ")).toBe("홍길동");
    expect(parseString(42)).toBe("42");
  });
});

describe("excelDateToDate", () => {
  it("should return null for non-positive, non-numeric serials", () => {
    expect(excelDateToDate(0)).toBeNull();
    expect(excelDateToDate(0.5)).toBeNull();
    expect(excelDateToDate(-1)).toBeNull();
    expect(excelDateToDate(Number.NaN)).toBeNull();
  });

  it("should map serial 25569 to the Unix epoch (1970-01-01 UTC)", () => {
    expect(excelDateToDate(25569)?.toISOString()).toBe("1970-01-01T00:00:00.000Z");
  });

  it("should map serial 45292 to 2024-01-01 UTC", () => {
    expect(excelDateToDate(45292)?.toISOString()).toBe("2024-01-01T00:00:00.000Z");
  });

  it("should floor fractional serials (time-of-day is discarded)", () => {
    expect(excelDateToDate(45292.75)?.toISOString()).toBe("2024-01-01T00:00:00.000Z");
  });
});
  • [ ] Step 2: 실행 확인 — Run: pnpm --filter @hereby/importer test -- src/utils/excel-reader.test.ts / Expected: 11 passed

  • [ ] Step 3: Commit

bash
git add apps/importer/src/utils/excel-reader.test.ts
git commit -m "test(importer): cover excel cell parsers

급여 숫자/문자/날짜 파싱의 경계값과 통화기호 제거를 검증하고,
회계식 음수가 0이 되는 현재 동작을 characterization으로 고정."

Task 9: sheet-tab 제네릭 파서 테스트 ​

Files:

  • Test: apps/importer/src/orgs/bestrh/parsers/sheet-tab.parser.test.ts

  • 대상: inferColumnFormat(정밀 검증) + parseGenericTab 단일 테이블 모드(characterization)

  • [ ] Step 1: 테스트 작성

ts
import { describe, expect, it } from "vitest";
import type { SheetData } from "../../../utils/excel-reader";
import { inferColumnFormat, type ParsedRow, parseGenericTab } from "./sheet-tab.parser";

function sheet(rows: unknown[][]): SheetData {
  return { name: "테스트", rows, rowCount: rows.length, colCount: rows[0]?.length ?? 0 };
}

function rowsOf(values: unknown[]): ParsedRow[] {
  return values.map((v) => ({ cells: { k: v } }));
}

describe("inferColumnFormat", () => {
  it("should return text when no values are present", () => {
    expect(inferColumnFormat(rowsOf(["", null, undefined]), "k")).toBe("text");
  });

  it("should return number when the majority of sampled values are numeric", () => {
    expect(inferColumnFormat(rowsOf([100, 200, "텍스트"]), "k")).toBe("number");
  });

  it("should return text when no format reaches majority", () => {
    expect(inferColumnFormat(rowsOf([100, "a", "b", "c"]), "k")).toBe("text");
  });

  it("should sample at most the first 20 rows", () => {
    const values = [...Array.from({ length: 20 }, () => "텍스트"), ...Array(30).fill(100)];
    expect(inferColumnFormat(rowsOf(values), "k")).toBe("text");
  });
});

describe("parseGenericTab (mode: none, single table)", () => {
  it("should parse a simple header+data table and skip summary rows", () => {
    const data = sheet([
      ["사번", "이름", "금액"],
      ["bestrh0001", "홍길동", 1000],
      ["합계", "", 1000],
    ]);

    const result = parseGenericTab(data, "탭", {
      mode: "none",
      headerRow: 0,
      dataStartRow: 1,
      employeeNumberColumn: 0,
      skipSummaryRows: true,
    });

    // characterization: locks current behavior, not proven-correct output
    expect(result).toHaveLength(1);
    expect(result[0].headers).toEqual(["사번", "이름", "금액"]);
    expect(result[0].rows).toHaveLength(1);
    expect(result[0].rows[0].employeeNumber).toBe("bestrh0001");
    expect(result[0].rows[0].cells["금액"]).toBe(1000);
  });

  it("should deduplicate repeated headers with numeric suffixes", () => {
    const data = sheet([
      ["이름", "금액", "금액"],
      ["홍길동", 1, 2],
    ]);

    const result = parseGenericTab(data, "탭", { mode: "none", headerRow: 0, dataStartRow: 1 });

    expect(result[0].headers).toEqual(["이름", "금액", "금액_2"]);
  });

  it("should return empty array for an empty sheet", () => {
    expect(parseGenericTab(sheet([]), "탭", { mode: "none" })).toEqual([]);
  });
});
  • [ ] Step 2: 실행 — characterization 보정

Run: pnpm --filter @hereby/importer test -- src/orgs/bestrh/parsers/sheet-tab.parser.test.ts Expected: 일부 단언은 내부 구현(parseSingleTable)에 따라 다를 수 있음. 실패 시 실제 출력을 확인해 단언값을 보정하고 characterization 주석 유지. (단, inferColumnFormat 4건은 코드 분석 기준 정확해야 함 — 실패하면 보정이 아니라 원인 분석.)

  • [ ] Step 3: Commit
bash
git add apps/importer/src/orgs/bestrh/parsers/sheet-tab.parser.test.ts
git commit -m "test(importer): cover generic sheet-tab parser engine

47개 시트가 공유하는 제네릭 파서의 헤더 중복 제거(수정 이력 보유),
합계행 스킵, 컬럼 포맷 추론 다수결을 검증."

Task 10: payroll-ledger 파서 테스트 ​

Files:

  • Test: apps/importer/src/orgs/bestrh/parsers/payroll-ledger.parser.test.ts

  • [ ] Step 1: 테스트 작성

ts
import { describe, expect, it } from "vitest";
import type { SheetData } from "../../../utils/excel-reader";
import { parsePayrollLedger } from "./payroll-ledger.parser";

/** 데이터는 row 8부터. C0=사번, C2=이름, C3=부서 …, C35=총급여액 */
function ledgerSheet(dataRows: unknown[][]): SheetData {
  const rows: unknown[][] = [...Array(8).fill([]), ...dataRows];
  return { name: "급여대장(전체)", rows, rowCount: rows.length, colCount: 39 };
}

function row(over: Record<number, unknown>): unknown[] {
  const r = Array(39).fill("");
  for (const [i, v] of Object.entries(over)) {
    r[Number(i)] = v;
  }
  return r;
}

describe("parsePayrollLedger", () => {
  it("should map columns to fields for a normal employee row", () => {
    const records = parsePayrollLedger(
      ledgerSheet([
        row({ 0: "bestrh0001", 2: "홍길동", 3: "간호부", 13: "36,000,000", 15: 3000000, 35: 3500000 }),
      ]),
    );

    expect(records).toHaveLength(1);
    expect(records[0]).toMatchObject({
      employeeNumber: "bestrh0001",
      name: "홍길동",
      departmentName: "간호부",
      annualSalary: 36000000,
      monthlySalary: 3000000,
      totalPay: 3500000,
    });
  });

  it("should skip rows missing employee number or name", () => {
    const records = parsePayrollLedger(
      ledgerSheet([row({ 0: "bestrh0001" }), row({ 2: "이름만" })]),
    );

    expect(records).toHaveLength(0);
  });

  it("should skip 합계 summary rows", () => {
    const records = parsePayrollLedger(ledgerSheet([row({ 0: "합계", 2: "합계" })]));

    expect(records).toHaveLength(0);
  });

  it("should convert excel serial dates and keep null for blanks", () => {
    const records = parsePayrollLedger(
      ledgerSheet([row({ 0: "bestrh0001", 2: "홍길동", 7: 45292 })]),
    );

    expect(records[0].actualHireDate?.toISOString()).toBe("2024-01-01T00:00:00.000Z");
    expect(records[0].lossDate).toBeNull();
  });

  it("should ignore rows before the data start row", () => {
    const rows: unknown[][] = Array(8).fill(row({ 0: "bestrh9999", 2: "헤더영역" }));
    const records = parsePayrollLedger({ name: "x", rows, rowCount: 8, colCount: 39 });

    expect(records).toHaveLength(0);
  });
});
  • [ ] Step 2: 실행 확인 — Run: pnpm --filter @hereby/importer test -- src/orgs/bestrh/parsers/payroll-ledger.parser.test.ts / Expected: 5 passed

  • [ ] Step 3: Commit

bash
git add apps/importer/src/orgs/bestrh/parsers/payroll-ledger.parser.test.ts
git commit -m "test(importer): cover payroll ledger sheet parser

급여대장 39컬럼 매핑, 합계행/불완전행 스킵, 엑셀 날짜 변환을 검증."

Task 11: overtime 파서 테스트 ​

Files:

  • Test: apps/importer/src/orgs/bestrh/parsers/overtime.parser.test.ts

  • [ ] Step 1: 테스트 작성

ts
import { describe, expect, it } from "vitest";
import type { SheetData } from "../../../utils/excel-reader";
import { parseOvertimeSheet } from "./overtime.parser";

function sheet(rows: unknown[][]): SheetData {
  return { name: "행정시간외", rows, rowCount: rows.length, colCount: 17 };
}

function row(over: Record<number, unknown>): unknown[] {
  const r = Array(17).fill("");
  for (const [i, v] of Object.entries(over)) {
    r[Number(i)] = v;
  }
  return r;
}

describe("parseOvertimeSheet (standard format: 이름 at C0)", () => {
  it("should emit one record per employee summary row with totals", () => {
    const records = parseOvertimeSheet(
      sheet([
        row({ 0: "이름", 1: "시급", 2: "일자" }), // headerRow=0
        row({}), // header 다음 행은 스킵됨 (headerRow+2부터 데이터)
        row({ 0: "홍길동", 1: "15,000" }), // 데이터 행 — 이름/시급 누적
        row({ 0: "bestrh0001", 8: 10, 10: 50000, 11: 100000, 13: "홍길동", 14: 450000, 15: 300000 }),
      ]),
      "ADMIN",
    );

    // characterization: locks current behavior, not proven-correct output
    expect(records).toHaveLength(1);
    expect(records[0]).toMatchObject({
      name: "홍길동",
      employeeNumber: "bestrh0001",
      hourlyRate: 15000,
      overtimeHours: 10,
      totalPay: 450000,
    });
    expect(records[0].workDate).toBeNull();
  });

  it("should skip summary rows with zero total pay", () => {
    const records = parseOvertimeSheet(
      sheet([
        row({ 0: "이름" }),
        row({}),
        row({ 0: "홍길동", 1: 15000 }),
        row({ 0: "bestrh0001", 14: 0 }),
      ]),
      "ADMIN",
    );

    expect(records).toHaveLength(0);
  });

  it("should return empty when no 이름 header is found in the first 5 rows", () => {
    const records = parseOvertimeSheet(sheet([row({ 0: "제목" }), row({}), row({})]), "ADMIN");

    expect(records).toEqual([]);
  });
});
  • [ ] Step 2: 실행 — characterization 보정 — Run: pnpm --filter @hereby/importer test -- src/orgs/bestrh/parsers/overtime.parser.test.ts. 합계 컬럼 인덱스(8/10/11/13/14/15)는 코드 분석 기준이나 헷갈리기 쉬움 — 실패 시 실제 출력으로 보정.

  • [ ] Step 3: Commit

bash
git add apps/importer/src/orgs/bestrh/parsers/overtime.parser.test.ts
git commit -m "test(importer): cover overtime sheet parser

시간외 시트의 요약행 수집(사번 prefix 감지), 0원 스킵,
헤더 미발견 시 빈 결과를 검증."

Task 12: salary-grade 파서 테스트 ​

Files:

  • Test: apps/importer/src/orgs/bestrh/parsers/salary-grade.parser.test.ts

  • [ ] Step 1: 테스트 작성

ts
import { describe, expect, it } from "vitest";
import type { SheetData } from "../../../utils/excel-reader";
import {
  parseFunctionalGrades,
  parseNursingGrades,
  parseRehabCareer,
} from "./salary-grade.parser";

function sheet(rows: unknown[][]): SheetData {
  return { name: "호봉", rows, rowCount: rows.length, colCount: 13 };
}

function row(over: Record<number, unknown>): unknown[] {
  const r = Array(13).fill("");
  for (const [i, v] of Object.entries(over)) {
    r[Number(i)] = v;
  }
  return r;
}

const HEADER_PAD = [row({}), row({}), row({})]; // row 0~2

describe("parseRehabCareer", () => {
  it("should prefer 군필(C12) salary and fall back to 미필(C9)", () => {
    const records = parseRehabCareer(
      sheet([
        ...HEADER_PAD,
        row({ 0: "군필자", 6: 3, 12: 40000000 }),
        row({ 0: "미필자", 6: 1, 9: 35000000 }),
      ]),
    );

    expect(records[0]).toMatchObject({ name: "군필자", grade: 3, annualSalary: 40000000 });
    expect(records[1]).toMatchObject({ name: "미필자", grade: 1, annualSalary: 35000000 });
  });

  it("should skip rows without a name", () => {
    expect(parseRehabCareer(sheet([...HEADER_PAD, row({ 9: 1000 })]))).toHaveLength(0);
  });
});

describe("parseFunctionalGrades", () => {
  it("should collect rows with positive base salary from row 4", () => {
    const records = parseFunctionalGrades(
      sheet([...HEADER_PAD, row({}), row({ 0: 1, 1: 2010000, 2: 30000, 4: 2040000 })]),
    );

    expect(records).toEqual([{ grade: 1, baseSalary: 2010000, hobongAmount: 30000, totalSalary: 2040000 }]);
  });

  it("should skip rows with zero base salary", () => {
    expect(parseFunctionalGrades(sheet([...HEADER_PAD, row({}), row({ 0: 1 })]))).toHaveLength(0);
  });
});

describe("parseNursingGrades", () => {
  it("should extract grade number from labels and convert 만원 to 원", () => {
    const records = parseNursingGrades(
      sheet([...HEADER_PAD, row({}), row({ 5: "1호봉", 6: 3600, 7: 60 })]),
    );

    expect(records).toEqual([
      { gradeLabel: "1호봉", grade: 1, annualSalary: 36000000, increaseAmount: 600000 },
    ]);
  });

  it("should map 입사 label to grade 0", () => {
    const records = parseNursingGrades(sheet([...HEADER_PAD, row({}), row({ 5: "입사", 6: 3400, 7: 0 })]));

    expect(records[0]).toMatchObject({ gradeLabel: "입사", grade: 0, annualSalary: 34000000 });
  });
});
  • [ ] Step 2: 실행 확인 — Run: pnpm --filter @hereby/importer test -- src/orgs/bestrh/parsers/salary-grade.parser.test.ts / Expected: 6 passed

  • [ ] Step 3: Commit

bash
git add apps/importer/src/orgs/bestrh/parsers/salary-grade.parser.test.ts
git commit -m "test(importer): cover salary grade parsers

재활 경력(군필 우선 연봉), 기능직/간호부 호봉 파싱과
만원→원 환산을 검증."

Phase D — admin / team-manager (4파일 + export 변경) ​

Task 13: admin scheduler 헬퍼 export + 테스트 ​

Files:

  • Modify: apps/admin/src/scheduler.ts:55-73 — toDateStr, getAssignmentLabels 앞에 export 키워드 추가 (function toDateStr → export function toDateStr, function getAssignmentLabels → export function getAssignmentLabels)

  • Test: apps/admin/src/scheduler-helpers.test.ts

  • [ ] Step 1: export 추가 (위 두 함수, 동작 무변경)

  • [ ] Step 2: 테스트 작성

ts
import { describe, expect, it } from "vitest";
import { getAssignmentLabels, toDateStr } from "./scheduler";

describe("toDateStr", () => {
  it("should take the date part of an ISO string as-is", () => {
    expect(toDateStr("2026-06-10T08:00:00")).toBe("2026-06-10");
    expect(toDateStr("2026-06-10")).toBe("2026-06-10");
  });

  it("should convert Date objects via UTC, shifting KST midnight to the previous day", () => {
    // characterization: locks current behavior, not proven-correct output
    // KST 자정(=UTC 전날 15시)의 Date는 전날 날짜가 된다 — 의심 버그로 보고됨.
    expect(toDateStr(new Date("2026-06-10T00:00:00+09:00"))).toBe("2026-06-09");
    // UTC 정오는 안전하게 같은 날짜.
    expect(toDateStr(new Date("2026-06-10T12:00:00Z"))).toBe("2026-06-10");
  });
});

describe("getAssignmentLabels", () => {
  it("should return fixed labels for shifts and i18n keys for leave types", () => {
    // $store 없는 ctx → _t는 키를 그대로 반환
    const labels = getAssignmentLabels({});

    expect(labels.day).toBe("D");
    expect(labels.off).toBe("OFF");
    expect(labels.annual_leave).toBe("nurse.assignment_annual_leave");
  });

  it("should use the store translation when available", () => {
    const ctx = { $store: { hereby: { t: (k: string) => `KO:${k}` } } };

    expect(getAssignmentLabels(ctx).education).toBe("KO:nurse.assignment_education");
  });
});
  • [ ] Step 3: 실행 확인 — Run: pnpm --filter @hereby/admin test -- src/scheduler-helpers.test.ts / Expected: 4 passed

  • [ ] Step 4: Commit

bash
git add apps/admin/src/scheduler.ts apps/admin/src/scheduler-helpers.test.ts
git commit -m "test(admin): cover scheduler date/label helpers

toDateStr의 UTC 변환(KST 자정 하루 밀림)을 characterization으로
고정해 의심 버그를 표면화하고, 근무 라벨 i18n 폴백을 검증.
테스트를 위해 두 헬퍼에 export만 추가(동작 무변경)."

Task 14: admin kr-holidays 테스트 ​

Files:

  • Test: apps/admin/src/data/kr-holidays.test.ts

  • [ ] Step 1: 테스트 작성

ts
import { describe, expect, it } from "vitest";
import { getHolidayName, isHoliday, KR_HOLIDAYS } from "./kr-holidays";

describe("kr-holidays", () => {
  it("should recognize fixed national holidays", () => {
    expect(isHoliday("2024-01-01")).toBe(true);
    expect(getHolidayName("2024-10-09")).toBe("한글날");
    expect(getHolidayName("2025-01-29")).toBe("설날");
  });

  it("should return false/empty for working days", () => {
    expect(isHoliday("2024-03-02")).toBe(false);
    expect(getHolidayName("2024-03-02")).toBe("");
  });

  it("should not match malformed date strings", () => {
    expect(isHoliday("2024-1-1")).toBe(false);
    expect(isHoliday("")).toBe(false);
  });

  it("should keep all keys in YYYY-MM-DD format", () => {
    for (const key of Object.keys(KR_HOLIDAYS)) {
      expect(key).toMatch(/^\d{4}-\d{2}-\d{2}$/);
    }
  });

  it("should cover years 2024 through 2027", () => {
    const years = new Set(Object.keys(KR_HOLIDAYS).map((k) => k.slice(0, 4)));
    expect([...years].sort()).toEqual(["2024", "2025", "2026", "2027"]);
  });
});
  • [ ] Step 2: 실행 확인 — Run: pnpm --filter @hereby/admin test -- src/data/kr-holidays.test.ts / Expected: 5 passed

  • [ ] Step 3: Commit

bash
git add apps/admin/src/data/kr-holidays.test.ts
git commit -m "test(admin): cover Korean holiday lookup

공휴일 판정/명칭 조회, 포맷 불일치 거부, 2024-2027 커버리지를 검증."

Task 15: team-manager 헬퍼 export + 테스트 ​

Files:

  • Modify: apps/team-manager/src/app.ts:54-72 — toDateStr, getAssignmentLabels에 export 추가. apps/team-manager/src/app.ts:165 — initialTabFromUrl에 export 추가

  • Test: apps/team-manager/src/app-helpers.test.ts

  • [ ] Step 1: export 추가 (세 함수, 동작 무변경)

  • [ ] Step 2: 테스트 작성

ts
import { afterEach, describe, expect, it, vi } from "vitest";
import { getAssignmentLabels, initialTabFromUrl, toDateStr } from "./app";

describe("toDateStr (admin scheduler와 동일 사본)", () => {
  it("should take the date part of an ISO string as-is", () => {
    expect(toDateStr("2026-06-10T08:00:00")).toBe("2026-06-10");
  });

  it("should convert Date objects via UTC, shifting KST midnight to the previous day", () => {
    // characterization: locks current behavior, not proven-correct output
    expect(toDateStr(new Date("2026-06-10T00:00:00+09:00"))).toBe("2026-06-09");
  });
});

describe("getAssignmentLabels", () => {
  it("should return fixed shift labels and i18n keys without a store", () => {
    const labels = getAssignmentLabels({});
    expect(labels.night).toBe("N");
    expect(labels.bereavement).toBe("nurse.assignment_bereavement");
  });
});

describe("initialTabFromUrl", () => {
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  function stubPath(pathname: string) {
    vi.stubGlobal("window", { location: { pathname } });
  }

  it("should pick the first path segment when it is a valid tab", () => {
    stubPath("/leave");
    expect(initialTabFromUrl()).toBe("leave");
  });

  it("should fall back to overview for unknown segments and root", () => {
    // characterization: locks current behavior, not proven-correct output
    stubPath("/unknown");
    expect(initialTabFromUrl()).toBe("overview");
    stubPath("/");
    expect(initialTabFromUrl()).toBe("overview");
  });
});
  • [ ] Step 3: 실행 확인 — Run: pnpm --filter @hereby/team-manager test -- src/app-helpers.test.ts 주의: app.ts import 시 모듈 최상단 사이드이펙트(다른 import)가 실행됨. SDK/Alpine import로 node에서 죽으면, 세 헬퍼를 apps/team-manager/src/app-helpers.ts 신규 파일로 이동(move)하고 app.ts가 거기서 import하도록 변경 후 테스트도 신규 파일을 import. (이 경우 커밋 메시지에 이동 사실 명시.)

  • [ ] Step 4: Commit

bash
git add apps/team-manager/src/app.ts apps/team-manager/src/app-helpers.test.ts
git commit -m "test(team-manager): cover scheduler date/tab helpers

admin과 중복인 toDateStr의 UTC 하루 밀림을 동일하게 고정하고,
URL→초기 탭 결정 로직을 검증. 헬퍼 export 추가(동작 무변경)."

Task 16: kr-holidays 사본 동기화 가드 ​

Files:

  • Test: apps/team-manager/src/data/kr-holidays-sync.test.ts

  • [ ] Step 1: 테스트 작성

ts
import { describe, expect, it } from "vitest";
import { KR_HOLIDAYS as adminHolidays } from "../../../admin/src/data/kr-holidays";
import { KR_HOLIDAYS as teamManagerHolidays } from "./kr-holidays";

describe("kr-holidays copy sync", () => {
  it("should keep admin and team-manager holiday tables identical", () => {
    // 두 앱에 동일 사본이 의도적으로 중복돼 있다(통합 리팩터링 전까지).
    // 한쪽만 갱신되면 이 테스트가 즉시 깨져 조용한 불일치를 막는다.
    expect(teamManagerHolidays).toEqual(adminHolidays);
  });
});
  • [ ] Step 2: 실행 확인 — Run: pnpm --filter @hereby/team-manager test -- src/data/kr-holidays-sync.test.ts / Expected: 1 passed. (team-manager의 tsc 빌드가 cross-app import를 거부하면 expect에 파일 시스템 비교 대신 두 모듈 dynamic import로 전환 검토 — 우선 정적 import로 시도.)

  • [ ] Step 3: Commit

bash
git add apps/team-manager/src/data/kr-holidays-sync.test.ts
git commit -m "test(team-manager): guard kr-holidays copy drift against admin

공휴일 데이터가 두 앱에 사본으로 존재하는 동안
한쪽만 갱신되는 조용한 드리프트를 deep-equal로 차단."

Phase E — employee-app / general-affairs / management-dashboard (3파일 + export) ​

Task 17: api-environment-store 테스트 ​

Files:

  • Test: apps/employee-app/src/stores/api-environment-store.test.ts

  • 대상: normalizeApiBaseUrl, getStartupApiBaseUrl (Capacitor는 vi.mock)

  • [ ] Step 1: 테스트 작성

ts
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const isNativePlatform = vi.fn();
vi.mock("@capacitor/core", () => ({
  // biome-ignore lint/style/useNamingConvention: upstream API name
  Capacitor: { isNativePlatform: () => isNativePlatform() },
}));

import { getStartupApiBaseUrl, normalizeApiBaseUrl } from "./api-environment-store";

describe("normalizeApiBaseUrl", () => {
  it("should prepend http:// when no protocol is given", () => {
    expect(normalizeApiBaseUrl("192.168.0.5:3000")).toBe("http://192.168.0.5:3000");
  });

  it("should strip trailing slashes", () => {
    expect(normalizeApiBaseUrl("https://api.hereby.kr/")).toBe("https://api.hereby.kr");
  });

  it("should return null for empty, whitespace and non-http protocols", () => {
    expect(normalizeApiBaseUrl("")).toBeNull();
    expect(normalizeApiBaseUrl("   ")).toBeNull();
    expect(normalizeApiBaseUrl("ftp://files.example.com")).toBeNull();
  });

  it("should return null for unparseable urls", () => {
    expect(normalizeApiBaseUrl("http://")).toBeNull();
  });
});

describe("getStartupApiBaseUrl", () => {
  const store = new Map<string, string>();

  beforeEach(() => {
    store.clear();
    vi.stubGlobal("window", {
      localStorage: {
        getItem: (k: string) => store.get(k) ?? null,
        setItem: (k: string, v: string) => void store.set(k, v),
        removeItem: (k: string) => void store.delete(k),
      },
    });
  });

  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it("should always use the default on web (non-native)", () => {
    isNativePlatform.mockReturnValue(false);
    store.set("hereby.api.base-url", "http://custom:3000");

    expect(getStartupApiBaseUrl("http://default:3000")).toBe("http://default:3000");
  });

  it("should use the stored custom url on native", () => {
    isNativePlatform.mockReturnValue(true);
    store.set("hereby.api.base-url", "http://custom:3000");

    expect(getStartupApiBaseUrl("http://default:3000")).toBe("http://custom:3000");
  });

  it("should fall back to default on native when nothing is stored", () => {
    isNativePlatform.mockReturnValue(true);

    expect(getStartupApiBaseUrl("http://default:3000")).toBe("http://default:3000");
  });
});
  • [ ] Step 2: 실행 확인 — Run: pnpm --filter @hereby/employee-app test -- src/stores/api-environment-store.test.ts / Expected: 7 passed. (normalizeApiBaseUrl("http://") 단언이 실패하면 — new URL("http://")는 환경에 따라 throw 여부가 다름 — 실제 동작으로 보정.)

  • [ ] Step 3: Commit

bash
git add apps/employee-app/src/stores/api-environment-store.test.ts
git commit -m "test(employee-app): cover API base-url normalization and startup pick

모바일 앱이 엉뚱한 API를 조용히 바라보게 만들 수 있는 URL 정규화와
네이티브/웹 분기, 저장값 폴백을 검증 (PR #253 버그군)."

Task 18: general-affairs 헬퍼 export + 테스트 ​

Files:

  • Modify: apps/general-affairs/src/app.ts:26-89 — safeNumber, formatDateTimeLocal, formatDateForInput에 export 추가

  • Test: apps/general-affairs/src/app-helpers.test.ts

  • [ ] Step 1: export 추가 (세 함수, 동작 무변경)

  • [ ] Step 2: 테스트 작성

ts
import { describe, expect, it } from "vitest";
import { formatDateForInput, formatDateTimeLocal, safeNumber } from "./app";

describe("safeNumber", () => {
  it("should pass finite numbers through and reject NaN/Infinity", () => {
    expect(safeNumber(42)).toBe(42);
    expect(safeNumber(Number.NaN)).toBe(0);
    expect(safeNumber(Number.POSITIVE_INFINITY)).toBe(0);
  });

  it("should parse comma-separated numeric strings", () => {
    expect(safeNumber("1,234,567")).toBe(1234567);
    expect(safeNumber("-12.5")).toBe(-12.5);
  });

  it("should reject hex, exponents and mixed text with the fallback", () => {
    expect(safeNumber("0x10")).toBe(0);
    expect(safeNumber("1e3")).toBe(0);
    expect(safeNumber("12개")).toBe(0);
    expect(safeNumber("12개", -1)).toBe(-1);
  });

  it("should return the fallback for empty and non-string/number values", () => {
    expect(safeNumber("")).toBe(0);
    expect(safeNumber(null, 7)).toBe(7);
    expect(safeNumber(undefined)).toBe(0);
  });
});

describe("formatDateTimeLocal", () => {
  it("should format an instant into the given timezone as YYYY-MM-DDTHH:mm", () => {
    expect(formatDateTimeLocal("2026-01-15T00:30:00Z", "Asia/Seoul")).toBe("2026-01-15T09:30");
    expect(formatDateTimeLocal("2026-01-15T00:30:00Z", "UTC")).toBe("2026-01-15T00:30");
  });

  it("should return empty string for empty input", () => {
    expect(formatDateTimeLocal("", "Asia/Seoul")).toBe("");
  });

  it("should slice unparseable strings to 16 chars", () => {
    expect(formatDateTimeLocal("not-a-date-but-long-string", "Asia/Seoul")).toBe(
      "not-a-date-but-l",
    );
  });
});

describe("formatDateForInput", () => {
  it("should keep YYYY-MM-DD strings as-is", () => {
    expect(formatDateForInput("2026-06-10")).toBe("2026-06-10");
  });

  it("should convert ISO datetimes to the UTC date part", () => {
    // characterization: locks current behavior, not proven-correct output
    // toISOString 기반이라 KST 자정 입력은 전날로 밀린다.
    expect(formatDateForInput("2026-06-10T00:00:00+09:00")).toBe("2026-06-09");
  });

  it("should slice unparseable strings to 10 chars", () => {
    expect(formatDateForInput("garbage-string")).toBe("garbage-st");
  });

  it("should return empty string for empty input", () => {
    expect(formatDateForInput("")).toBe("");
  });
});
  • [ ] Step 3: 실행 확인 — Run: pnpm --filter @hereby/general-affairs test -- src/app-helpers.test.ts / Expected: 11 passed. Task 15와 동일하게 app.ts import가 사이드이펙트로 실패하면 세 함수를 app-helpers.ts로 이동.

  • [ ] Step 4: Commit

bash
git add apps/general-affairs/src/app.ts apps/general-affairs/src/app-helpers.test.ts
git commit -m "test(general-affairs): cover number/date form helpers

콤마 숫자 파싱의 거부 규칙(hex/지수/혼합텍스트), 타임존 변환 포맷,
date input 변환의 UTC 하루 밀림(characterization)을 검증."

Task 19: management-dashboard 월 산술 export + 테스트 ​

Files:

  • Modify: apps/management-dashboard/src/app.ts:1303-1326 — currentMonth, formatYearMonth, previousMonthOf, generateYearRange에 export 추가

  • Test: apps/management-dashboard/src/month-helpers.test.ts

  • [ ] Step 1: export 추가 (네 함수, 동작 무변경)

  • [ ] Step 2: 테스트 작성

ts
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { currentMonth, formatYearMonth, generateYearRange, previousMonthOf } from "./app";

describe("formatYearMonth", () => {
  it("should zero-pad the month", () => {
    expect(formatYearMonth(new Date(2026, 0, 15))).toBe("2026-01");
    expect(formatYearMonth(new Date(2026, 11, 1))).toBe("2026-12");
  });
});

describe("previousMonthOf", () => {
  it("should step back one month within a year", () => {
    expect(previousMonthOf("2026-03")).toBe("2026-02");
  });

  it("should cross the year boundary from January", () => {
    expect(previousMonthOf("2026-01")).toBe("2025-12");
  });
});

describe("currentMonth / generateYearRange (시계 고정)", () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date(2026, 5, 10)); // 2026-06-10
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it("should return the frozen current month", () => {
    expect(currentMonth()).toBe("2026-06");
  });

  it("should list the current year and two previous years, descending", () => {
    expect(generateYearRange()).toEqual([2026, 2025, 2024]);
  });
});
  • [ ] Step 3: 실행 확인 — Run: pnpm --filter @hereby/management-dashboard test -- src/month-helpers.test.ts / Expected: 5 passed. import 사이드이펙트 문제 시 Task 15와 동일 대응.

  • [ ] Step 4: Commit

bash
git add apps/management-dashboard/src/app.ts apps/management-dashboard/src/month-helpers.test.ts
git commit -m "test(management-dashboard): cover month arithmetic helpers

연 경계를 넘는 전월 계산과 연도 범위 생성을 시계 고정으로 검증."

Phase F — packages/components (3파일 + 헬퍼 추출) ​

Task 20: positionFloatingMenu 테스트 ​

Files:

  • Test: packages/components/src/positionFloatingMenu.test.ts

  • 대상: packages/components/src/positionFloatingMenu.ts — DOM 인자는 가짜 객체로, window.innerHeight는 stub.

  • [ ] Step 1: 테스트 작성

ts
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { positionFloatingMenu } from "./positionFloatingMenu";

type FakeMenu = {
  closest: () => HTMLElement | null;
  offsetHeight: number;
  style: { left?: string; top?: string };
};

function trigger(rect: Partial<DOMRect>): HTMLElement {
  return { getBoundingClientRect: () => rect as DOMRect } as unknown as HTMLElement;
}

function menu(offsetHeight: number, root: { getBoundingClientRect: () => Partial<DOMRect> } | null = null): FakeMenu {
  return {
    closest: () => root as unknown as HTMLElement | null,
    offsetHeight,
    style: {},
  };
}

beforeEach(() => {
  vi.stubGlobal("window", { innerHeight: 800 });
});

afterEach(() => {
  vi.unstubAllGlobals();
});

describe("positionFloatingMenu", () => {
  it("should do nothing when trigger or menu is missing", () => {
    const m = menu(100);
    positionFloatingMenu(null, m as unknown as HTMLElement);
    positionFloatingMenu(trigger({}), null);
    expect(m.style).toEqual({});
  });

  it("should open below the trigger when there is room", () => {
    const m = menu(150);

    positionFloatingMenu(trigger({ left: 100, bottom: 200 }), m as unknown as HTMLElement);

    expect(m.style.left).toBe("100px");
    expect(m.style.top).toBe("204px"); // bottom + 4
  });

  it("should flip above the trigger when space below is short", () => {
    vi.stubGlobal("window", { innerHeight: 300 });
    const m = menu(150);

    positionFloatingMenu(trigger({ left: 100, bottom: 200 }), m as unknown as HTMLElement);

    expect(m.style.top).toBe("46px"); // bottom - height - 4
  });

  it("should clamp the flipped position to 4px minimum", () => {
    vi.stubGlobal("window", { innerHeight: 100 });
    const m = menu(300);

    positionFloatingMenu(trigger({ left: 0, bottom: 50 }), m as unknown as HTMLElement);

    expect(m.style.top).toBe("4px");
  });

  it("should position relative to the containing modal when present", () => {
    const root = { getBoundingClientRect: () => ({ left: 50, top: 40, bottom: 760 }) };
    const m = menu(150, root);

    positionFloatingMenu(trigger({ left: 100, bottom: 200 }), m as unknown as HTMLElement);

    expect(m.style.left).toBe("50px"); // 100 - 50
    expect(m.style.top).toBe("164px"); // 200 - 40 + 4
  });
});
  • [ ] Step 2: 실행 확인 — Run: pnpm --filter @hereby/components test -- src/positionFloatingMenu.test.ts / Expected: 5 passed

  • [ ] Step 3: Commit

bash
git add packages/components/src/positionFloatingMenu.test.ts
git commit -m "test(components): cover floating menu positioning

동일 버그가 2회 수정된 메뉴 위치 계산의 아래/위 플립,
최소 4px 클램프, 모달 기준 상대 좌표를 검증."

Task 21: Messenger 대화 매핑 헬퍼 추출 + 테스트 ​

Files:

  • Create: packages/components/src/components/messenger-helpers.ts

  • Modify: packages/components/src/components/Messenger.ts:332-374 — loadConversations의 map 콜백 본문을 신규 헬퍼 호출로 교체

  • Test: packages/components/src/components/messenger-helpers.test.ts

  • [ ] Step 1: 헬퍼 추출 — messenger-helpers.ts 생성. Messenger.ts의 매핑 로직을 그대로 이동(동작 무변경):

ts
/**
 * Messenger 대화 목록 매핑 — Messenger.ts loadConversations에서 추출한 순수 로직.
 * 표시명 우선순위: customName > title > (direct는 상대 이름 / group·department는 기본 라벨)
 */

// biome-ignore lint/suspicious/noExplicitAny: API response shape (기존 Messenger.ts와 동일)
export function buildConversationItem(c: any, currentUserId: string) {
  let contactName = c.customName || c.title || "";
  let contactRole = "";
  if (!contactName && c.type === "direct" && c.members) {
    // biome-ignore lint/suspicious/noExplicitAny: API response shape
    const other = c.members.find((m: any) => m.employeeId !== currentUserId);
    if (other) {
      contactName = `${other.lastName || ""}${other.firstName || ""}`.trim();
      contactRole = "";
    } else {
      contactName = "알 수 없음";
    }
  } else if (c.type === "group") {
    if (!contactName) {
      contactName = c.title || "그룹";
    }
    const memberCount = c.members?.length || 0;
    contactRole = `${memberCount}명`;
  } else if (c.type === "department") {
    if (!contactName) {
      contactName = c.title || "부서 단톡방";
    }
    const memberCount = c.members?.length || 0;
    contactRole = `${memberCount}명`;
  }
  return {
    id: c.id,
    type: c.type || "direct",
    title: c.title || "",
    contactName: contactName || "대화",
    contactRole,
    lastMessage: c.lastMessageContent || "",
    lastMessageTime: c.lastMessageAt || c.createdAt || "",
    unreadCount: c.unreadCount || 0,
    isPinned: false,
    customName: c.customName || undefined,
  };
}

Messenger.ts에서는:

ts
import { buildConversationItem } from "./messenger-helpers";
// ...
this.conversations = items.map((c: any) => buildConversationItem(c, this.currentUserId));
  • [ ] Step 2: 테스트 작성
ts
import { describe, expect, it } from "vitest";
import { buildConversationItem } from "./messenger-helpers";

const ME = "emp-me";

describe("buildConversationItem", () => {
  it("should prefer customName over title over derived name", () => {
    const item = buildConversationItem(
      { id: "1", type: "direct", customName: "별칭", title: "원제목", members: [] },
      ME,
    );
    expect(item.contactName).toBe("별칭");
  });

  it("should derive the other member's name for direct chats", () => {
    const item = buildConversationItem(
      {
        id: "1",
        type: "direct",
        members: [
          { employeeId: ME, lastName: "나", firstName: "야" },
          { employeeId: "emp-2", lastName: "홍", firstName: "길동" },
        ],
      },
      ME,
    );
    expect(item.contactName).toBe("홍길동");
  });

  it("should label a direct chat 알 수 없음 when the other party left", () => {
    const item = buildConversationItem(
      { id: "1", type: "direct", members: [{ employeeId: ME }] },
      ME,
    );
    expect(item.contactName).toBe("알 수 없음");
  });

  it("should show member count as role for group and department chats", () => {
    const group = buildConversationItem(
      { id: "g", type: "group", title: "그룹A", members: [{}, {}, {}] },
      ME,
    );
    const dept = buildConversationItem({ id: "d", type: "department", members: [{}] }, ME);

    expect(group.contactRole).toBe("3명");
    expect(dept.contactName).toBe("부서 단톡방");
    expect(dept.contactRole).toBe("1명");
  });

  it("should fall back lastMessageTime to createdAt and default unread to 0", () => {
    const item = buildConversationItem({ id: "1", createdAt: "2026-06-01T00:00:00Z" }, ME);

    expect(item.type).toBe("direct");
    expect(item.lastMessageTime).toBe("2026-06-01T00:00:00Z");
    expect(item.unreadCount).toBe(0);
    expect(item.contactName).toBe("대화");
  });
});
  • [ ] Step 3: 실행 + 빌드 확인

Run: pnpm --filter @hereby/components test -- src/components/messenger-helpers.test.ts Expected: 5 passed Run: pnpm --filter @hereby/components build Expected: 추출 후에도 빌드 통과

  • [ ] Step 4: Commit
bash
git add packages/components/src/components/messenger-helpers.ts packages/components/src/components/Messenger.ts packages/components/src/components/messenger-helpers.test.ts
git commit -m "refactor(components): extract Messenger conversation mapping + tests

정렬/표시명 버그가 잦았던 대화 목록 매핑을 순수 헬퍼로 추출(동작
무변경)하고 표시명 우선순위, 상대 퇴장(알 수 없음), 멤버 수 라벨,
폴백 규칙을 검증."

Task 22: LoginForm 검증 로직 테스트 ​

Files:

  • Test: packages/components/src/components/LoginForm.test.ts

  • 대상: 팩토리 객체에 가짜 $store를 붙여 메서드 직접 호출 (DOM·Alpine 불필요)

  • [ ] Step 1: 테스트 작성

ts
import { describe, expect, it, vi } from "vitest";
import { loginFormComponent } from "./LoginForm";

function makeForm(storeOverrides: Record<string, unknown> = {}) {
  const login = vi.fn().mockResolvedValue(undefined);
  const loginWithCode = vi.fn().mockResolvedValue(undefined);
  const form = loginFormComponent();
  form.$store = {
    hereby: { t: (k: string) => k, login, loginWithCode, ...storeOverrides },
  };
  return { form, login, loginWithCode };
}

describe("LoginForm.submit", () => {
  it("should trim the identifier before calling login", async () => {
    const { form, login } = makeForm();
    form.identifier = "  emp001  ";
    form.password = "pw";

    await form.submit();

    expect(login).toHaveBeenCalledWith("emp001", "pw");
    expect(form.error).toBe("");
  });

  it("should set an error and skip login when identifier or password is missing", async () => {
    const { form, login } = makeForm();
    form.identifier = "";
    form.password = "";

    await form.submit();

    expect(form.error).toBe("auth.identifier_required");
    expect(login).not.toHaveBeenCalled();
  });

  it("should surface login failure messages and reset isLoading", async () => {
    const { form, login } = makeForm();
    login.mockRejectedValue(new Error("잘못된 비밀번호"));
    form.identifier = "emp001";
    form.password = "bad";

    await form.submit();

    expect(form.error).toBe("잘못된 비밀번호");
    expect(form.isLoading).toBe(false);
  });

  it("should map network failures to the api_unreachable message", async () => {
    const { form, login } = makeForm();
    login.mockRejectedValue(new TypeError("Failed to fetch"));
    form.identifier = "emp001";
    form.password = "pw";

    await form.submit();

    expect(form.error).toBe("auth.api_unreachable");
  });
});

describe("LoginForm.verifyCode", () => {
  it("should reject non-6-digit codes before hitting the API", async () => {
    const { form, loginWithCode } = makeForm();
    form.identifier = "emp001";
    form.code = "12345";

    await form.verifyCode();

    expect(form.error).toBe("auth.code_format_invalid");
    expect(loginWithCode).not.toHaveBeenCalled();
  });

  it("should trim identifier and code on success", async () => {
    const { form, loginWithCode } = makeForm();
    form.identifier = " emp001 ";
    form.code = " 123456 ";

    await form.verifyCode();

    expect(loginWithCode).toHaveBeenCalledWith("emp001", "123456");
  });
});

describe("LoginForm.switchMode", () => {
  it("should clear the error when switching modes", () => {
    const { form } = makeForm();
    form.error = "이전 에러";

    form.switchMode("phone_otp");

    expect(form.mode).toBe("phone_otp");
    expect(form.error).toBe("");
  });
});
  • [ ] Step 2: 실행 확인 — Run: pnpm --filter @hereby/components test -- src/components/LoginForm.test.ts / Expected: 7 passed

  • [ ] Step 3: Commit

bash
git add packages/components/src/components/LoginForm.test.ts
git commit -m "test(components): cover LoginForm validation and error mapping

식별자 trim(수정 이력 보유), 빈 입력 거부, OTP 6자리 검증,
네트워크 오류 메시지 매핑을 가짜 store로 검증."

Phase G — 유닛 전체 검증 ​

Task 23: 전 패키지 검증 + 의심 버그 보고 ​

  • [ ] Step 1: 전체 테스트 실행

Run (각각):

bash
pnpm --filter @hereby/hr-system test
pnpm --filter @hereby/importer test
pnpm --filter @hereby/admin test
pnpm --filter @hereby/team-manager test
pnpm --filter @hereby/employee-app test
pnpm --filter @hereby/general-affairs test
pnpm --filter @hereby/management-dashboard test
pnpm --filter @hereby/components test

Expected: 전부 green (21개 파일)

  • [ ] Step 2: 린트 + 빌드

Run: pnpm lint:check && pnpm build Expected: 통과. 실패 시 pnpm lint:fix 후 재확인. (export 추가로 인한 unused-export 경고 등은 여기서 잡는다.)

  • [ ] Step 3: 의심 버그 정리 커밋(문서) — 발견 사항을 spec 문서 하단에 추가:

docs/superpowers/specs/2026-06-10-frontend-testing-design.md 끝에 섹션 추가:

markdown
## 7. Wave 1 구현 중 표면화된 의심 버그 (수정 안 함 — 별도 결정 필요)

- `toDateStr` (team-manager app.ts / admin scheduler.ts 중복): Date 입력 시
  `toISOString()` 기반이라 KST 자정~09:00 시각이 전날 날짜로 변환됨.
  간호사 근무표 날짜 계산에 영향 가능. characterization 테스트로 고정됨.
- `formatDateForInput` (general-affairs): 동일한 UTC 변환 패턴.
- `parseNumber` (importer): 회계식 음수 "(1,000)"이 조용히 0이 됨.
- (구현 중 추가 발견 시 여기에 추기)
  • [ ] Step 4: Commit
bash
git add docs/superpowers/specs/2026-06-10-frontend-testing-design.md
git commit -m "docs(testing): record suspected bugs surfaced by wave 1 tests

characterization 테스트가 고정한 의심 동작(타임존 하루 밀림,
회계식 음수 소실)을 설계 문서에 기록."

Phase H — E2E 확장 (packages/test) ​

Task 24: Playwright 멀티앱 인프라 ​

Files:

  • Modify: packages/test/playwright.config.ts — projects 추가

  • Modify: packages/test/e2e/global-setup.ts — 페르소나별 storageState 생성

  • Create: packages/test/e2e/helpers/app-urls.ts

  • [ ] Step 1: 앱 URL 헬퍼 생성 (packages/test/e2e/helpers/app-urls.ts)

ts
export const PORTS = {
  api: Number(process.env.API_PORT || 3000),
  hrSystem: Number(process.env.HR_SYSTEM_PORT || 4000),
  admin: Number(process.env.ADMIN_PORT || 4001),
  employeeApp: Number(process.env.EMPLOYEE_APP_PORT || 4002),
  teamManager: Number(process.env.TEAM_MANAGER_PORT || 4004),
  generalAffairs: Number(process.env.GENERAL_AFFAIRS_PORT || 4005),
};

export const BASE_URLS = {
  api: process.env.E2E_API_BASE_URL || `http://localhost:${PORTS.api}`,
  admin: process.env.E2E_ADMIN_BASE_URL || `http://localhost:${PORTS.admin}`,
  employeeApp: process.env.E2E_EMPLOYEE_BASE_URL || `http://localhost:${PORTS.employeeApp}`,
  teamManager: process.env.E2E_TEAM_MANAGER_BASE_URL || `http://localhost:${PORTS.teamManager}`,
  generalAffairs: process.env.E2E_GA_BASE_URL || `http://localhost:${PORTS.generalAffairs}`,
};
  • [ ] Step 2: global-setup 확장 — 기존 admin 로그인 로직을 함수화해 페르소나×앱 조합으로 storageState 파일 생성. 기존 loginAndStash 흐름(fetch auth.login → page.goto(앱) → localStorage에 hereby_access_token/hereby_refresh_token 저장 → context.storageState({path})) 재사용:
ts
// global-setup.ts에 추가/치환 (기존 hr-system 셋업은 유지)
async function loginAndStash(identifier: string, password: string, appBaseUrl: string, statePath: string) {
  const loginRes = await fetch(`${API_BASE_URL}/trpc/auth.login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ json: { identifier, password } }),
  });
  if (!loginRes.ok) {
    throw new Error(`[e2e:setup] login failed for ${identifier}: ${loginRes.status}`);
  }
  const body = (await loginRes.json()) as {
    result?: { data?: { json?: { accessToken?: string; refreshToken?: string } } };
  };
  const { accessToken, refreshToken } = body.result?.data?.json ?? {};
  if (!accessToken) {
    throw new Error(`[e2e:setup] missing accessToken for ${identifier}`);
  }
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();
  await page.goto(appBaseUrl);
  await page.evaluate(
    ([at, rt]) => {
      window.localStorage.setItem("hereby_access_token", at);
      if (rt) {
        window.localStorage.setItem("hereby_refresh_token", rt);
      }
    },
    [accessToken, refreshToken ?? ""] as const,
  );
  await context.storageState({ path: statePath });
  await browser.close();
}

호출부: admin 계정으로 admin/general-affairs 앱, 팀장 계정으로 team-manager, 직원 계정으로 employee-app. 계정 식별자는 시드 데이터 기준으로 확인 필요 — pnpm --filter @hereby/database db:seed가 만드는 계정을 packages/database의 seed 스크립트에서 확인하고 env(E2E_MANAGER_IDENTIFIER/E2E_EMPLOYEE_IDENTIFIER 등)로 주입한다. 확인 결과 적합한 계정이 없으면 admin 계정 하나로 모든 앱에 로그인(권한이 가장 넓음)하는 것으로 단순화한다.

  • [ ] Step 3: playwright.config.ts projects 추가 — 기존 chromium project 유지, 추가:
ts
projects: [
  { name: "chromium", use: { ...devices["Desktop Chrome"], storageState: "e2e/playwright/storageState.json" } },
  {
    name: "admin",
    testMatch: /apps\/admin-.*\.spec\.ts/,
    use: { ...devices["Desktop Chrome"], baseURL: ADMIN_BASE_URL, storageState: "e2e/playwright/adminState.json" },
  },
  {
    name: "employee-team-flow",
    testMatch: /apps\/(leave|overtime)-approval\.spec\.ts/,
    use: { ...devices["Desktop Chrome"] }, // 크로스앱: 스펙 내부에서 context를 직접 만든다
  },
  {
    name: "general-affairs",
    testMatch: /apps\/ga-.*\.spec\.ts/,
    use: { ...devices["Desktop Chrome"], baseURL: GA_BASE_URL, storageState: "e2e/playwright/gaState.json" },
  },
],

(파일 상단에 ADMIN_BASE_URL/GA_BASE_URL 상수를 app-urls와 동일 규칙으로 정의. 기존 chromium project가 e2e/tests/apps/를 잡지 않도록 testIgnore: /apps\// 추가.)

  • [ ] Step 4: 셋업 동작 확인

Run: make dev (별도 터미널, API+앱 기동) 후 pnpm test:e2e -- --list Expected: 기존 15 spec + 신규 project 구성이 에러 없이 나열됨

  • [ ] Step 5: Commit
bash
git add packages/test/playwright.config.ts packages/test/e2e/global-setup.ts packages/test/e2e/helpers/app-urls.ts
git commit -m "test(e2e): add per-app Playwright projects and persona auth states

admin/employee-app/team-manager/general-affairs 앱별 project와
페르소나 storageState 부트스트랩을 추가해 e2e를 다중 앱으로 확장."

Task 25: admin 탭 라우팅 스모크 spec ​

Files:

  • Create: packages/test/e2e/tests/apps/admin-tab-routing.spec.ts

  • [ ] Step 1: spec 작성 — 회귀 6건+ 클러스터(PR 251/252) 방어가 목적. 탭 목록은 admin 실행 화면에서 확인하되 초안:

ts
import { expect, test } from "@playwright/test";

/**
 * admin 탭 라우팅 스모크 — "탭 클릭 → URL 갱신 → 새로고침 복원 → 뒤로가기"
 * 회귀 클러스터(PR #251/#252: 라우트명 불일치, 새로고침 시 데이터 미로드) 방어.
 */
const TABS: { name: RegExp; urlPart: string }[] = [
  { name: /직원/, urlPart: "employees" },
  { name: /조직/, urlPart: "organization" },
  { name: /연차|휴가/, urlPart: "leave" },
  { name: /급여/, urlPart: "payroll" },
  { name: /설정/, urlPart: "settings" },
];

test.describe("Admin tab routing", () => {
  for (const tab of TABS) {
    test(`tab ${tab.urlPart}: click updates URL, refresh restores, back works`, async ({ page }) => {
      await page.goto("/");

      await page.getByRole("button", { name: tab.name }).or(page.getByRole("link", { name: tab.name })).first().click();
      await expect(page).toHaveURL(new RegExp(tab.urlPart));

      await page.reload();
      await expect(page).toHaveURL(new RegExp(tab.urlPart));
      // 탭 컨텐츠가 비어 있지 않아야 함 — "빈 탭" 회귀 방어
      await expect(page.locator("main, [x-show], .tab-content").first()).toBeVisible();

      await page.goBack();
      await expect(page).toHaveURL(/\/$|\/(?!$)/);
    });
  }
});
  • [ ] Step 2: 셀렉터 보정 — pnpm test:e2e -- --project=admin admin-tab-routing 실행, 실제 탭 라벨/URL 패턴(라우팅이 path 기반인지 확인 — admin은 router.ts 보유)에 맞게 TABS와 컨텐츠 로케이터를 보정. 탭이 path가 아닌 내부 상태만 바꾸면 URL 단언을 탭 활성 상태 단언(aria-selected 또는 활성 클래스 대신 역할 기반으로)으로 교체.

  • [ ] Step 3: Commit

bash
git add packages/test/e2e/tests/apps/admin-tab-routing.spec.ts
git commit -m "test(e2e): add admin tab routing smoke spec

탭 클릭→URL→새로고침 복원→뒤로가기 루프로
반복된 탭 라우팅 회귀(빈 탭/라우트명 불일치)를 방어."

Task 26: admin 직원 추가 위저드 spec ​

Files:

  • Create: packages/test/e2e/tests/apps/admin-add-employee.spec.ts

  • [ ] Step 1: spec 작성

ts
import { expect, test } from "@playwright/test";

test.describe("Admin add employee wizard", () => {
  test("creates an employee through the wizard and shows it in the list", async ({ page }) => {
    const unique = `e2e${Date.now().toString().slice(-7)}`;

    await page.goto("/");
    await page.getByRole("button", { name: /직원/ }).or(page.getByRole("link", { name: /직원/ })).first().click();

    await page.getByRole("button", { name: /직원 추가|추가/ }).first().click();

    // AddEmployeeWizard (공유 컴포넌트) — 필수 필드만 채워 끝까지 진행
    await page.getByLabel(/이름|성명/).first().fill(`테스트${unique}`);
    await page.getByLabel(/이메일/).fill(`${unique}@e2e.test`);
    // 단계가 여러 개면 "다음" 버튼 반복
    while (await page.getByRole("button", { name: /다음/ }).isVisible().catch(() => false)) {
      await page.getByRole("button", { name: /다음/ }).click();
    }
    await page.getByRole("button", { name: /저장|완료|등록/ }).click();

    // 목록에 신규 직원 노출
    await expect(page.getByText(`테스트${unique}`)).toBeVisible({ timeout: 15_000 });
  });
});
  • [ ] Step 2: 위저드 단계 보정 — 실행하며 packages/components/html/add-employee-wizard.html의 실제 필드 라벨(사번/팀/입사일 등 필수값)을 확인해 채움 단계를 보정. 필수 셀렉트(팀 등)는 첫 옵션 선택으로 처리.

  • [ ] Step 3: Commit

bash
git add packages/test/e2e/tests/apps/admin-add-employee.spec.ts
git commit -m "test(e2e): add admin add-employee wizard spec

온보딩의 시작점인 공유 직원 추가 위저드를 끝까지 진행해
목록 노출까지 검증."

Task 27: 휴가 신청→승인 크로스앱 spec ​

Files:

  • Create: packages/test/e2e/tests/apps/leave-approval.spec.ts

  • Create: packages/test/e2e/helpers/cross-app.ts

  • [ ] Step 1: 크로스앱 헬퍼 (cross-app.ts) — 페르소나별 context 생성:

ts
import { type Browser, type BrowserContext, type Page } from "@playwright/test";
import { BASE_URLS } from "./app-urls";

export async function openAs(
  browser: Browser,
  statePath: string,
  baseUrl: string,
): Promise<{ context: BrowserContext; page: Page }> {
  const context = await browser.newContext({ storageState: statePath, baseURL: baseUrl });
  const page = await context.newPage();
  return { context, page };
}

export const STATE = {
  employee: "e2e/playwright/employeeState.json",
  manager: "e2e/playwright/managerState.json",
};

export { BASE_URLS };
  • [ ] Step 2: spec 작성
ts
import { expect, test } from "@playwright/test";
import { BASE_URLS, openAs, STATE } from "../../helpers/cross-app";

test.describe("Leave request approval loop", () => {
  test("employee submits leave; manager approves; employee sees approval", async ({ browser }) => {
    const reason = `e2e 휴가 ${Date.now().toString().slice(-7)}`;

    // 1) 직원: employee-app에서 휴가 신청
    const emp = await openAs(browser, STATE.employee, BASE_URLS.employeeApp);
    await emp.page.goto("/");
    await emp.page.getByRole("button", { name: /휴가|신청/ }).first().click();
    await emp.page.getByLabel(/사유/).fill(reason);
    await emp.page.getByRole("button", { name: /신청|제출/ }).click();
    await expect(emp.page.getByText(/신청.*완료|대기/).first()).toBeVisible({ timeout: 15_000 });

    // 2) 팀장: team-manager에서 해당 건 승인
    const mgr = await openAs(browser, STATE.manager, BASE_URLS.teamManager);
    await mgr.page.goto("/leave");
    const row = mgr.page.getByText(reason).first();
    await expect(row).toBeVisible({ timeout: 15_000 });
    await row.click();
    await mgr.page.getByRole("button", { name: /승인/ }).click();
    await expect(mgr.page.getByText(/승인.*완료|승인됨/).first()).toBeVisible({ timeout: 15_000 });

    // 3) 직원: 상태 반영 확인
    await emp.page.reload();
    await expect(emp.page.getByText(/승인/).first()).toBeVisible({ timeout: 15_000 });

    await emp.context.close();
    await mgr.context.close();
  });
});
  • [ ] Step 3: 셀렉터·날짜 필드 보정 — 실행하며 apps/employee-app/src/views/modals/leave-request.html과 apps/team-manager/src/views/tabs/leave.html의 실제 라벨(시작일/종료일 필수 등)에 맞게 보정. 날짜는 page.getByLabel(/시작일/).fill("2026-07-01") 식으로 미래 날짜 고정.

  • [ ] Step 4: Commit

bash
git add packages/test/e2e/tests/apps/leave-approval.spec.ts packages/test/e2e/helpers/cross-app.ts
git commit -m "test(e2e): add cross-app leave approval loop spec

직원 휴가 신청(employee-app)→팀장 승인(team-manager)→직원 상태
반영까지 플랫폼 핵심 루프를 검증."

Task 28: 시간외 신청→승인 spec ​

Files:

  • Create: packages/test/e2e/tests/apps/overtime-approval.spec.ts

  • [ ] Step 1: spec 작성 — Task 27과 동일 구조. 차이점만: 직원 쪽 진입은 overtime-request 모달(getByRole("button", { name: /시간외|초과근무/ })), 사유 대신 시간 필드(getByLabel(/시간/)에 "2"), 팀장 쪽은 /overtime 탭. 검증 포인트 동일(신청→목록 노출→승인→직원 반영).

ts
import { expect, test } from "@playwright/test";
import { BASE_URLS, openAs, STATE } from "../../helpers/cross-app";

test.describe("Overtime request approval loop", () => {
  test("employee submits overtime; manager approves", async ({ browser }) => {
    const memo = `e2e 시간외 ${Date.now().toString().slice(-7)}`;

    const emp = await openAs(browser, STATE.employee, BASE_URLS.employeeApp);
    await emp.page.goto("/");
    await emp.page.getByRole("button", { name: /시간외|초과근무/ }).first().click();
    await emp.page.getByLabel(/사유|내용/).fill(memo);
    await emp.page.getByRole("button", { name: /신청|제출/ }).click();
    await expect(emp.page.getByText(/완료|대기/).first()).toBeVisible({ timeout: 15_000 });

    const mgr = await openAs(browser, STATE.manager, BASE_URLS.teamManager);
    await mgr.page.goto("/overtime");
    await expect(mgr.page.getByText(memo).first()).toBeVisible({ timeout: 15_000 });
    await mgr.page.getByText(memo).first().click();
    await mgr.page.getByRole("button", { name: /승인/ }).click();
    await expect(mgr.page.getByText(/승인/).first()).toBeVisible({ timeout: 15_000 });

    await emp.context.close();
    await mgr.context.close();
  });
});
  • [ ] Step 2: 실행·보정 후 Commit
bash
git add packages/test/e2e/tests/apps/overtime-approval.spec.ts
git commit -m "test(e2e): add overtime approval loop spec

시간외 신청→승인 루프를 휴가와 동일 패턴으로 검증 (52시간 규제
직결 플로우)."

Task 29: general-affairs task/교육 spec ​

Files:

  • Create: packages/test/e2e/tests/apps/ga-task-training.spec.ts

  • [ ] Step 1: spec 작성 — PR 227/249 QA 클러스터("task count 항상 0", "API 불일치") 방어:

ts
import { expect, test } from "@playwright/test";

test.describe("General affairs tasks & mandatory training", () => {
  test("creates a recurring task and records an execution", async ({ page }) => {
    const title = `e2e task ${Date.now().toString().slice(-7)}`;

    await page.goto("/");
    await page.getByRole("button", { name: /업무|task/i }).first().click();

    await page.getByRole("button", { name: /추가|등록/ }).first().click();
    await page.getByLabel(/제목|이름/).fill(title);
    await page.getByRole("button", { name: /저장|등록/ }).click();
    await expect(page.getByText(title)).toBeVisible({ timeout: 15_000 });

    // 실행 기록 → 카운트 1 이상 (PR #227 "count always 0" 방어)
    await page.getByText(title).click();
    await page.getByRole("button", { name: /실행|완료 기록/ }).click();
    await page.getByRole("button", { name: /저장|확인/ }).click();
    await expect(page.getByText(/1\s*회|1\s*건/).first()).toBeVisible({ timeout: 15_000 });
  });

  test("creates a mandatory training and records completion", async ({ page }) => {
    const title = `e2e 교육 ${Date.now().toString().slice(-7)}`;

    await page.goto("/");
    await page.getByRole("button", { name: /교육/ }).first().click();

    await page.getByRole("button", { name: /추가|등록/ }).first().click();
    await page.getByLabel(/제목|교육명/).fill(title);
    await page.getByRole("button", { name: /저장|등록/ }).click();
    await expect(page.getByText(title)).toBeVisible({ timeout: 15_000 });

    await page.getByText(title).click();
    await page.getByRole("button", { name: /이수|완료/ }).first().click();
    await page.getByRole("button", { name: /저장|확인/ }).click();
    await expect(page.getByText(/이수|완료/).first()).toBeVisible({ timeout: 15_000 });
  });
});
  • [ ] Step 2: 실행·보정 — apps/general-affairs/src/views/modals/task-form.html/training-form.html/completion-form.html의 실제 필수 필드에 맞게 보정.

  • [ ] Step 3: Commit

bash
git add packages/test/e2e/tests/apps/ga-task-training.spec.ts
git commit -m "test(e2e): add general-affairs task and training specs

task 생성→실행 카운트(항상 0 버그 방어)와 법정의무교육
등록→이수 기록을 검증 (PR #227/#249 QA 클러스터)."

Task 30: e2e 전체 검증 + 마무리 ​

  • [ ] Step 1: 환경 기동 — make dev(API + 앱), DB 시드 상태 확인 (make db-seed 필요 시).

  • [ ] Step 2: 전체 e2e 실행

Run: pnpm test:e2e Expected: 기존 15 + 신규 5 spec green. 신규 spec의 셀렉터 불일치는 각 spec의 보정 스텝으로 복귀해 수정.

  • [ ] Step 3: 최종 검증

Run: pnpm lint:check && pnpm build Expected: 통과

  • [ ] Step 4: 최종 보고 작성 — 사용자에게: 패키지별 테스트 수, characterization으로 고정한 의심 버그 목록(toDateStr/formatDateForInput/parseNumber + 추가 발견), Wave 2/인프라/중복 통합 후속 항목.

Self-Review 체크 결과 ​

  • 스펙 커버리지: 설계 문서 §3의 21파일 → Task 2~22에 1:1 매핑 ✓. §4의 e2e 5 spec + 인프라 → Task 24~29 ✓. §3 소스 변경(export 9개, Messenger 추출) → Task 13/15/18/19/21 ✓. §6 검증 기준 → Task 23/30 ✓.
  • 플레이스홀더: 모든 코드 스텝에 실제 코드 포함 ✓. e2e 셀렉터는 특성상 런타임 보정 스텝을 명시(보정 절차가 구체적이므로 placeholder 아님) ✓.
  • 타입 일관성: ColumnDef 픽스처(makeCol)는 Task 3/7에서 동일 형태 ✓. SheetData 픽스처 헬퍼는 파서 태스크마다 로컬 정의(독립 실행 가능) ✓.