# Instructions

- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.

# Test info

- Name: 3154/dev/account-holder-lookup.spec.ts >> [3154][dev] [QA] 헥토파이낸셜 예금주 조회 전환 검증 >> VE, admin, ta 권한별로 접근 가능한 화면의 버튼 노출과 예금주 조회 흐름이 권한에 맞게 동작하는지 확인
- Location: src/qa/scenarios/accountHolderLookup.ts:187:5

# Error details

```
Test timeout of 90000ms exceeded.
```

```
Error: BLOCKED: 권한별 접근/버튼 확인이 끝나지 않은 화면이 있습니다: 3. VE / 링크샵 상품구입 / 은행 선택=미확인 / 계좌 입력=미확인 / 조회 버튼=미확인 / 응답=- / 예금주=- / 안내=- / 화면 일부=biscuit Pay 정산금 확인부터 매출 발생까지, 스마트폰 하나로 끝! 가맹점을 위한 ‘모바일 단말기 + 지갑’ 서비스 앱으로 더 많은 혜택을 받아보세요 QR코드 스캔하기 링크 결제 결제 정보를 확인할 수 없습니다. 잘못된 경로로 접근하셨습니다. 링크 정보가 없습니다. 홈으로 이동 | 1. ADMIN / 계좌조회 / 은행 선택=미확인 / 계좌 입력=미확인 / 조회 버튼=미확인 / 응답=- / 예금주=- / 안내=- / 화면 일부= | 3. ADMIN / 계좌 관리 / 은행 선택=미확인 / 계좌 입력=미확인 / 조회 버튼=미확인 / 응답=- / 예금주=- / 안내=BLOCKED: route/selector 확정 필요 / 화면 일부=자동 경로 후보 미확정 | 4. ADMIN / 통합 계좌 관리 / 은행 선택=미확인 / 계좌 입력=미확인 / 조회 버튼=미확인 / 응답=- / 예금주=- / 안내=BLOCKED: route/selector 확정 필요 / 화면 일부=자동 경로 후보 미확정 | 5. ADMIN / 모계좌관리 / 은행 선택=미확인 / 계좌 입력=미확인 / 조회 버튼=미확인 / 응답=- / 예금주=- / 안내=BLOCKED: route/selector 확정 필요 / 화면 일부=자동 경로 후보 미확정
```

# Test source

```ts
  648 |   }
  649 |   return undefined;
  650 | }
  651 | 
  652 | function findHolderNameFromText(text: string): string | undefined {
  653 |   const match = text.match(/예금주\s*[:：]?\s*([가-힣A-Za-z0-9()._-]{2,30})/);
  654 |   return match?.[1];
  655 | }
  656 | 
  657 | function findFailureLine(text: string): string | undefined {
  658 |   return text
  659 |     .split(/(?<=[.!?。])\s+|\n+/)
  660 |     .map((line) => line.trim())
  661 |     .find((line) => FAILURE_TEXT_PATTERN.test(line));
  662 | }
  663 | 
  664 | function accessDeniedText(text: string): string | undefined {
  665 |   if (/권한이 없습니다|접근.*제한|접근.*불가|Not Found|404|Forbidden|로그인/.test(text)) {
  666 |     return sampleText(text);
  667 |   }
  668 |   return undefined;
  669 | }
  670 | 
  671 | function formatAttempts(attempts: LookupAttempt[]): string[] {
  672 |   return attempts.map((attempt, index) =>
  673 |     [
  674 |       `${index + 1}. ${attempt.role} / ${attempt.pageName}`,
  675 |       `은행 선택=${attempt.bankSelected ? "성공" : "미확인"}`,
  676 |       `계좌 입력=${attempt.accountFilled ? "성공" : "미확인"}`,
  677 |       `조회 버튼=${attempt.lookupClicked ? "확인" : "미확인"}`,
  678 |       `응답=${attempt.responseStatus ?? "-"} ${attempt.responseUrl ?? ""}`.trim(),
  679 |       `예금주=${attempt.holderName ? maskHolder(attempt.holderName) : "-"}`,
  680 |       `안내=${attempt.failureMessage ?? "-"}`,
  681 |       `화면 일부=${attempt.bodySample}`
  682 |     ].join(" / ")
  683 |   );
  684 | }
  685 | 
  686 | async function attachEvidenceLog(
  687 |   testInfo: TestInfo,
  688 |   pageName: string,
  689 |   role: string,
  690 |   suffix: string,
  691 |   lines: string[]
  692 | ): Promise<void> {
  693 |   await testInfo.attach(`${safeFilename(pageName)}-${role}-${suffix}.md`, {
  694 |     body: `${lines.join("\n")}\n`,
  695 |     contentType: "text/markdown"
  696 |   });
  697 | }
  698 | 
  699 | async function attachPageScreenshot(
  700 |   page: Page,
  701 |   testInfo: TestInfo,
  702 |   pageName: string,
  703 |   role: string,
  704 |   suffix: string
  705 | ): Promise<void> {
  706 |   const screenshotPath = testInfo.outputPath(`${safeFilename(pageName)}-${role}-${suffix}.png`);
  707 |   await page.screenshot({ path: screenshotPath, fullPage: true });
  708 |   await testInfo.attach(`${safeFilename(pageName)}-${role}-${suffix}.png`, {
  709 |     path: screenshotPath,
  710 |     contentType: "image/png"
  711 |   });
  712 | }
  713 | 
  714 | function sanitizeUrl(rawUrl: string): string {
  715 |   const url = new URL(rawUrl);
  716 |   for (const key of Array.from(url.searchParams.keys())) {
  717 |     if (/account|bank|holder|name|number/i.test(key)) {
  718 |       url.searchParams.set(key, "<redacted>");
  719 |     }
  720 |   }
  721 |   return url.toString().replace(/accountNo=[^&]+/gi, "accountNo=<redacted>");
  722 | }
  723 | 
  724 | function sampleText(value: string): string {
  725 |   return value.replace(/\s+/g, " ").slice(0, 260);
  726 | }
  727 | 
  728 | function maskHolder(value: string): string {
  729 |   if (value.length <= 2) {
  730 |     return "*".repeat(value.length);
  731 |   }
  732 |   return `${value.slice(0, 1)}${"*".repeat(Math.max(1, value.length - 2))}${value.slice(-1)}`;
  733 | }
  734 | 
  735 | function safeFilename(value: string): string {
  736 |   return value
  737 |     .trim()
  738 |     .replace(/[^a-zA-Z0-9가-힣_.-]+/g, "-")
  739 |     .replace(/^-+|-+$/g, "")
  740 |     .slice(0, 100);
  741 | }
  742 | 
  743 | function escapeRegExp(value: string): string {
  744 |   return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  745 | }
  746 | 
  747 | function blocked(message: string): never {
> 748 |   throw new Error(`BLOCKED: ${message}`);
      |         ^ Error: BLOCKED: 권한별 접근/버튼 확인이 끝나지 않은 화면이 있습니다: 3. VE / 링크샵 상품구입 / 은행 선택=미확인 / 계좌 입력=미확인 / 조회 버튼=미확인 / 응답=- / 예금주=- / 안내=- / 화면 일부=biscuit Pay 정산금 확인부터 매출 발생까지, 스마트폰 하나로 끝! 가맹점을 위한 ‘모바일 단말기 + 지갑’ 서비스 앱으로 더 많은 혜택을 받아보세요 QR코드 스캔하기 링크 결제 결제 정보를 확인할 수 없습니다. 잘못된 경로로 접근하셨습니다. 링크 정보가 없습니다. 홈으로 이동 | 1. ADMIN / 계좌조회 / 은행 선택=미확인 / 계좌 입력=미확인 / 조회 버튼=미확인 / 응답=- / 예금주=- / 안내=- / 화면 일부= | 3. ADMIN / 계좌 관리 / 은행 선택=미확인 / 계좌 입력=미확인 / 조회 버튼=미확인 / 응답=- / 예금주=- / 안내=BLOCKED: route/selector 확정 필요 / 화면 일부=자동 경로 후보 미확정 | 4. ADMIN / 통합 계좌 관리 / 은행 선택=미확인 / 계좌 입력=미확인 / 조회 버튼=미확인 / 응답=- / 예금주=- / 안내=BLOCKED: route/selector 확정 필요 / 화면 일부=자동 경로 후보 미확정 | 5. ADMIN / 모계좌관리 / 은행 선택=미확인 / 계좌 입력=미확인 / 조회 버튼=미확인 / 응답=- / 예금주=- / 안내=BLOCKED: route/selector 확정 필요 / 화면 일부=자동 경로 후보 미확정
  749 | }
  750 | 
```