프론트엔드 테스트 도입 구현 계획
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 주석 규칙)
공통 규약 (모든 태스크 적용):
- Characterization 프로토콜: 단언값이 불확실한 테스트는 ① 최선의 추정값으로 작성 → ② 실행 → ③ 실패 시 실제 출력으로 단언값을 보정하고
// characterization: locks current behavior, not proven-correct output주석을 단다. 동작이 버그로 보이면 고치지 말고 최종 보고에 기록한다. - 커밋 메시지는 Conventional Commits(영문 제목), 본문에 한글 설명. 푸시는 하지 않는다.
- 각 태스크 완료 시 해당 패키지에서
pnpm --filter <pkg> test가 green이어야 커밋한다. - 작업 브랜치:
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: 브랜치 생성
git switch docs/frontend-testing-design && git switch -c test/frontend-wave1- [ ] Step 2: 8개 package.json 각각에 scripts와 devDependency 추가
각 파일의 "scripts"에 두 줄 추가 (기존 키 유지):
"test": "vitest run",
"test:watch": "vitest""devDependencies"에 추가 (다른 패키지와 동일 버전대):
"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
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: 테스트 작성
import { describe, expect, it } from "vitest";
import { escapeHtml } from "./escape-html";
describe("escapeHtml", () => {
it("should escape all five special characters", () => {
expect(escapeHtml(`&<>"'`)).toBe("&<>"'");
});
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(
"<script>alert("x")</script>",
);
});
it("should escape repeated specials in long strings", () => {
expect(escapeHtml("a&b&c")).toBe("a&b&c");
});
});- [ ] Step 2: 실행 확인
Run: pnpm --filter @hereby/hr-system test -- src/sheet/escape-html.test.ts Expected: 6 passed
- [ ] Step 3: Commit
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: 테스트 작성
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
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: 테스트 작성
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
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: 테스트 작성
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
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: 테스트 작성
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
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: 테스트 작성
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
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: 테스트 작성
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
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: 테스트 작성
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
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: 테스트 작성
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
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: 테스트 작성
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
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: 테스트 작성
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
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: 테스트 작성
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
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: 테스트 작성
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
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: 테스트 작성
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.tsimport 시 모듈 최상단 사이드이펙트(다른 import)가 실행됨. SDK/Alpine import로 node에서 죽으면, 세 헬퍼를apps/team-manager/src/app-helpers.ts신규 파일로 이동(move)하고app.ts가 거기서 import하도록 변경 후 테스트도 신규 파일을 import. (이 경우 커밋 메시지에 이동 사실 명시.)[ ] Step 4: Commit
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: 테스트 작성
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
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: 테스트 작성
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
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: 테스트 작성
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.tsimport가 사이드이펙트로 실패하면 세 함수를app-helpers.ts로 이동.[ ] Step 4: Commit
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: 테스트 작성
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
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: 테스트 작성
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
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.tsModify:
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의 매핑 로직을 그대로 이동(동작 무변경):
/**
* 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에서는:
import { buildConversationItem } from "./messenger-helpers";
// ...
this.conversations = items.map((c: any) => buildConversationItem(c, this.currentUserId));- [ ] Step 2: 테스트 작성
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
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: 테스트 작성
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
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 (각각):
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 testExpected: 전부 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 끝에 섹션 추가:
## 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
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)
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흐름(fetchauth.login→page.goto(앱)→ localStorage에hereby_access_token/hereby_refresh_token저장 →context.storageState({path})) 재사용:
// 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 유지, 추가:
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
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 실행 화면에서 확인하되 초안:
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
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 작성
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
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.tsCreate:
packages/test/e2e/helpers/cross-app.ts[ ] Step 1: 크로스앱 헬퍼 (
cross-app.ts) — 페르소나별 context 생성:
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 작성
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
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탭. 검증 포인트 동일(신청→목록 노출→승인→직원 반영).
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
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 불일치") 방어:
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
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픽스처 헬퍼는 파서 태스크마다 로컬 정의(독립 실행 가능) ✓.