# 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: 4147/dev/privacy-masking-regression.spec.ts >> [4147][dev] [QA] 개인정보 마스킹 및 원문 조회 역할별 회귀 검증 >> [CO] 총판관리 / Excel 기본 마스킹 및 PIN 원문 다운로드
- Location: src/qa/scenarios/privacyMaskingRegression.ts:116:11

# Error details

```
Error: 문서보관함에 tenant-id-redacted 행이 표시되어야 합니다.

expect(locator).toBeVisible() failed

Locator: locator('[data-route-keep-alive-active=\'true\']').last().locator('[data-id=\'tenant-id-redacted\']').first()
Expected: visible
Timeout: 10000ms
Error: element(s) not found

Call log:
  - 문서보관함에 tenant-id-redacted 행이 표시되어야 합니다. with timeout 10000ms
  - waiting for locator('[data-route-keep-alive-active=\'true\']').last().locator('[data-id=\'tenant-id-redacted\']').first()

```

```yaml
- button "공지 배너 닫기"
- text: 테스트
- link "logo 빅스페이먼츠":
  - /url: /
  - img "logo"
  - text: 빅스페이먼츠
- button "사이드바 접기":
  - img
  - img "메뉴 열기"
- navigation "열린 페이지 탭":
  - img
  - text: 홈
  - button "홈" [disabled]:
    - img
    - text: 홈
    - img
  - button "새 탭 열기":
    - img
  - status
- button "다운로드 이력":
  - img
- img
- text: 159:29:24
- button "연장"
- button "secret-redacted"
- button "범례 범례 설명":
  - text: 범례
  - img
  - img
- text: 법인 영업점
- list:
  - listitem:
    - img
    - text: 홈
  - listitem:
    - img
    - text: 거래관리
    - button
    - list:
      - button "거래내역 새 탭으로 열기":
        - img
      - link "거래내역":
        - /url: /trade-history-v2?tenantId=redacted
      - button "상계처리내역 새 탭으로 열기":
        - img
      - link "상계처리내역":
        - /url: /offset-management?tenantId=redacted
      - button "수기결제내역 새 탭으로 열기":
        - img
      - link "수기결제내역":
        - /url: /log-management/manual-log?tenantId=redacted
      - button "거래로그 새 탭으로 열기":
        - img
      - link "거래로그":
        - /url: /payment-history?tenantId=redacted
      - button "PG거래대사 새 탭으로 열기":
        - img
      - link "PG거래대사":
        - /url: /master-accounting/pg-reconciliation?tenantId=redacted
      - button "영업점 기준 수익 집계 새 탭으로 열기":
        - img
      - link "영업점 기준 수익 집계":
        - /url: /branch-office-aggregations?tenantId=redacted
      - button "링크발송내역 새 탭으로 열기":
        - img
      - link "링크발송내역":
        - /url: /link-payment?tenantId=redacted
      - button "과세자료 새 탭으로 열기":
        - img
      - link "과세자료":
        - /url: /tax-reports/taxable-materials?tenantId=redacted
      - button "부가세 신고자료 새 탭으로 열기":
        - img
      - link "부가세 신고자료":
        - /url: /tax-reports/vat-reports?tenantId=redacted
      - button "배송정보조회(달큐) 새 탭으로 열기":
        - img
      - link "배송정보조회(달큐)":
        - /url: /payment-deliveries?tenantId=redacted
  - listitem:
    - img
    - text: 계좌관리
    - button
    - list:
      - button "펌뱅킹 등록 새 탭으로 열기":
        - img
      - link "펌뱅킹 등록":
        - /url: /account/account-inquiry?tenantId=redacted
      - button "운영계좌 관리 새 탭으로 열기":
        - img
      - link "운영계좌 관리":
        - /url: /master-accounting/disbursement-accounts?tenantId=redacted
      - button "운영계좌 입출금 내역 새 탭으로 열기":
        - img
      - link "운영계좌 입출금 내역":
        - /url: /master-accounting/disbursement-accounts-inout-history?tenantId=redacted
      - button "법인계좌관리 새 탭으로 열기":
        - img
      - link "법인계좌관리":
        - /url: /master-accounting/corporation-accounts?tenantId=redacted
      - button "법인계좌 입출금 내역 새 탭으로 열기":
        - img
      - link "법인계좌 입출금 내역":
        - /url: /master-accounting/corporation-accounts-inout-history?tenantId=redacted
      - button "입금확인 새 탭으로 열기":
        - img
      - link "입금확인":
        - /url: /account/deposit-confirmation?tenantId=redacted
      - button "이체내역 새 탭으로 열기":
        - img
      - link "이체내역":
        - /url: /account/transfer-details?tenantId=redacted
      - button "영업라인 관리 새 탭으로 열기":
        - img
      - link "영업라인 관리":
        - /url: /master-accounting/salesline-management?tenantId=redacted
      - button "이체하기 새 탭으로 열기":
        - img
      - link "이체하기":
        - /url: /account/remittance-manage?tenantId=redacted
      - button "이체하기(렌탈페이) 새 탭으로 열기":
        - img
      - link "이체하기(렌탈페이)":
        - /url: /account/remittance-manage-rentalpay?tenantId=redacted
  - listitem:
    - img
    - text: 지갑관리
    - button
    - list:
      - button "지갑관리 새 탭으로 열기":
        - img
      - link "지갑관리":
        - /url: /master-accounting/wallet-management?tenantId=redacted
      - button "지갑입출금내역 새 탭으로 열기":
        - img
      - link "지갑입출금내역":
        - /url: /master-accounting/wallet-inout-history?tenantId=redacted
  - listitem:
    - img
    - text: 정산관리
    - button
    - list:
      - button "PG정산내역 새 탭으로 열기":
        - img
      - link "PG정산내역":
        - /url: /settlement-history/pg-settlement-details?tenantId=redacted
      - button "정산내역 새 탭으로 열기":
        - img
      - link "정산내역":
        - /url: /settlement-history/settlement-details?tenantId=redacted
      - button "채권관리 새 탭으로 열기":
        - img
      - link "채권관리":
        - /url: /bond-management?tenantId=redacted
      - button "지갑 입출금 내역 새 탭으로 열기":
        - img
      - link "지갑 입출금 내역":
        - /url: /wallet-history?tenantId=redacted
      - button "선정산 한도 조회 새 탭으로 열기":
        - img
      - link "선정산 한도 조회":
        - /url: /tenant-early-settlement-limit-inquiry?tenantId=redacted
  - listitem:
    - img
    - text: 매출집계
    - button
    - list:
      - button "영업점매출집계 새 탭으로 열기":
        - img
      - link "영업점매출집계":
        - /url: /branch-profit-aggregate?tenantId=redacted
  - listitem:
    - img
    - text: 업체관리
    - button
    - list:
      - text: 총판관리
      - list:
        - listitem:
          - button "총판관리 새 탭으로 열기":
            - img
          - link "총판관리":
            - /url: /business/corporation?tenantId=redacted
        - listitem:
          - button "계약관리 새 탭으로 열기":
            - img
          - link "계약관리":
            - /url: /business/pg-contract-manage?tenantId=redacted
        - listitem:
          - button "부서별 운영상태관리 새 탭으로 열기":
            - img
          - link "부서별 운영상태관리":
            - /url: /saleline-status-control?tenantId=redacted
      - text: 영업점관리
      - list:
        - listitem:
          - button "영업점관리 새 탭으로 열기":
            - img
          - link "영업점관리":
            - /url: /business/branch?tenantId=redacted
        - listitem:
          - button "영업라인조회 새 탭으로 열기":
            - img
          - link "영업라인조회":
            - /url: /business/salesline-management?tenantId=redacted
        - listitem:
          - button "영업라인일괄적용 새 탭으로 열기":
            - img
          - link "영업라인일괄적용":
            - /url: /business/salesline-preset?tenantId=redacted
      - text: 가맹점관리
      - list:
        - listitem:
          - button "가맹점관리 새 탭으로 열기":
            - img
          - link "가맹점관리":
            - /url: /business/vendor-manage?tenantId=redacted
        - listitem:
          - button "TID/MID관리 새 탭으로 열기":
            - img
          - link "TID/MID관리":
            - /url: /business/pg-mid-manage?tenantId=redacted
      - text: 사용자관리
      - list:
        - listitem:
          - button "사용자프리셋 새 탭으로 열기":
            - img
          - link "사용자프리셋":
            - /url: /business/member-preset?tenantId=redacted
        - listitem:
          - button "사용자 새 탭으로 열기":
            - img
          - link "사용자":
            - /url: /business/member?tenantId=redacted
      - button "사용자 보호 정책 새 탭으로 열기":
        - img
      - link "사용자 보호 정책":
        - /url: /business/user-protection?tenantId=redacted
  - listitem:
    - img
    - text: 법인
    - button
    - list:
      - button "법인관리 새 탭으로 열기":
        - img
      - link "법인관리":
        - /url: /sales-agency/agency?tenantId=redacted
      - button "법인계정과목 새 탭으로 열기":
        - img
      - link "법인계정과목":
        - /url: /sales-agency/account-subject-agency?tenantId=redacted
      - button "영업자 새 탭으로 열기":
        - img
      - link "영업자":
        - /url: /sales-agency/sales-person?tenantId=redacted
  - listitem:
    - img
    - text: 비스킷 카드관리
    - button
    - list:
      - button "카드관리 새 탭으로 열기":
        - img
      - link "카드관리":
        - /url: /admin/biscuit-card/card-manage
      - button "사용내역 새 탭으로 열기":
        - img
      - link "사용내역":
        - /url: /admin/biscuit-card/usage-history
      - button "연결계좌관리 새 탭으로 열기":
        - img
      - link "연결계좌관리":
        - /url: /admin/biscuit-card/linked-account-setting
      - button "카드통계 새 탭으로 열기":
        - img
      - link "카드통계":
        - /url: /admin/statistics/biscuit-card-corporation
      - button "비스킷카드신청관리 새 탭으로 열기":
        - img
      - link "비스킷카드신청관리":
        - /url: /admin/biscuit-card/application-manage
      - button "법인별 수수료 관리 새 탭으로 열기":
        - img
      - link "법인별 수수료 관리":
        - /url: /admin/biscuit-card/corporation-commission
- main:
  - heading "세상의 모든 거래와 결제" [level=1]
  - paragraph: 안전하고 신뢰할 수 있는 결제 정산 시스템으로 비즈니스의 모든 거래를 책임집니다
  - text: 🌍
  - heading "글로벌 서비스" [level=3]
  - paragraph: 세상의 모든 거래를 연결하는 플랫폼입니다
  - text: 🔒
  - heading "안전한 결제" [level=4]
  - paragraph: 최고 수준의 보안 시스템으로 모든 거래를 보호합니다
  - text: ⚡
  - heading "실시간 정산" [level=4]
  - paragraph: 빠르고 정확한 실시간 정산 시스템을 제공합니다
  - text: 🤝
  - heading "신뢰있는 거래" [level=4]
  - paragraph: 투명하고 신뢰할 수 있는 거래 환경을 만듭니다
  - text: 🌍
  - heading "글로벌 서비스" [level=4]
  - paragraph: 세상의 모든 거래를 연결하는 플랫폼입니다
  - text: 💻
  - heading "빅스페이먼츠의 메시지" [level=3]
  - paragraph: 모든 거래에는 신뢰가 필요합니다. 우리는 최고의 기술력과 끊임없는 혁신으로 고객의 비즈니스가 안전하고 효율적으로 성장할 수 있도록 돕고 있습니다. 우리와 함께 더 나은 미래를 만들어가세요.
  - text: 빅스페이먼츠 일동
```

# Test source

```ts
  437 |   const requestListener = (request: { method: () => string; url: () => string }): void => {
  438 |     if (/excel|masking|authentication/i.test(request.url())) {
  439 |       observedRequests.push(`${request.method()} ${new URL(request.url()).pathname}`);
  440 |     }
  441 |   };
  442 |   const listener = (response: Response): void => {
  443 |     const request = response.request();
  444 |     if (request.method() !== "POST") return;
  445 |     if (!/\/api\/v1\/excel(?:[/?]|$)/.test(response.url())) return;
  446 |     if (/export-authorizations/.test(response.url())) return;
  447 |     responses.push(response);
  448 |   };
  449 |   page.on("request", requestListener);
  450 |   page.on("response", listener);
  451 |   try {
  452 |     const downloadButton = await firstVisible(root.getByRole("button", { name: /다운로드/ }));
  453 |     if (!downloadButton) throw new Error(`${definition.name} Excel 다운로드 실행 버튼을 찾지 못했습니다.`);
  454 |     await domClick(downloadButton);
  455 |
  456 |     if (unmasked) {
  457 |       const pin = await firstVisible(page.locator("input[placeholder*='PIN'], input[name='pin'], input[type='password']"), 10_000);
  458 |       if (!pin) throw new Error(`${definition.name} 원문 Excel 선택 후 PIN 인증창이 표시되지 않았습니다.`);
  459 |       await pin.fill(runtime.twoFactorCode ?? "secret-redacted");
  460 |       const pinDialog = pin.locator("xpath=ancestor::*[@role='dialog'][1]");
  461 |       const pinRoot = (await pinDialog.count()) > 0 ? pinDialog : page.locator("body");
  462 |       const confirm = await firstVisible(pinRoot.locator("button:visible").filter({ hasText: /^(확인|인증)$/ }), 5_000);
  463 |       if (!confirm) {
  464 |         const buttonNames = (await pinRoot.locator("button:visible").allTextContents()).map(normalize).filter(Boolean);
  465 |         throw new Error(`${definition.name} 원문 Excel PIN 확인 버튼을 찾지 못했습니다. 표시 버튼=${buttonNames.join(" | ")}`);
  466 |       }
  467 |       await confirm.click({ force: true });
  468 |     }
  469 |
  470 |     const response = await waitForCollectedExcelResponse(page, responses, observedRequests);
  471 |     const json = await response.json().catch(() => undefined);
  472 |     const id = findDocumentId(json);
  473 |     if (!id) throw new Error(`${definition.name} Excel 생성 응답에서 문서 ID를 찾지 못했습니다.`);
  474 |     await closeVisibleDialogs(page);
  475 |     return { id, mode: unmasked ? "UNMASKED" : "MASKED" };
  476 |   } finally {
  477 |     page.off("request", requestListener);
  478 |     page.off("response", listener);
  479 |   }
  480 | }
  481 |
  482 | async function findExcelDialog(page: Page, timeoutMs = 1_000): Promise<Locator | undefined> {
  483 |   const checkbox = await firstVisible(page.getByLabel(/마스킹 해제 후 다운로드/), timeoutMs);
  484 |   if (!checkbox) return undefined;
  485 |   const dialog = checkbox.locator("xpath=ancestor::*[@role='dialog'][1]");
  486 |   return (await dialog.count()) > 0 ? dialog : page.locator("body");
  487 | }
  488 |
  489 | async function waitForCollectedExcelResponse(
  490 |   page: Page,
  491 |   responses: Response[],
  492 |   observedRequests: string[]
  493 | ): Promise<Response> {
  494 |   const deadline = Date.now() + 30_000;
  495 |   while (Date.now() < deadline) {
  496 |     if (responses.length > 0) return responses.at(-1)!;
  497 |     await page.waitForTimeout(200);
  498 |   }
  499 |   throw new Error(`Excel 생성 POST 응답을 30초 안에 확인하지 못했습니다. 관련 요청=${observedRequests.join(" | ") || "없음"}`);
  500 | }
  501 |
  502 | async function waitForExcelDocuments(
  503 |   page: Page,
  504 |   runtime: RuntimeQaEnv,
  505 |   ids: string[]
  506 | ): Promise<ExcelDocument[]> {
  507 |   if (!runtime.baseUrl) blocked(`${runtime.role} baseUrl이 없습니다.`);
  508 |   const documentUrl = new URL("/document", ensureTrailingSlash(runtime.baseUrl!));
  509 |   if (runtime.tenantId) documentUrl.searchParams.set("tenantId", runtime.tenantId);
  510 |   const deadline = Date.now() + 120_000;
  511 |   let latest: ExcelDocument[] = [];
  512 |
  513 |   while (Date.now() < deadline) {
  514 |     const responsePromise = page.waitForResponse(
  515 |       (response) => response.request().method() === "GET" && /\/api\/v1\/excel\?/.test(response.url()),
  516 |       { timeout: 15_000 }
  517 |     ).catch(() => undefined);
  518 |     await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: 30_000 });
  519 |     const response = await responsePromise;
  520 |     await settle(page);
  521 |     if (response) {
  522 |       const json = await response.json().catch(() => undefined);
  523 |       latest = collectDocuments(json);
  524 |       const selected = ids.map((id) => latest.find((document) => document.id === id)).filter(Boolean) as ExcelDocument[];
  525 |       if (selected.length === ids.length && selected.every((document) => normalizeMode(document.status) === "AVAILABLE")) {
  526 |         return selected;
  527 |       }
  528 |     }
  529 |     await page.waitForTimeout(2_000);
  530 |   }
  531 |   blocked(`문서보관함에서 Excel ${ids.join(", ")} 생성 완료를 120초 안에 확인하지 못했습니다. 마지막 문서 수=${latest.length}`);
  532 | }
  533 |
  534 | async function downloadDocument(page: Page, document: ExcelDocument): Promise<Buffer> {
  535 |   const root = await interactionRoot(page);
  536 |   const row = root.locator(`[data-id='${cssEscape(document.id)}']`).first();
> 537 |   await expect(row, `문서보관함에 ${document.id} 행이 표시되어야 합니다.`).toBeVisible({ timeout: 10_000 });
      |                                                            ^ Error: 문서보관함에 tenant-id-redacted 행이 표시되어야 합니다.
  538 |   const control = await firstVisible(row.getByRole("link", { name: /다운로드/ }))
  539 |     ?? await firstVisible(row.getByRole("button", { name: /다운로드/ }))
  540 |     ?? await firstVisible(row.getByText("다운로드", { exact: true }));
  541 |   if (!control) throw new Error(`${document.id} 문서 다운로드 버튼을 찾지 못했습니다.`);
  542 |   const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
  543 |   await domClick(control);
  544 |   const download = await downloadPromise;
  545 |   return readDownloadBuffer(download);
  546 | }
  547 |
  548 | async function readDownloadBuffer(download: Download): Promise<Buffer> {
  549 |   const filePath = await download.path();
  550 |   if (!filePath) throw new Error(`${download.suggestedFilename()} 다운로드 임시 파일 경로가 없습니다.`);
  551 |   return fs.readFileSync(filePath);
  552 | }
  553 |
  554 | function findDocumentId(value: unknown): string | undefined {
  555 |   if (typeof value === "string" && /^[a-zA-Z0-9-]{16,}$/.test(value)) return value;
  556 |   if (!value || typeof value !== "object") return undefined;
  557 |   const object = value as Record<string, unknown>;
  558 |   for (const key of ["documentId", "excelId", "id"]) {
  559 |     const candidate = object[key];
  560 |     if (typeof candidate === "string" && candidate.length >= 16) return candidate;
  561 |   }
  562 |   for (const key of ["payload", "data", "result"]) {
  563 |     const candidate = findDocumentId(object[key]);
  564 |     if (candidate) return candidate;
  565 |   }
  566 |   return undefined;
  567 | }
  568 |
  569 | function collectDocuments(value: unknown): ExcelDocument[] {
  570 |   const result: ExcelDocument[] = [];
  571 |   const visit = (candidate: unknown): void => {
  572 |     if (!candidate || typeof candidate !== "object") return;
  573 |     if (Array.isArray(candidate)) {
  574 |       candidate.forEach(visit);
  575 |       return;
  576 |     }
  577 |     const object = candidate as Record<string, unknown>;
  578 |     if (typeof object.id === "string" && (object.filename || object.fileName || object.exposureMode || object.status)) {
  579 |       result.push({
  580 |         id: object.id,
  581 |         status: String(object.status ?? ""),
  582 |         filename: String(object.filename ?? object.fileName ?? ""),
  583 |         exposureMode: String(object.exposureMode ?? "")
  584 |       });
  585 |     }
  586 |     for (const nested of Object.values(object)) visit(nested);
  587 |   };
  588 |   visit(value);
  589 |   return result;
  590 | }
  591 |
  592 | function requireDocument(documents: ExcelDocument[], id: string): ExcelDocument {
  593 |   const document = documents.find((candidate) => candidate.id === id);
  594 |   if (!document) throw new Error(`문서보관함 응답에서 ${id} 문서를 찾지 못했습니다.`);
  595 |   return document;
  596 | }
  597 |
  598 | async function hasActionableRows(page: Page): Promise<boolean> {
  599 |   const root = await interactionRoot(page);
  600 |   // Header-only grids expose a role=row even when the result set is empty.
  601 |   const rows = root.locator("tbody tr:visible, [role='row']:visible:has([role='cell'])");
  602 |   const count = await rows.count().catch(() => 0);
  603 |   for (let index = 0; index < Math.min(count, 30); index += 1) {
  604 |     if (!(await isActiveElement(rows.nth(index)))) continue;
  605 |     const text = normalize(await rows.nth(index).innerText().catch(() => ""));
  606 |     if (text && !EMPTY_TEXT.test(text) && !/No rows/i.test(text)) return true;
  607 |   }
  608 |   const screen = normalize(await root.innerText().catch(() => ""));
  609 |   return /총\s*[1-9][0-9,]*(?:\s*\/\s*[0-9,]+)?\s*건/.test(screen);
  610 | }
  611 |
  612 | async function interactionRoot(page: Page): Promise<Locator> {
  613 |   const active = page.locator("[data-route-keep-alive-active='true']").last();
  614 |   if ((await active.count()) > 0) return active;
  615 |   return page.locator("body");
  616 | }
  617 |
  618 | async function waitForLoading(page: Page): Promise<void> {
  619 |   const deadline = Date.now() + 15_000;
  620 |   while (Date.now() < deadline) {
  621 |     const text = normalize(await page.locator("body").innerText().catch(() => ""));
  622 |     if (!LOADING_TEXT.test(text)) return;
  623 |     await page.waitForTimeout(300);
  624 |   }
  625 | }
  626 |
  627 | async function closeSurface(page: Page, surface: Locator): Promise<void> {
  628 |   const close = await firstVisible(surface.getByRole("button", { name: /^(닫기|취소)$/ }))
  629 |     ?? await firstVisible(surface.locator("button[aria-label='Close'], button[aria-label='닫기']"));
  630 |   if (close) await domClick(close).catch(() => undefined);
  631 |   else await page.keyboard.press("Escape").catch(() => undefined);
  632 |   await page.waitForTimeout(200);
  633 | }
  634 |
  635 | async function closeVisibleDialogs(page: Page): Promise<void> {
  636 |   for (let attempt = 0; attempt < 3; attempt += 1) {
  637 |     const dialog = await firstVisible(page.locator("[role='dialog']:visible"));
```