# 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: 3839/dev/user-preset-bulk-department-admin.spec.ts >> [3839][dev] [QA] 역할별 사용자 프리셋 일괄 변경 및 부서 관리 검증 >> 총판/영업점/가맹점 엑셀 다운로드 파일의 사업자 구분 컬럼/값과 그대로 업로드 후 대상 데이터 유지 여부를 확인
- Location: src/qa/scenarios/userPresetBulkDepartmentAdmin.ts:299:5

# Error details

```
Error: 총판관리 엑셀 다운로드 이벤트 또는 파일 응답이 발생하지 않았습니다.
```

# Test source

```ts
  668 |       const style = window.getComputedStyle(element);
  669 |       return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
  670 |     };
  671 |     const inputs = Array.from(document.querySelectorAll("input:not([type='hidden']):not([type='checkbox']):not([type='radio'])")) as HTMLInputElement[];
  672 |     const candidates = inputs.filter((input) => visible(input) && !input.disabled && !input.readOnly);
  673 |     const target = candidates.find((input) => /아이디|이름|상호|가맹|영업|법인|검색/.test(input.placeholder ?? "")) ?? candidates[candidates.length - 1];
  674 |     if (!target) return false;
  675 |     target.focus();
  676 |     target.value = "";
  677 |     target.dispatchEvent(new Event("input", { bubbles: true }));
  678 |     target.value = value;
  679 |     target.dispatchEvent(new Event("input", { bubbles: true }));
  680 |     target.dispatchEvent(new Event("change", { bubbles: true }));
  681 |     return true;
  682 |   }, keyword);
  683 |   if (!filled) return;
  684 |   const clicked = await clickButton(page, /검색|조회/);
  685 |   if (!clicked && !options.tolerateNoSearchButton) throw new Error("검색/조회 버튼을 찾지 못했습니다.");
  686 |   await settle(page);
  687 | }
  688 |
  689 | async function expandSearch(page: Page): Promise<void> {
  690 |   const hasVisibleFilter = await page
  691 |     .getByText(/아이디|법인명|상호명|부서명|가맹점명|영업점명|프리셋명/)
  692 |     .first()
  693 |     .isVisible({ timeout: 500 })
  694 |     .catch(() => false);
  695 |   if (hasVisibleFilter) return;
  696 |   for (const pattern of [/상세검색/, /펼치기/]) {
  697 |     if (await clickButton(page, pattern)) {
  698 |       await page.waitForTimeout(200);
  699 |       return;
  700 |     }
  701 |   }
  702 | }
  703 |
  704 | async function clickButton(page: Page, pattern: RegExp): Promise<boolean> {
  705 |   const clickedInMainContent = await clickVisibleElementByText(page, "button,[role='button']", pattern);
  706 |   if (clickedInMainContent) return true;
  707 |   const button = findButton(page, pattern).first();
  708 |   if (!(await button.isVisible({ timeout: 1_000 }).catch(() => false))) return false;
  709 |   const clicked = await button.click({ timeout: 3_000, force: true }).then(() => true).catch(() => false);
  710 |   if (clicked) return true;
  711 |   return button.evaluate((element: HTMLElement) => element.click()).then(() => true).catch(() => false);
  712 | }
  713 |
  714 | async function clickText(page: Page, pattern: RegExp): Promise<boolean> {
  715 |   const clickedInMainContent = await clickVisibleElementByText(page, "button,[role='button'],[role='menuitem'],li,a,div,span", pattern);
  716 |   if (clickedInMainContent) return true;
  717 |   const target = page.getByText(pattern).first();
  718 |   if (!(await target.isVisible({ timeout: 1_000 }).catch(() => false))) return false;
  719 |   const clicked = await target.click({ timeout: 3_000, force: true }).then(() => true).catch(() => false);
  720 |   if (clicked) return true;
  721 |   return target.evaluate((element: HTMLElement) => element.click()).then(() => true).catch(() => false);
  722 | }
  723 |
  724 | async function clickVisibleElementByText(page: Page, selector: string, pattern: RegExp): Promise<boolean> {
  725 |   return page.evaluate(({ selector: sourceSelector, patternSource, flags }) => {
  726 |     const regex = new RegExp(patternSource, flags);
  727 |     const visible = (element: Element): boolean => {
  728 |       const rect = element.getBoundingClientRect();
  729 |       const style = window.getComputedStyle(element);
  730 |       return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden" && rect.x > 240;
  731 |     };
  732 |     const elements = Array.from(document.querySelectorAll(sourceSelector))
  733 |       .filter((element) => visible(element) && regex.test((element.textContent ?? element.getAttribute("aria-label") ?? element.getAttribute("title") ?? "").replace(/\s+/g, " ")));
  734 |     const activeCandidates = elements.filter((element) => {
  735 |       const rect = element.getBoundingClientRect();
  736 |       return rect.y >= 0 && rect.y < window.innerHeight;
  737 |     });
  738 |     const target = [...(activeCandidates.length > 0 ? activeCandidates : elements)].reverse()[0];
  739 |     if (!(target instanceof HTMLElement)) return false;
  740 |     target.click();
  741 |     return true;
  742 |   }, { selector, patternSource: pattern.source, flags: pattern.flags }).catch(() => false);
  743 | }
  744 |
  745 | async function downloadExcelIfPossible(
  746 |   page: Page,
  747 |   testInfo: TestInfo,
  748 |   pageName: string,
  749 |   suffix: string
  750 | ): Promise<{ fileName: string; headers: string[]; rows: string[][] }> {
  751 |   const downloadPromise = page.waitForEvent("download", { timeout: 20_000 }).catch(() => undefined);
  752 |   const responsePromise = page
  753 |     .waitForResponse((response) => /\.xlsx(\?|$)|cloudflarestorage.*excel/i.test(response.url()), { timeout: 20_000 })
  754 |     .catch(() => undefined);
  755 |   const clickedExcel = await clickButton(page, /엑셀|다운로드/);
  756 |   if (!clickedExcel) throw new Error(`${pageName} 엑셀/다운로드 버튼을 찾지 못했습니다.`);
  757 |   await page.waitForTimeout(500);
  758 |   const clickedDownloadItem =
  759 |     (await clickButton(page, /조회결과|다운로드|양식|확인/).catch(() => false)) ||
  760 |     (await clickText(page, /조회결과|다운로드|양식|엑셀|내려받기/).catch(() => false));
  761 |   if (!clickedDownloadItem) {
  762 |     const body = normalize(await page.locator("body").innerText({ timeout: 2_000 }).catch(() => ""));
  763 |     throw new Error(`${pageName} 엑셀 드롭다운 항목을 찾지 못했습니다. 화면=${snippet(body, 1000)}`);
  764 |   }
  765 |   const download = await downloadPromise;
  766 |   if (!download) {
  767 |     const response = await responsePromise;
> 768 |     if (!response) throw new Error(`${pageName} 엑셀 다운로드 이벤트 또는 파일 응답이 발생하지 않았습니다.`);
      |                          ^ Error: 총판관리 엑셀 다운로드 이벤트 또는 파일 응답이 발생하지 않았습니다.
  769 |     const buffer = await readResponseBody(response.url(), response.body.bind(response));
  770 |     const fileName = guessDownloadFileNameFromUrl(response.url(), `${sanitizeFileName(pageName)}-${suffix}.xlsx`);
  771 |     const filePath = testInfo.outputPath(`${sanitizeFileName(pageName)}-${suffix}-${sanitizeFileName(fileName)}`);
  772 |     await fs.promises.writeFile(filePath, buffer);
  773 |     await testInfo.attach(fileName, {
  774 |       path: filePath,
  775 |       contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
  776 |     });
  777 |     const parsedRows = await extractDownloadRows(buffer, fileName);
  778 |     const headers = parsedRows.find((row) => row.some((cell) => /사업자|구분|상호|법인|영업|가맹/.test(cell))) ?? parsedRows[0] ?? [];
  779 |     const rows = parsedRows.filter((row) => row.length > 0 && row !== headers);
  780 |     return { fileName, headers, rows };
  781 |   }
  782 |   const parsed = await parseDownload(download);
  783 |   await attachDownloadedFile(download, testInfo, pageName, suffix);
  784 |   return parsed;
  785 | }
  786 |
  787 | async function readResponseBody(url: string, readBody: () => Promise<Buffer>): Promise<Buffer> {
  788 |   try {
  789 |     return await readBody();
  790 |   } catch {
  791 |     const response = await fetch(url);
  792 |     return Buffer.from(await response.arrayBuffer());
  793 |   }
  794 | }
  795 |
  796 | async function parseDownload(download: Download): Promise<{ fileName: string; headers: string[]; rows: string[][] }> {
  797 |   const downloadPath = await download.path();
  798 |   if (!downloadPath) throw new Error("다운로드 임시 파일 경로를 확인하지 못했습니다.");
  799 |   const buffer = fs.readFileSync(downloadPath);
  800 |   const parsedRows = await extractDownloadRows(buffer, download.suggestedFilename());
  801 |   const headers = parsedRows.find((row) => row.some((cell) => /사업자|구분|상호|법인|영업|가맹/.test(cell))) ?? parsedRows[0] ?? [];
  802 |   const rows = parsedRows.filter((row) => row.length > 0 && row !== headers);
  803 |   return { fileName: download.suggestedFilename(), headers, rows };
  804 | }
  805 |
  806 | async function attachDownloadedFile(download: Download, testInfo: TestInfo, pageName: string, suffix: string): Promise<void> {
  807 |   const downloadPath = await download.path();
  808 |   if (!downloadPath) return;
  809 |   const fileName = `${sanitizeFileName(pageName)}-${suffix}-${sanitizeFileName(download.suggestedFilename())}`;
  810 |   await testInfo.attach(fileName, {
  811 |     path: downloadPath,
  812 |     contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
  813 |   });
  814 | }
  815 |
  816 | async function visibleDialogText(page: Page): Promise<string> {
  817 |   const dialog = page.locator("[role='dialog'], .modal, .ant-modal, .MuiDialog-root, .swal2-popup").filter({ hasText: /./ }).last();
  818 |   if (await dialog.isVisible({ timeout: 1_500 }).catch(() => false)) {
  819 |     return normalize(await dialog.innerText({ timeout: 3_000 }).catch(() => ""));
  820 |   }
  821 |   return "";
  822 | }
  823 |
  824 | async function closeDialog(page: Page): Promise<void> {
  825 |   await page.keyboard.press("Escape").catch(() => undefined);
  826 |   for (const pattern of [/닫기|취소|확인|×|x/i]) {
  827 |     const button = page.locator("[role='dialog'], .modal, .ant-modal, .MuiDialog-root, .swal2-popup").locator("button").filter({ hasText: pattern }).last();
  828 |     if (await button.isVisible({ timeout: 500 }).catch(() => false)) {
  829 |       await button.click().catch(() => undefined);
  830 |       await page.waitForTimeout(300);
  831 |       return;
  832 |     }
  833 |   }
  834 | }
  835 |
  836 | async function visibleButtons(page: Page): Promise<string[]> {
  837 |   return page.locator("button, [role='button']").evaluateAll((elements) => {
  838 |     const visible = (element: Element): boolean => {
  839 |       const rect = element.getBoundingClientRect();
  840 |       const style = window.getComputedStyle(element);
  841 |       return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
  842 |     };
  843 |     return elements
  844 |       .filter(visible)
  845 |       .map((element) => (element.textContent ?? element.getAttribute("aria-label") ?? element.getAttribute("title") ?? "").replace(/\s+/g, " ").trim())
  846 |       .filter(Boolean)
  847 |       .slice(0, 80);
  848 |   });
  849 | }
  850 |
  851 | async function visibleTableText(page: Page): Promise<string> {
  852 |   const table = page.locator("table, [role='table'], tbody").first();
  853 |   if (await table.isVisible({ timeout: 1_000 }).catch(() => false)) {
  854 |     return normalize(await table.innerText({ timeout: 2_000 }).catch(() => ""));
  855 |   }
  856 |   return normalize(await page.locator("body").innerText({ timeout: 2_000 }).catch(() => ""));
  857 | }
  858 |
  859 | async function waitForListSettled(page: Page): Promise<void> {
  860 |   await page.waitForFunction(() => !/불러오는 중|로딩/.test(document.body.innerText), null, { timeout: 15_000 }).catch(() => undefined);
  861 |   await page.waitForTimeout(500);
  862 | }
  863 |
  864 | async function attachEvidenceLog(testInfo: TestInfo, pageName: string, role: string, suffix: string, lines: string[]): Promise<void> {
  865 |   await testInfo.attach(`${sanitizeFileName(pageName)}-${role}-${suffix}.md`, {
  866 |     body: lines.join("\n"),
  867 |     contentType: "text/markdown"
  868 |   });
```