Merge remote-tracking branch 'upstream/main'

This commit is contained in:
kjs
2026-03-11 19:11:35 +09:00
652 changed files with 33132 additions and 14759 deletions

151
scripts/run-e2e-test.js Normal file
View File

@@ -0,0 +1,151 @@
/**
* 결재 템플릿 관리 및 결재함 E2E 테스트
* 실행: node scripts/run-e2e-test.js
*/
const { chromium } = require("playwright");
const BASE_URL = "http://localhost:9771";
const SCREENSHOT_DIR = ".agent-pipeline/browser-tests";
async function runTest() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1280, height: 720 },
});
const page = await context.newPage();
const results = [];
let allPassed = true;
function pass(name) {
results.push({ name, status: "PASS" });
console.log(`PASS: ${name}`);
}
function fail(name, reason) {
results.push({ name, status: "FAIL", reason });
console.log(`FAIL: ${name} - ${reason}`);
allPassed = false;
}
try {
// 로그인
await page.goto(`${BASE_URL}/login`);
await page.waitForLoadState("networkidle");
await page.getByPlaceholder("사용자 ID를 입력하세요").fill("wace");
await page.getByPlaceholder("비밀번호를 입력하세요").fill("qlalfqjsgh11");
await Promise.all([
page.waitForURL(url => !url.toString().includes("/login"), { timeout: 30000 }),
page.getByRole("button", { name: "로그인" }).click(),
]);
await page.waitForLoadState("networkidle");
pass("로그인");
// =========================================================
// 결재 템플릿 관리 페이지
// =========================================================
await page.goto(`${BASE_URL}/admin/approvalTemplate`);
await page.waitForLoadState("domcontentloaded");
await page.waitForTimeout(3000);
// 1. "결재 템플릿 관리" 제목 확인
try {
await page.locator("h1").filter({ hasText: "결재 템플릿 관리" }).waitFor({ timeout: 10000 });
pass("결재 템플릿 관리 - 제목 확인");
} catch (e) {
fail("결재 템플릿 관리 - 제목 확인", e.message);
}
// 2. "신규 등록" 버튼 확인
try {
await page.getByRole("button", { name: "신규 등록" }).waitFor({ timeout: 10000 });
pass("결재 템플릿 관리 - 신규 등록 버튼 확인");
} catch (e) {
fail("결재 템플릿 관리 - 신규 등록 버튼 확인", e.message);
}
// 3. 검색 입력란 확인
try {
await page.getByPlaceholder("템플릿명 또는 설명 검색...").waitFor({ timeout: 10000 });
pass("결재 템플릿 관리 - 검색 입력란 확인");
} catch (e) {
// placeholder가 다를 수 있으므로 input으로 재시도
try {
await page.locator("input[type='text']").first().waitFor({ timeout: 5000 });
pass("결재 템플릿 관리 - 검색 입력란 확인 (input fallback)");
} catch (e2) {
fail("결재 템플릿 관리 - 검색 입력란 확인", e.message);
}
}
// 4. JS 에러 오버레이 확인
const hasError1 = await page.locator('[id="__next"] .nextjs-container-errors-body').isVisible().catch(() => false);
if (!hasError1) {
pass("결재 템플릿 관리 - JS 에러 없음");
} else {
fail("결재 템플릿 관리 - JS 에러 없음", "에러 오버레이 감지됨");
}
await page.screenshot({ path: `${SCREENSHOT_DIR}/result-template.png`, fullPage: true });
// =========================================================
// 결재함 페이지
// =========================================================
await page.goto(`${BASE_URL}/admin/approvalBox`);
await page.waitForLoadState("domcontentloaded");
await page.waitForTimeout(3000);
// 1. 탭 확인
try {
await page.getByRole("tab", { name: /수신함/ }).waitFor({ timeout: 10000 });
pass("결재함 - 수신함 탭 확인");
} catch (e) {
fail("결재함 - 수신함 탭 확인", e.message);
}
try {
await page.getByRole("tab", { name: /상신함/ }).waitFor({ timeout: 10000 });
pass("결재함 - 상신함 탭 확인");
} catch (e) {
fail("결재함 - 상신함 탭 확인", e.message);
}
// 2. JS 에러 오버레이 확인
const hasError2 = await page.locator('[id="__next"] .nextjs-container-errors-body').isVisible().catch(() => false);
if (!hasError2) {
pass("결재함 - JS 에러 없음");
} else {
fail("결재함 - JS 에러 없음", "에러 오버레이 감지됨");
}
await page.screenshot({ path: `${SCREENSHOT_DIR}/result-box.png`, fullPage: true });
} catch (e) {
fail("테스트 실행", e.message);
} finally {
await browser.close();
}
console.log("\n=== 테스트 결과 ===");
results.forEach(r => {
const status = r.status === "PASS" ? "✓" : "✗";
console.log(`${status} ${r.name}${r.reason ? `: ${r.reason}` : ""}`);
});
if (allPassed) {
console.log("\nBROWSER_TEST_RESULT: PASS");
process.exit(0);
} else {
const failed = results.filter(r => r.status === "FAIL").map(r => r.name).join(", ");
console.log(`\nBROWSER_TEST_RESULT: FAIL - ${failed}`);
process.exit(1);
}
}
runTest().catch(e => {
console.error("치명적 오류:", e);
console.log("BROWSER_TEST_RESULT: FAIL - 치명적 오류: " + e.message);
process.exit(1);
});

View File

@@ -0,0 +1,139 @@
/**
* 옵션설정 (Option Settings) 페이지 반응형 동작 테스트
* - V2CategoryManagerComponent + ResponsiveGridRenderer
* - 화면: http://localhost:9771/screens/1421
*
* 실행: npx tsx scripts/test-option-settings-responsive.ts
*/
import { chromium } from "playwright";
import * as path from "path";
import * as fs from "fs";
const BASE_URL = "http://localhost:9771";
const API_URL = "http://localhost:8080/api";
const PAGE_URL = `${BASE_URL}/screens/1421`;
const CREDENTIALS = [
{ userId: "SUPER", password: "1234" },
{ userId: "wace", password: "qlalfqjsgh11" },
];
const OUTPUT_DIR = path.join(__dirname, "../test-output/option-settings-responsive");
async function loginViaApi(): Promise<string> {
for (const cred of CREDENTIALS) {
const res = await fetch(`${API_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: cred.userId, password: cred.password }),
});
const data = await res.json();
if (data.success && data.data?.token) {
console.log(` Using credentials: ${cred.userId}`);
return data.data.token;
}
}
throw new Error("Login failed with all credentials");
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const token = await loginViaApi();
console.log("1. Logged in via API");
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1400, height: 900 },
});
const page = await context.newPage();
const consoleErrors: string[] = [];
const reactHookErrors: string[] = [];
page.on("console", (msg) => {
const type = msg.type();
const text = msg.text();
if (type === "error") {
consoleErrors.push(text);
if (text.includes("order of Hooks") || text.includes("React has detected")) {
reactHookErrors.push(text);
}
}
});
try {
console.log("2. Loading login page to inject token...");
await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded", timeout: 15000 });
await page.evaluate((t: string) => {
localStorage.setItem("authToken", t);
document.cookie = `authToken=${t}; path=/; max-age=86400; SameSite=Lax`;
}, token);
console.log("3. Navigating to Option Settings page (screens/1421)...");
await page.goto(PAGE_URL, { waitUntil: "domcontentloaded", timeout: 15000 });
await page.waitForTimeout(5000);
const report: string[] = [];
const widths = [
{ w: 1400, name: "1-desktop-1400px" },
{ w: 1100, name: "2-tablet-1100px" },
{ w: 900, name: "3-tablet-900px" },
{ w: 600, name: "4-mobile-600px" },
{ w: 1400, name: "5-desktop-1400px-restored" },
];
for (const { w, name } of widths) {
console.log(`\nResizing to ${w}px...`);
await page.setViewportSize({ width: w, height: w === 600 ? 812 : 900 });
await page.waitForTimeout(1500);
const filePath = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: filePath, fullPage: false });
console.log(` Saved: ${filePath}`);
const hasCategoryColumn = (await page.locator('text=카테고리 컬럼').count()) > 0;
const hasUseStatus = (await page.locator('text=사용여부').count()) > 0;
const hasVerticalStack = (await page.locator('button:has-text("목록")').count()) > 0;
const hasLeftRightSplit = (await page.locator('[class*="cursor-col-resize"]').count()) > 0;
report.push(`[${w}px] Category column: ${hasCategoryColumn}, Use status: ${hasUseStatus}, Vertical: ${hasVerticalStack}, Split: ${hasLeftRightSplit}`);
}
console.log("\n" + "=".repeat(60));
console.log("OPTION SETTINGS RESPONSIVE TEST REPORT");
console.log("=".repeat(60));
report.forEach((r) => console.log(r));
if (consoleErrors.length > 0) {
console.log("\n--- Console Errors ---");
consoleErrors.slice(0, 10).forEach((e) => console.log(" ", e));
}
if (reactHookErrors.length > 0) {
console.log("\n--- React Hook Errors (order of Hooks) ---");
reactHookErrors.forEach((e) => console.log(" ", e));
}
fs.writeFileSync(
path.join(OUTPUT_DIR, "report.txt"),
[
"OPTION SETTINGS RESPONSIVE TEST REPORT",
"=".repeat(50),
...report,
"",
"Console errors: " + consoleErrors.length,
"React Hook errors: " + reactHookErrors.length,
...reactHookErrors.map((e) => " " + e),
].join("\n")
);
console.log("\nScreenshots saved to:", OUTPUT_DIR);
} catch (e) {
console.error("Error:", e);
throw e;
} finally {
await browser.close();
}
}
main();

View File

@@ -0,0 +1,134 @@
/**
* ResponsiveSplitPanel 반응형 동작 테스트
* - Desktop (>= 1280px): 좌우 분할 + 리사이저
* - Tablet (768-1279px): 좌측 패널 자동 접힘, 아이콘 버튼만 표시
* - Mobile (< 768px): 세로 스택 + 접기/펼치기 헤더
*
* 실행: npx tsx scripts/test-responsive-split-panel.ts
*/
import { chromium } from "playwright";
import * as path from "path";
import * as fs from "fs";
const BASE_URL = "http://localhost:9771";
const API_URL = "http://localhost:8080/api";
const TABLE_MNG_PATH = "/admin/systemMng/tableMngList";
const USER_ID = "wace";
const PASSWORD = "qlalfqjsgh11";
const OUTPUT_DIR = path.join(__dirname, "../test-output/responsive-split-panel");
async function loginViaApi(): Promise<string> {
const res = await fetch(`${API_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: USER_ID, password: PASSWORD }),
});
const data = await res.json();
if (!data.success || !data.data?.token) throw new Error("Login failed: " + (data.message || "no token"));
return data.data.token;
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const token = await loginViaApi();
console.log("1. Logged in via API");
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1400, height: 900 },
});
const page = await context.newPage();
try {
// 공개 페이지에서 토큰 주입 후 테이블 페이지로 이동
console.log("2. Loading login page to inject token...");
await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded", timeout: 15000 });
await page.evaluate((t: string) => {
localStorage.setItem("authToken", t);
document.cookie = `authToken=${t}; path=/; max-age=86400; SameSite=Lax`;
}, token);
console.log("3. Navigating to table management page...");
await page.goto(`${BASE_URL}${TABLE_MNG_PATH}`, { waitUntil: "domcontentloaded", timeout: 15000 });
await page.waitForTimeout(3000);
const report: string[] = [];
// --- 3. Desktop 1400px (default wide) ---
console.log("\n3. Desktop 1400px - taking screenshot...");
await page.setViewportSize({ width: 1400, height: 900 });
await page.waitForTimeout(1000);
const desktopPath = path.join(OUTPUT_DIR, "1-desktop-1400px.png");
await page.screenshot({ path: desktopPath, fullPage: false });
console.log(` Saved: ${desktopPath}`);
const at1400 = {
resizer: (await page.locator('[class*="cursor-col-resize"]').count()) > 0,
leftPanelVisible: (await page.locator('text=테이블 검색').count()) > 0 || (await page.locator('input[placeholder*="검색"]').count()) > 0,
iconButtonOnly: (await page.locator('button[title*="열기"]').count()) > 0 && !(await page.locator('input[placeholder*="검색"]').isVisible()),
};
report.push(`[1400px] Resizer: ${at1400.resizer}, Left panel visible: ${at1400.leftPanelVisible}, Icon-only: ${at1400.iconButtonOnly}`);
// --- 4. Tablet 1000px ---
console.log("\n4. Tablet 1000px - resizing and taking screenshot...");
await page.setViewportSize({ width: 1000, height: 900 });
await page.waitForTimeout(1500);
const tabletPath = path.join(OUTPUT_DIR, "2-tablet-1000px.png");
await page.screenshot({ path: tabletPath, fullPage: false });
console.log(` Saved: ${tabletPath}`);
const at1000 = {
resizer: (await page.locator('[class*="cursor-col-resize"]').count()) > 0,
leftPanelVisible: (await page.locator('input[placeholder*="검색"]').isVisible()),
iconButtonOnly: (await page.locator('button[title*="열기"]').count()) > 0,
verticalStack: (await page.locator('button:has-text("테이블 목록")').count()) > 0,
};
report.push(`[1000px] Resizer: ${at1000.resizer}, Left panel visible: ${at1000.leftPanelVisible}, Icon-only: ${at1000.iconButtonOnly}, Vertical stack: ${at1000.verticalStack}`);
// --- 5. Mobile 600px ---
console.log("\n5. Mobile 600px - resizing and taking screenshot...");
await page.setViewportSize({ width: 600, height: 812 });
await page.waitForTimeout(1500);
const mobilePath = path.join(OUTPUT_DIR, "3-mobile-600px.png");
await page.screenshot({ path: mobilePath, fullPage: false });
console.log(` Saved: ${mobilePath}`);
const at600 = {
collapsibleHeader: (await page.locator('button:has-text("테이블 목록")').count()) > 0,
verticalStack: (await page.locator('button:has-text("테이블 목록")').count()) > 0,
leftPanelVisible: (await page.locator('input[placeholder*="검색"]').isVisible()),
};
report.push(`[600px] Collapsible header: ${at600.collapsibleHeader}, Vertical stack: ${at600.verticalStack}, Left panel visible: ${at600.leftPanelVisible}`);
// --- 6. Back to Desktop 1400px ---
console.log("\n6. Back to Desktop 1400px - resizing and taking screenshot...");
await page.setViewportSize({ width: 1400, height: 900 });
await page.waitForTimeout(1500);
const desktopAgainPath = path.join(OUTPUT_DIR, "4-desktop-1400px-again.png");
await page.screenshot({ path: desktopAgainPath, fullPage: false });
console.log(` Saved: ${desktopAgainPath}`);
const at1400Again = {
resizer: (await page.locator('[class*="cursor-col-resize"]').count()) > 0,
leftPanelVisible: (await page.locator('input[placeholder*="검색"]').isVisible()),
};
report.push(`[1400px again] Resizer: ${at1400Again.resizer}, Left panel visible: ${at1400Again.leftPanelVisible}`);
// --- Report ---
console.log("\n" + "=".repeat(60));
console.log("RESPONSIVE LAYOUT TEST REPORT");
console.log("=".repeat(60));
report.forEach((r) => console.log(r));
console.log("\nScreenshots saved to:", OUTPUT_DIR);
} catch (e) {
console.error("Error:", e);
throw e;
} finally {
await browser.close();
}
}
main();