# 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] 개인정보 마스킹 및 원문 조회 역할별 회귀 검증 >> [BO] 사용자관리 / Excel 기본 마스킹 및 PIN 원문 다운로드
- Location: src/qa/scenarios/privacyMaskingRegression.ts:127:11

# Error details

```
Error: 사용자관리 Excel 생성 응답에서 문서 ID를 찾지 못했습니다.
```

# Test source

```ts
  537 |   // Document filenames are timestamped to the second. Keep MASKED and
  538 |   // UNMASKED exports distinct so the repository UI can select each file.
  539 |   await page.waitForTimeout(1_200);
  540 |   const unmaskedRequest = await requestExcelDocument(page, runtime, definition, true);
  541 |
  542 |   const repository = await waitForExcelDocuments(page, runtime, definition, [maskedRequest.id, unmaskedRequest.id]);
  543 |   const masked = requireDocument(repository.documents, maskedRequest.id);
  544 |   const unmasked = requireDocument(repository.documents, unmaskedRequest.id);
  545 |   expect(normalizeMode(masked.exposureMode), `${definition.name} 기본 Excel은 MASKED여야 합니다.`).toBe("MASKED");
  546 |   expect(normalizeMode(unmasked.exposureMode), `${definition.name} 원문 Excel은 UNMASKED여야 합니다.`).toBe("UNMASKED");
  547 |
  548 |   const maskedBuffer = await downloadDocument(repository.page, runtime, masked);
  549 |   const unmaskedBuffer = await downloadDocument(repository.page, runtime, unmasked);
  550 |   const maskedText = extractDownloadBufferText(maskedBuffer, masked.filename ?? "masked.xlsx");
  551 |   const unmaskedText = extractDownloadBufferText(unmaskedBuffer, unmasked.filename ?? "unmasked.xlsx");
  552 |   const maskedStars = countMaskCharacters(maskedText);
  553 |   const unmaskedStars = countMaskCharacters(unmaskedText);
  554 |
  555 |   if (maskedStars === 0) {
  556 |     blocked(`${runtime.role} ${definition.name} 기본 Excel에 마스킹 검증이 가능한 개인정보 값이 없습니다.`);
  557 |   }
  558 |   expect(unmaskedStars, `${definition.name} 원문 Excel은 기본 Excel보다 마스킹 문자가 적어야 합니다.`).toBeLessThan(maskedStars);
  559 |   return { masked, unmasked, maskedStars, unmaskedStars };
  560 | }
  561 |
  562 | async function requestExcelDocument(
  563 |   page: Page,
  564 |   runtime: RuntimeQaEnv,
  565 |   definition: PrivacyPageDefinition,
  566 |   unmasked: boolean
  567 | ): Promise<ExcelRequestResult> {
  568 |   const interaction = await interactionRoot(page);
  569 |   const excelButton = await firstVisible(interaction.locator("button:visible").filter({ hasText: /^엑셀$/ }));
  570 |   if (!excelButton) {
  571 |     throw new Error(`${runtime.role} ${definition.name} 화면에 Excel 버튼이 표시되지 않습니다.`);
  572 |   }
  573 |   await domClick(excelButton);
  574 |   await page.waitForTimeout(300);
  575 |
  576 |   let dialog = await findExcelDialog(page);
  577 |   if (!dialog) {
  578 |     const menuPattern = definition.excelMenuText
  579 |       ? new RegExp(escapeRegExp(definition.excelMenuText).replace(/\\ /g, "\\s*"))
  580 |       : /조회결과\s*받기|조회결과\s*다운로드|가맹점\s*엑셀\s*다운로드|사용자\s*일괄등록\s*양식\s*받기|엑셀\s*다운로드/;
  581 |     const menu = await firstVisible(
  582 |       page.locator("li:visible, [role='menuitem']:visible, button:visible, a:visible")
  583 |         .filter({ hasText: menuPattern })
  584 |     );
  585 |     if (!menu) throw new Error(`${runtime.role} ${definition.name} Excel 조회결과 다운로드 메뉴가 없습니다.`);
  586 |     await domClick(menu);
  587 |     dialog = await findExcelDialog(page, 10_000);
  588 |   }
  589 |   if (!dialog) throw new Error(`${definition.name} Excel 마스킹 안내 모달이 표시되지 않았습니다.`);
  590 |   const root = dialog;
  591 |   const checkbox = root.getByLabel(/마스킹 해제 후 다운로드/).last();
  592 |   if (unmasked) {
  593 |     await expect(checkbox, `${definition.name} Excel 원문 다운로드 선택 항목이 표시되어야 합니다.`).toBeVisible();
  594 |     await checkbox.check();
  595 |   }
  596 |
  597 |   const responses: Response[] = [];
  598 |   const observedRequests: string[] = [];
  599 |   const requestListener = (request: { method: () => string; url: () => string }): void => {
  600 |     if (/excel|masking|authentication/i.test(request.url())) {
  601 |       observedRequests.push(`${request.method()} ${new URL(request.url()).pathname}`);
  602 |     }
  603 |   };
  604 |   const listener = (response: Response): void => {
  605 |     const request = response.request();
  606 |     if (!/^(GET|POST)$/.test(request.method())) return;
  607 |     const url = new URL(response.url());
  608 |     if (!/^\/api\/v1\/excel(?:\/[^?]+)?$/.test(url.pathname)) return;
  609 |     if (url.pathname === "/api/v1/excel" && request.method() === "GET") return;
  610 |     if (/export-authorizations/.test(url.pathname)) return;
  611 |     responses.push(response);
  612 |   };
  613 |   page.on("request", requestListener);
  614 |   page.on("response", listener);
  615 |   try {
  616 |     const downloadButton = await firstVisible(root.getByRole("button", { name: /다운로드/ }));
  617 |     if (!downloadButton) throw new Error(`${definition.name} Excel 다운로드 실행 버튼을 찾지 못했습니다.`);
  618 |     await domClick(downloadButton);
  619 |
  620 |     if (unmasked) {
  621 |       const pin = await findPinInput(page, 10_000);
  622 |       if (!pin) throw new Error(`${definition.name} 원문 Excel 선택 후 PIN 인증창이 표시되지 않았습니다.`);
  623 |       await enterPin(pin, runtime.twoFactorCode ?? "secret-redacted");
  624 |       const pinRoot = await pinConfirmationRoot(page, pin);
  625 |       const confirm = await firstVisible(pinRoot.locator("button:visible").filter({ hasText: /^(확인|인증)$/ }), 5_000);
  626 |       if (!confirm) {
  627 |         const buttonNames = (await pinRoot.locator("button:visible").allTextContents()).map(normalize).filter(Boolean);
  628 |         throw new Error(`${definition.name} 원문 Excel PIN 확인 버튼을 찾지 못했습니다. 표시 버튼=${buttonNames.join(" | ")}`);
  629 |       }
  630 |       await expect(confirm, `${definition.name} 원문 Excel PIN 확인 버튼이 활성화되어야 합니다.`).toBeEnabled();
  631 |       await domClick(confirm);
  632 |     }
  633 |
  634 |     const response = await waitForCollectedExcelResponse(page, responses, observedRequests);
  635 |     const json = await response.json().catch(() => undefined);
  636 |     const id = findDocumentId(json);
> 637 |     if (!id) throw new Error(`${definition.name} Excel 생성 응답에서 문서 ID를 찾지 못했습니다.`);
      |                    ^ Error: 사용자관리 Excel 생성 응답에서 문서 ID를 찾지 못했습니다.
  638 |     await closeVisibleDialogs(page);
  639 |     return { id, mode: unmasked ? "UNMASKED" : "MASKED" };
  640 |   } finally {
  641 |     page.off("request", requestListener);
  642 |     page.off("response", listener);
  643 |   }
  644 | }
  645 |
  646 | async function findExcelDialog(page: Page, timeoutMs = 1_000): Promise<Locator | undefined> {
  647 |   const checkbox = await firstVisible(page.getByLabel(/마스킹 해제 후 다운로드/), timeoutMs);
  648 |   if (!checkbox) return undefined;
  649 |   const dialog = checkbox.locator("xpath=ancestor::*[@role='dialog'][1]");
  650 |   return (await dialog.count()) > 0 ? dialog : page.locator("body");
  651 | }
  652 |
  653 | async function findPinInput(page: Page, timeoutMs: number): Promise<Locator | undefined> {
  654 |   const deadline = Date.now() + timeoutMs;
  655 |   do {
  656 |     const dialogs = page.locator("[role='dialog']:visible");
  657 |     const count = await dialogs.count().catch(() => 0);
  658 |     for (let index = 0; index < count; index += 1) {
  659 |       const dialog = dialogs.nth(index);
  660 |       const text = normalize(await dialog.innerText().catch(() => ""));
  661 |       if (!/PIN\s*인증|PIN\s*번호/.test(text)) continue;
  662 |       const input = await firstVisible(dialog.locator("input[placeholder*='PIN'], input[name='pin'], input[type='password']"));
  663 |       if (input) return input;
  664 |     }
  665 |     const explicit = await firstVisible(page.locator("input[placeholder*='PIN'], input[name='pin']"));
  666 |     if (explicit) return explicit;
  667 |     if (Date.now() < deadline) await page.waitForTimeout(100);
  668 |   } while (Date.now() < deadline);
  669 |   return undefined;
  670 | }
  671 |
  672 | async function enterPin(input: Locator, value: string): Promise<void> {
  673 |   await input.click();
  674 |   await input.fill("");
  675 |   await input.pressSequentially(value, { delay: 80 });
  676 | }
  677 |
  678 | async function pinConfirmationRoot(page: Page, input: Locator): Promise<Locator> {
  679 |   const dialog = input.locator("xpath=ancestor::*[@role='dialog'][1]");
  680 |   if ((await dialog.count()) > 0) return dialog;
  681 |   const nearest = input.locator(
  682 |     "xpath=ancestor::*[.//button[normalize-space()='확인' or normalize-space()='인증']][1]"
  683 |   );
  684 |   if ((await nearest.count()) > 0) return nearest;
  685 |   return page.locator("body");
  686 | }
  687 |
  688 | async function waitForCollectedExcelResponse(
  689 |   page: Page,
  690 |   responses: Response[],
  691 |   observedRequests: string[]
  692 | ): Promise<Response> {
  693 |   const deadline = Date.now() + 30_000;
  694 |   while (Date.now() < deadline) {
  695 |     if (responses.length > 0) return responses.at(-1)!;
  696 |     await page.waitForTimeout(200);
  697 |   }
  698 |   throw new Error(`Excel 생성 응답을 30초 안에 확인하지 못했습니다. 관련 요청=${observedRequests.join(" | ") || "없음"}`);
  699 | }
  700 |
  701 | async function waitForExcelDocuments(
  702 |   page: Page,
  703 |   runtime: RuntimeQaEnv,
  704 |   definition: PrivacyPageDefinition,
  705 |   ids: string[]
  706 | ): Promise<ExcelRepositoryResult> {
  707 |   if (!runtime.baseUrl) blocked(`${runtime.role} baseUrl이 없습니다.`);
  708 |   const documentUrl = new URL("/document", ensureTrailingSlash(runtime.baseUrl!));
  709 |   if (runtime.tenantId) documentUrl.searchParams.set("tenantId", runtime.tenantId);
  710 |   const deadline = Date.now() + 120_000;
  711 |   let latest: ExcelDocument[] = [];
  712 |
  713 |   let repositoryPage = page;
  714 |   if (definition.documentNavigation) {
  715 |     const history = await firstVisible(page.locator("button[aria-label*='다운로드 이력'], button[aria-label*='문서현황']"), 5_000);
  716 |     if (!history) throw new Error(`${definition.name} 우측 상단 문서현황 버튼을 찾지 못했습니다.`);
  717 |     await domClick(history);
  718 |     const move = await firstVisible(page.getByText("문서관리 페이지로 이동", { exact: true }), 5_000);
  719 |     if (!move) throw new Error(`${definition.name} 문서현황 드롭다운에서 문서관리 페이지로 이동 항목을 찾지 못했습니다.`);
  720 |     const popupPromise = page.context().waitForEvent("page", { timeout: 5_000 }).catch(() => undefined);
  721 |     await domClick(move);
  722 |     const popup = await popupPromise;
  723 |     if (popup) {
  724 |       await popup.waitForLoadState("domcontentloaded", { timeout: 30_000 }).catch(() => undefined);
  725 |       await settle(popup);
  726 |       await settle(page);
  727 |       if (/\/document(?:\?|$)/.test(page.url())) {
  728 |         repositoryPage = page;
  729 |         await popup.close().catch(() => undefined);
  730 |       } else if (/\/document(?:\?|$)/.test(popup.url())) {
  731 |         repositoryPage = popup;
  732 |       } else {
  733 |         repositoryPage = page;
  734 |         await popup.close().catch(() => undefined);
  735 |       }
  736 |     }
  737 |
```