# 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] role >> 스토어(VE) 링크샵 상품구입 화면에서 예금주 조회 성공 후 다음 단계 진행이 정상 동작하는지 확인
- Location: src/qa/scenarios/accountHolderLookup.ts:73:7

# Error details

```
Error: BLOCKED: 링크샵/큐브 링크샵 예금주 조회 화면 URL이 필요합니다. QA_3154_LINK_SHOP_URL 또는 QA_3154_CUBE_LINK_SHOP_URL을 .env.local에 설정하세요.
```

# 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: 링크샵/큐브 링크샵 예금주 조회 화면 URL이 필요합니다. QA_3154_LINK_SHOP_URL 또는 QA_3154_CUBE_LINK_SHOP_URL을 .env.local에 설정하세요.
  749 | }
  750 |
```