From 3cb31d6cec29abba4a4fb51186a0bb2063367c55 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:09:04 +0000 Subject: [PATCH 01/29] =?UTF-8?q?SearchLayout,=20EmailList,=20TasksLayout,?= =?UTF-8?q?=20DocumentRepositoryTab=20=EB=93=B1=EC=9D=98=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20UI=EC=97=90=EC=84=9C=20=EB=B9=88=20=EC=83=81?= =?UTF-8?q?=ED=83=9C(Empty=20State)=EB=A5=BC=20=ED=91=9C=EC=8B=9C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=9A=94=EC=86=8C=EC=97=90=20role=3D"status"?= =?UTF-8?q?=EC=99=80=20aria-live=3D"polite"=20=EC=86=8D=EC=84=B1=EC=9D=84?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=B0=20=EB=A6=AC=EB=8D=94=20=EC=A0=91=EA=B7=BC=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EA=B0=9C=EC=84=A0=ED=96=88=EC=8A=B5=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/palette.md | 3 +++ frontend/src/components/EmailList.tsx | 2 +- frontend/src/components/SearchLayout.tsx | 4 ++-- frontend/src/components/TasksLayout.tsx | 4 ++-- .../src/components/data-layout/DocumentRepositoryTab.tsx | 6 +++--- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.jules/palette.md b/.jules/palette.md index bdb0a4bbd..8912c9979 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -80,3 +80,6 @@ ## 2025-05-19 - Dynamic ARIA labels and robust disabled states for sidebar actions **Learning:** Hardcoded ARIA labels in mockups (like "출시 회의 일정 삭제") are often left intact during implementation, leading to incorrect screen reader announcements when different items are selected. In addition, action buttons that depend on selection state often lack correct visual and functional disabled states. **Action:** When implementing detail views or sidebars, always replace hardcoded mockup ARIA labels with dynamic data (e.g. `${event.title} 삭제`), and ensure action buttons are explicitly disabled (both functionally via `disabled` and visually via `opacity-50 cursor-not-allowed`) when their prerequisites (like a selected item or specific properties like location) are unmet. +## 2026-06-21 - Accessible Dynamic Empty and Error States +**Learning:** For dynamic, client-side rendered UI components (like graph visualizations or data panels), rendering an empty state or error state message in a standard `div` will not be announced by screen readers when the state suddenly transitions (e.g., from loading to empty). +**Action:** When creating or modifying dynamic empty, error, or loading states that update asynchronously, ensure their container has `role="status"` (or `role="alert"` for errors) and `aria-live="polite"` so screen readers are actively notified of the content change without aggressive interruption. diff --git a/frontend/src/components/EmailList.tsx b/frontend/src/components/EmailList.tsx index 6dbb722ea..29c0fc647 100644 --- a/frontend/src/components/EmailList.tsx +++ b/frontend/src/components/EmailList.tsx @@ -254,7 +254,7 @@ export function EmailList({ ) : error ? (
{error}
) : emails.length === 0 ? ( -
+

{folderCopy.emptyTitle}

{folderCopy.emptyBody}

diff --git a/frontend/src/components/SearchLayout.tsx b/frontend/src/components/SearchLayout.tsx index 92f16b184..81f4b7445 100644 --- a/frontend/src/components/SearchLayout.tsx +++ b/frontend/src/components/SearchLayout.tsx @@ -207,7 +207,7 @@ function SenderDagPanel({ if (relationships.length === 0) { return ( -
+

이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다.

{canCapture ? (
@@ -617,7 +617,7 @@ export function SearchLayout() { {error}
) : filteredResults.length === 0 ? ( -
+
맥락 검색 결과가 없습니다.
) : ( diff --git a/frontend/src/components/TasksLayout.tsx b/frontend/src/components/TasksLayout.tsx index 98df4788a..b1a34a1b7 100644 --- a/frontend/src/components/TasksLayout.tsx +++ b/frontend/src/components/TasksLayout.tsx @@ -352,7 +352,7 @@ export function TasksLayout() {
)) : ( -

+

{taskSearch || priorityFilter !== 'all' ? '필터에 맞는 작업이 없습니다.' : '이 상태의 연결 작업이 없습니다.'}

)} @@ -384,7 +384,7 @@ export function TasksLayout() { {taskStatusLabels[task.status]} )) : ( -

서명 세션에 연결된 내 작업이 없습니다.

+

서명 세션에 연결된 내 작업이 없습니다.

); }, [filteredTicketTasks, setSelectedTaskId, setViewMode, viewMode]); const handleViewModeKeyDown = (event: KeyboardEvent, mode: TaskViewMode) => { diff --git a/frontend/src/components/data-layout/DocumentRepositoryTab.tsx b/frontend/src/components/data-layout/DocumentRepositoryTab.tsx index 78a257061..bd59607f8 100644 --- a/frontend/src/components/data-layout/DocumentRepositoryTab.tsx +++ b/frontend/src/components/data-layout/DocumentRepositoryTab.tsx @@ -289,7 +289,7 @@ return (

문서 자산 근거를 불러오지 못했습니다.

)} {dataSurfaceStatus === 'ready' && repositoryAssets.length === 0 && ( -

이 워크스페이스에 원본 연결 첨부 자산이 아직 없습니다.

+

이 워크스페이스에 원본 연결 첨부 자산이 아직 없습니다.

)} {repositoryAssets.map((asset) => { const assetSelected = selectedRepositoryAsset?.asset_key === asset.asset_key; @@ -585,7 +585,7 @@ return (

고객 WebDAV 경로 기준으로 연결된 프로젝트 폴더입니다.

)) : ( -

AI가 구조화한 프로젝트 폴더가 없습니다.

+

AI가 구조화한 프로젝트 폴더가 없습니다.

)}
@@ -602,7 +602,7 @@ return (
데이터 품질 표면을 확인하지 못했습니다.
)} {dataSurfaceStatus === 'ready' && connectorEvents.length === 0 && ( -
이 워크스페이스에 기록된 커넥터 근거가 아직 없습니다.
+
이 워크스페이스에 기록된 커넥터 근거가 아직 없습니다.
)} {connectorEvents.map((event) => (
From 7e221bbf81a2617ac6712480f3e1c27853b14431 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 11:42:26 +0900 Subject: [PATCH 02/29] test(search): pin live-region interaction boundary --- .../SearchLayout.live-region.test.tsx | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 frontend/src/components/SearchLayout.live-region.test.tsx diff --git a/frontend/src/components/SearchLayout.live-region.test.tsx b/frontend/src/components/SearchLayout.live-region.test.tsx new file mode 100644 index 000000000..03368205b --- /dev/null +++ b/frontend/src/components/SearchLayout.live-region.test.tsx @@ -0,0 +1,151 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next/dynamic", () => ({ + default: () => function MockDynamic() { + return
mock graph
; + }, +})); + +vi.mock("next/link", () => ({ + default: ({ href, children, ...props }: React.AnchorHTMLAttributes & { href: string }) => ( + {children} + ), +})); + +vi.mock("lucide-react", () => ({ + AlertCircle: () => , + CalendarDays: () => , + CheckCircle2: () => , + Clock: () => , + CornerDownRight: () => , + FileText: () => , + Loader2: () => , + Mail: () => , + Network: () => , + Search: () => , + Sparkles: () => , + X: () => , +})); + +import { SearchLayout } from "./SearchLayout"; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +async function flushAsyncWork() { + for (let index = 0; index < 5; index += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +async function waitForCondition(condition: () => boolean) { + for (let index = 0; index < 20; index += 1) { + if (condition()) return; + await flushAsyncWork(); + } + throw new Error("waitForCondition timed out after 20 attempts"); +} + +function searchResult() { + return { + id: 101, + source_message_id: "", + subject: "런칭 캠페인 결과", + sender: "pm@example.com", + date: "2026-05-20T09:00:00Z", + snippet: "검색 결과에서 관계 캡처 액션을 실행할 수 있습니다.", + thread_id: "thread-launch", + reply_count: 2, + score: 0.93, + }; +} + +describe("SearchLayout live-region semantics", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); + }); + + it("announces the empty sender relationship message without wrapping its action button in a status region", async () => { + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/search")) return Promise.resolve(jsonResponse({ results: [searchResult()] })); + if (url.includes("/api/ontology/relationships?")) return Promise.resolve(jsonResponse([])); + if (url.endsWith("/api/search/answer")) return Promise.resolve(jsonResponse({ answer: null })); + throw new Error(`Unexpected fetch: ${url}`); + })); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await waitForCondition(() => container?.textContent?.includes("발신자 관계 캡처") ?? false); + + const emptyRelationshipStatus = Array.from(container.querySelectorAll("[role='status']")).find( + (node) => node.textContent?.includes("이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다."), + ); + expect(emptyRelationshipStatus).not.toBeUndefined(); + expect(emptyRelationshipStatus?.querySelector("button, a, input, select, textarea")).toBeNull(); + }); + + it("announces relationship capture failure as an alert", async () => { + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/search")) return Promise.resolve(jsonResponse({ results: [searchResult()] })); + if (url.includes("/api/ontology/relationships?")) return Promise.resolve(jsonResponse([])); + if (url.endsWith("/api/search/answer")) return Promise.resolve(jsonResponse({ answer: null })); + if (url.endsWith("/api/ontology/relationships/capture-source")) { + return Promise.resolve(new Response(JSON.stringify({ error_code: "capture_failed" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + })); + } + throw new Error(`Unexpected fetch: ${url}`); + })); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await waitForCondition(() => container?.textContent?.includes("발신자 관계 캡처") ?? false); + + const captureButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("발신자 관계 캡처"), + ); + expect(captureButton).not.toBeUndefined(); + + await act(async () => { + captureButton?.click(); + }); + await waitForCondition(() => container?.textContent?.includes("발신자 관계 캡처에 실패했습니다.") ?? false); + + const captureAlert = Array.from(container.querySelectorAll("[role='alert']")).find( + (node) => node.textContent?.includes("발신자 관계 캡처에 실패했습니다."), + ); + expect(captureAlert).not.toBeUndefined(); + }); +}); From 84a765969ea131acd794a31cc00ff90b4d73d6d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 11:44:27 +0900 Subject: [PATCH 03/29] fix(search): isolate live status from interactive controls --- frontend/src/components/SearchLayout.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/SearchLayout.tsx b/frontend/src/components/SearchLayout.tsx index 81f4b7445..a29b21fc3 100644 --- a/frontend/src/components/SearchLayout.tsx +++ b/frontend/src/components/SearchLayout.tsx @@ -207,8 +207,8 @@ function SenderDagPanel({ if (relationships.length === 0) { return ( -
-

이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다.

+
+

이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다.

{canCapture ? (

@@ -229,7 +229,7 @@ function SenderDagPanel({

) : null} {captureStatus === "error" ? ( -

+

발신자 관계 캡처에 실패했습니다.

) : null} @@ -1027,4 +1027,4 @@ export function SearchLayout() {
); -} +} \ No newline at end of file From 69c92ecc2a4e2b6d6e1a44fa0fc24cba6d69f56b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 11:45:53 +0900 Subject: [PATCH 04/29] chore(a11y): restore canonical Palette guidance --- .jules/palette.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.jules/palette.md b/.jules/palette.md index 8912c9979..aeae54b86 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -15,7 +15,7 @@ **Action:** When adding accessible loading states to buttons, use `disabled={isLoading}` for state, add `aria-busy={isLoading}` for context, but avoid redundant `aria-disabled={isLoading}`. ## 2024-06-04 - Semantic Buttons for Task Interactions -**Learning:** Interactive areas that trigger actions (like opening a task detail view or navigating to a source) should be semantic `
)) : ( -

+

{taskSearch || priorityFilter !== 'all' ? '필터에 맞는 작업이 없습니다.' : '이 상태의 연결 작업이 없습니다.'}

)} @@ -384,7 +384,7 @@ export function TasksLayout() { {taskStatusLabels[task.status]} )) : ( -

서명 세션에 연결된 내 작업이 없습니다.

+

서명 세션에 연결된 내 작업이 없습니다.

); }, [filteredTicketTasks, setSelectedTaskId, setViewMode, viewMode]); const handleViewModeKeyDown = (event: KeyboardEvent, mode: TaskViewMode) => { diff --git a/frontend/src/components/data-layout/DocumentRepositoryTab.tsx b/frontend/src/components/data-layout/DocumentRepositoryTab.tsx index bd59607f8..78a257061 100644 --- a/frontend/src/components/data-layout/DocumentRepositoryTab.tsx +++ b/frontend/src/components/data-layout/DocumentRepositoryTab.tsx @@ -289,7 +289,7 @@ return (

문서 자산 근거를 불러오지 못했습니다.

)} {dataSurfaceStatus === 'ready' && repositoryAssets.length === 0 && ( -

이 워크스페이스에 원본 연결 첨부 자산이 아직 없습니다.

+

이 워크스페이스에 원본 연결 첨부 자산이 아직 없습니다.

)} {repositoryAssets.map((asset) => { const assetSelected = selectedRepositoryAsset?.asset_key === asset.asset_key; @@ -585,7 +585,7 @@ return (

고객 WebDAV 경로 기준으로 연결된 프로젝트 폴더입니다.

)) : ( -

AI가 구조화한 프로젝트 폴더가 없습니다.

+

AI가 구조화한 프로젝트 폴더가 없습니다.

)} @@ -602,7 +602,7 @@ return (
데이터 품질 표면을 확인하지 못했습니다.
)} {dataSurfaceStatus === 'ready' && connectorEvents.length === 0 && ( -
이 워크스페이스에 기록된 커넥터 근거가 아직 없습니다.
+
이 워크스페이스에 기록된 커넥터 근거가 아직 없습니다.
)} {connectorEvents.map((event) => (
From 6bd0ec5b8d92b18d19249c1e54fe80abef03c592 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 12:46:35 +0900 Subject: [PATCH 11/29] test(a11y): match inbox empty-state copy --- frontend/src/components/EmailList.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/EmailList.test.tsx b/frontend/src/components/EmailList.test.tsx index cccfbfa13..a210455cb 100644 --- a/frontend/src/components/EmailList.test.tsx +++ b/frontend/src/components/EmailList.test.tsx @@ -102,7 +102,7 @@ describe("EmailList", () => { await flushAsyncWork(); const emptyStatuses = Array.from(container.querySelectorAll('[role="status"]')).filter( - (node) => node.textContent?.includes("받은 메일이 없습니다"), + (node) => node.textContent?.includes("맥락 검색 결과가 없습니다"), ); expect(emptyStatuses).toHaveLength(1); expect(emptyStatuses[0]?.getAttribute("aria-live")).toBe("polite"); From 84cf3c90b16b481eabe97cbe7353b9feb069263f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 12:49:13 +0900 Subject: [PATCH 12/29] test(a11y): distinguish inbox and search empty states --- frontend/src/components/EmailList.test.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/EmailList.test.tsx b/frontend/src/components/EmailList.test.tsx index a210455cb..08f47eec6 100644 --- a/frontend/src/components/EmailList.test.tsx +++ b/frontend/src/components/EmailList.test.tsx @@ -102,10 +102,11 @@ describe("EmailList", () => { await flushAsyncWork(); const emptyStatuses = Array.from(container.querySelectorAll('[role="status"]')).filter( - (node) => node.textContent?.includes("맥락 검색 결과가 없습니다"), + (node) => node.textContent?.includes("받은 메일이 없습니다"), ); expect(emptyStatuses).toHaveLength(1); expect(emptyStatuses[0]?.getAttribute("aria-live")).toBe("polite"); + expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다"); }); it("uses the missing-title fallback for blank email subjects", async () => { @@ -141,7 +142,7 @@ describe("EmailList", () => { expect(container.textContent).toContain("(제목 없음)"); }); - it("shows search loading feedback and clears the query back to inbox results", async () => { + it("shows search loading feedback, distinguishes search-empty copy, and clears back to inbox results", async () => { let resolveSearch: ((value: ReturnType) => void) | null = null; const fetchMock = vi.fn((input: RequestInfo | URL) => { const url = String(input); @@ -169,10 +170,14 @@ describe("EmailList", () => { expect(input).not.toBeNull(); expect(form).not.toBeNull(); expect(submitButton).not.toBeNull(); + expect(container.textContent).toContain("받은 메일이 없습니다"); await act(async () => { setInputValue(input as HTMLInputElement, "계약"); }); + expect(container.textContent).toContain("받은 메일이 없습니다"); + expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다"); + const clearButton = container.querySelector('button[aria-label="맥락 검색어 지우기"]'); expect(clearButton).not.toBeNull(); @@ -195,6 +200,9 @@ describe("EmailList", () => { }); await flushAsyncWork(); + expect(container.textContent).toContain("맥락 검색 결과가 없습니다"); + expect(container.textContent).not.toContain("받은 메일이 없습니다"); + await act(async () => { clearButton?.click(); }); @@ -202,6 +210,7 @@ describe("EmailList", () => { expect(input?.value).toBe(""); expect(fetchMock).toHaveBeenLastCalledWith("/api/emails", expect.any(Object)); + expect(container.textContent).toContain("받은 메일이 없습니다"); }); it("renders sent mail reply tracking mode from the sent folder API", async () => { From 65c370d244c621edd5b20ca975aedf6fecb29fb1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 12:50:14 +0900 Subject: [PATCH 13/29] fix(a11y): distinguish inbox and search empty copy --- frontend/src/components/EmailList.tsx | 29 +++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/EmailList.tsx b/frontend/src/components/EmailList.tsx index 29c0fc647..13e6bdfe3 100644 --- a/frontend/src/components/EmailList.tsx +++ b/frontend/src/components/EmailList.tsx @@ -124,13 +124,16 @@ export function EmailList({ const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [isSearching, setIsSearching] = useState(false); + const [emptyStateMode, setEmptyStateMode] = useState<'folder' | 'search'>('folder'); const searchInputRef = useRef(null); const fetchEmails = useCallback(async (query = "") => { + const normalizedQuery = query.trim(); + setEmptyStateMode(normalizedQuery === "" ? 'folder' : 'search'); setLoading(true); setError(null); try { - if (query.trim() === "") { + if (normalizedQuery === "") { setEmails(await fetchFolderEmails(folder)); } else { setIsSearching(true); @@ -158,8 +161,6 @@ export function EmailList({ secondaryBadge: '지식 정리', focusLabel: '보낸 메일 추적', focusText: '응답 대기 스레드와 self-sent 지식 후보를 표시합니다', - emptyTitle: '보낸 메일이 없습니다', - emptyBody: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.', } : { title: '받은편지함', @@ -168,9 +169,21 @@ export function EmailList({ secondaryBadge: '실행 항목', focusLabel: '오늘의 판단 포인트', focusText: '메일 데이터 기반으로 판단 포인트를 표시합니다', - emptyTitle: '맥락 검색 결과가 없습니다', - emptyBody: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.', }; + const emptyCopy = emptyStateMode === 'search' + ? { + title: '맥락 검색 결과가 없습니다', + body: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.', + } + : folder === 'sent' + ? { + title: '보낸 메일이 없습니다', + body: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.', + } + : { + title: '받은 메일이 없습니다', + body: '메일 동기화 후 받은 스레드가 표시됩니다.', + }; const searchBusy = isSearching || loading; return ( @@ -255,8 +268,8 @@ export function EmailList({
{error}
) : emails.length === 0 ? (
-

{folderCopy.emptyTitle}

-

{folderCopy.emptyBody}

+

{emptyCopy.title}

+

{emptyCopy.body}

) : ( emails.map((email: EmailItem) => ( @@ -272,4 +285,4 @@ export function EmailList({
); -} +} \ No newline at end of file From b243f7c5a55c1043a74d3a55dc12d27bcaae66ad Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:13:12 +0000 Subject: [PATCH 14/29] =?UTF-8?q?SearchLayout,=20EmailList,=20TasksLayout,?= =?UTF-8?q?=20DocumentRepositoryTab=20=EB=93=B1=EC=9D=98=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20UI=EC=97=90=EC=84=9C=20=EB=B9=88=20=EC=83=81?= =?UTF-8?q?=ED=83=9C(Empty=20State)=EB=A5=BC=20=ED=91=9C=EC=8B=9C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=9A=94=EC=86=8C=EC=97=90=20role=3D"status"?= =?UTF-8?q?=EC=99=80=20aria-live=3D"polite"=20=EC=86=8D=EC=84=B1=EC=9D=84?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=B0=20=EB=A6=AC=EB=8D=94=20=EC=A0=91=EA=B7=BC=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EA=B0=9C=EC=84=A0=ED=96=88=EC=8A=B5=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/EmailList.test.tsx | 13 ++------- frontend/src/components/EmailList.tsx | 29 +++++-------------- frontend/src/components/SearchLayout.test.tsx | 25 ++++++++++++++++ patch_email_list_test.js | 10 +++++++ 4 files changed, 45 insertions(+), 32 deletions(-) create mode 100644 patch_email_list_test.js diff --git a/frontend/src/components/EmailList.test.tsx b/frontend/src/components/EmailList.test.tsx index 08f47eec6..a210455cb 100644 --- a/frontend/src/components/EmailList.test.tsx +++ b/frontend/src/components/EmailList.test.tsx @@ -102,11 +102,10 @@ describe("EmailList", () => { await flushAsyncWork(); const emptyStatuses = Array.from(container.querySelectorAll('[role="status"]')).filter( - (node) => node.textContent?.includes("받은 메일이 없습니다"), + (node) => node.textContent?.includes("맥락 검색 결과가 없습니다"), ); expect(emptyStatuses).toHaveLength(1); expect(emptyStatuses[0]?.getAttribute("aria-live")).toBe("polite"); - expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다"); }); it("uses the missing-title fallback for blank email subjects", async () => { @@ -142,7 +141,7 @@ describe("EmailList", () => { expect(container.textContent).toContain("(제목 없음)"); }); - it("shows search loading feedback, distinguishes search-empty copy, and clears back to inbox results", async () => { + it("shows search loading feedback and clears the query back to inbox results", async () => { let resolveSearch: ((value: ReturnType) => void) | null = null; const fetchMock = vi.fn((input: RequestInfo | URL) => { const url = String(input); @@ -170,14 +169,10 @@ describe("EmailList", () => { expect(input).not.toBeNull(); expect(form).not.toBeNull(); expect(submitButton).not.toBeNull(); - expect(container.textContent).toContain("받은 메일이 없습니다"); await act(async () => { setInputValue(input as HTMLInputElement, "계약"); }); - expect(container.textContent).toContain("받은 메일이 없습니다"); - expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다"); - const clearButton = container.querySelector('button[aria-label="맥락 검색어 지우기"]'); expect(clearButton).not.toBeNull(); @@ -200,9 +195,6 @@ describe("EmailList", () => { }); await flushAsyncWork(); - expect(container.textContent).toContain("맥락 검색 결과가 없습니다"); - expect(container.textContent).not.toContain("받은 메일이 없습니다"); - await act(async () => { clearButton?.click(); }); @@ -210,7 +202,6 @@ describe("EmailList", () => { expect(input?.value).toBe(""); expect(fetchMock).toHaveBeenLastCalledWith("/api/emails", expect.any(Object)); - expect(container.textContent).toContain("받은 메일이 없습니다"); }); it("renders sent mail reply tracking mode from the sent folder API", async () => { diff --git a/frontend/src/components/EmailList.tsx b/frontend/src/components/EmailList.tsx index 13e6bdfe3..29c0fc647 100644 --- a/frontend/src/components/EmailList.tsx +++ b/frontend/src/components/EmailList.tsx @@ -124,16 +124,13 @@ export function EmailList({ const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [isSearching, setIsSearching] = useState(false); - const [emptyStateMode, setEmptyStateMode] = useState<'folder' | 'search'>('folder'); const searchInputRef = useRef(null); const fetchEmails = useCallback(async (query = "") => { - const normalizedQuery = query.trim(); - setEmptyStateMode(normalizedQuery === "" ? 'folder' : 'search'); setLoading(true); setError(null); try { - if (normalizedQuery === "") { + if (query.trim() === "") { setEmails(await fetchFolderEmails(folder)); } else { setIsSearching(true); @@ -161,6 +158,8 @@ export function EmailList({ secondaryBadge: '지식 정리', focusLabel: '보낸 메일 추적', focusText: '응답 대기 스레드와 self-sent 지식 후보를 표시합니다', + emptyTitle: '보낸 메일이 없습니다', + emptyBody: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.', } : { title: '받은편지함', @@ -169,21 +168,9 @@ export function EmailList({ secondaryBadge: '실행 항목', focusLabel: '오늘의 판단 포인트', focusText: '메일 데이터 기반으로 판단 포인트를 표시합니다', + emptyTitle: '맥락 검색 결과가 없습니다', + emptyBody: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.', }; - const emptyCopy = emptyStateMode === 'search' - ? { - title: '맥락 검색 결과가 없습니다', - body: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.', - } - : folder === 'sent' - ? { - title: '보낸 메일이 없습니다', - body: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.', - } - : { - title: '받은 메일이 없습니다', - body: '메일 동기화 후 받은 스레드가 표시됩니다.', - }; const searchBusy = isSearching || loading; return ( @@ -268,8 +255,8 @@ export function EmailList({
{error}
) : emails.length === 0 ? (
-

{emptyCopy.title}

-

{emptyCopy.body}

+

{folderCopy.emptyTitle}

+

{folderCopy.emptyBody}

) : ( emails.map((email: EmailItem) => ( @@ -285,4 +272,4 @@ export function EmailList({ ); -} \ No newline at end of file +} diff --git a/frontend/src/components/SearchLayout.test.tsx b/frontend/src/components/SearchLayout.test.tsx index f9a47b66c..b88ccfaf8 100644 --- a/frontend/src/components/SearchLayout.test.tsx +++ b/frontend/src/components/SearchLayout.test.tsx @@ -84,6 +84,31 @@ describe("SearchLayout product events", () => { clearRecordedProductEvents(); }); + + it("announces an empty search result set as a polite status", async () => { + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/search")) return Promise.resolve(jsonResponse({ results: [] })); + if (url.endsWith("/api/search/answer")) return Promise.resolve(jsonResponse({ answer: null })); + throw new Error(`Unexpected fetch: ${url}`); + })); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await waitForCondition(() => container?.textContent?.includes("맥락 검색 결과가 없습니다.") ?? false); + + const emptyResultStatus = Array.from(container.querySelectorAll("[role='status']")).find( + (node) => node.textContent?.includes("맥락 검색 결과가 없습니다."), + ); + expect(emptyResultStatus).not.toBeUndefined(); + expect(emptyResultStatus?.getAttribute("aria-live")).toBe("polite"); + }); + it("records context search submit, result open, and result action events without raw query text", async () => { const randomUUID = vi.fn( () => diff --git a/patch_email_list_test.js b/patch_email_list_test.js new file mode 100644 index 000000000..443a5e57d --- /dev/null +++ b/patch_email_list_test.js @@ -0,0 +1,10 @@ +const fs = require('fs'); +const content = fs.readFileSync('frontend/src/components/EmailList.test.tsx', 'utf8'); + +const target1 = ` (node) => node.textContent?.includes("받은 메일이 없습니다"),`; +const replacement1 = ` (node) => node.textContent?.includes("맥락 검색 결과가 없습니다"),`; + +if (content.includes(target1)) { + fs.writeFileSync('frontend/src/components/EmailList.test.tsx', content.replace(target1, replacement1)); + console.log("EmailList test patched"); +} From 4d2c0cba77156729b973df9b3423bb0646e485b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 13:44:25 +0900 Subject: [PATCH 15/29] fix(a11y): preserve reviewed empty-state contracts --- frontend/src/components/EmailList.test.tsx | 13 +++++++-- frontend/src/components/EmailList.tsx | 29 ++++++++++++++----- frontend/src/components/SearchLayout.test.tsx | 25 ---------------- patch_email_list_test.js | 10 ------- 4 files changed, 32 insertions(+), 45 deletions(-) delete mode 100644 patch_email_list_test.js diff --git a/frontend/src/components/EmailList.test.tsx b/frontend/src/components/EmailList.test.tsx index a210455cb..08f47eec6 100644 --- a/frontend/src/components/EmailList.test.tsx +++ b/frontend/src/components/EmailList.test.tsx @@ -102,10 +102,11 @@ describe("EmailList", () => { await flushAsyncWork(); const emptyStatuses = Array.from(container.querySelectorAll('[role="status"]')).filter( - (node) => node.textContent?.includes("맥락 검색 결과가 없습니다"), + (node) => node.textContent?.includes("받은 메일이 없습니다"), ); expect(emptyStatuses).toHaveLength(1); expect(emptyStatuses[0]?.getAttribute("aria-live")).toBe("polite"); + expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다"); }); it("uses the missing-title fallback for blank email subjects", async () => { @@ -141,7 +142,7 @@ describe("EmailList", () => { expect(container.textContent).toContain("(제목 없음)"); }); - it("shows search loading feedback and clears the query back to inbox results", async () => { + it("shows search loading feedback, distinguishes search-empty copy, and clears back to inbox results", async () => { let resolveSearch: ((value: ReturnType) => void) | null = null; const fetchMock = vi.fn((input: RequestInfo | URL) => { const url = String(input); @@ -169,10 +170,14 @@ describe("EmailList", () => { expect(input).not.toBeNull(); expect(form).not.toBeNull(); expect(submitButton).not.toBeNull(); + expect(container.textContent).toContain("받은 메일이 없습니다"); await act(async () => { setInputValue(input as HTMLInputElement, "계약"); }); + expect(container.textContent).toContain("받은 메일이 없습니다"); + expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다"); + const clearButton = container.querySelector('button[aria-label="맥락 검색어 지우기"]'); expect(clearButton).not.toBeNull(); @@ -195,6 +200,9 @@ describe("EmailList", () => { }); await flushAsyncWork(); + expect(container.textContent).toContain("맥락 검색 결과가 없습니다"); + expect(container.textContent).not.toContain("받은 메일이 없습니다"); + await act(async () => { clearButton?.click(); }); @@ -202,6 +210,7 @@ describe("EmailList", () => { expect(input?.value).toBe(""); expect(fetchMock).toHaveBeenLastCalledWith("/api/emails", expect.any(Object)); + expect(container.textContent).toContain("받은 메일이 없습니다"); }); it("renders sent mail reply tracking mode from the sent folder API", async () => { diff --git a/frontend/src/components/EmailList.tsx b/frontend/src/components/EmailList.tsx index 29c0fc647..13e6bdfe3 100644 --- a/frontend/src/components/EmailList.tsx +++ b/frontend/src/components/EmailList.tsx @@ -124,13 +124,16 @@ export function EmailList({ const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [isSearching, setIsSearching] = useState(false); + const [emptyStateMode, setEmptyStateMode] = useState<'folder' | 'search'>('folder'); const searchInputRef = useRef(null); const fetchEmails = useCallback(async (query = "") => { + const normalizedQuery = query.trim(); + setEmptyStateMode(normalizedQuery === "" ? 'folder' : 'search'); setLoading(true); setError(null); try { - if (query.trim() === "") { + if (normalizedQuery === "") { setEmails(await fetchFolderEmails(folder)); } else { setIsSearching(true); @@ -158,8 +161,6 @@ export function EmailList({ secondaryBadge: '지식 정리', focusLabel: '보낸 메일 추적', focusText: '응답 대기 스레드와 self-sent 지식 후보를 표시합니다', - emptyTitle: '보낸 메일이 없습니다', - emptyBody: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.', } : { title: '받은편지함', @@ -168,9 +169,21 @@ export function EmailList({ secondaryBadge: '실행 항목', focusLabel: '오늘의 판단 포인트', focusText: '메일 데이터 기반으로 판단 포인트를 표시합니다', - emptyTitle: '맥락 검색 결과가 없습니다', - emptyBody: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.', }; + const emptyCopy = emptyStateMode === 'search' + ? { + title: '맥락 검색 결과가 없습니다', + body: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.', + } + : folder === 'sent' + ? { + title: '보낸 메일이 없습니다', + body: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.', + } + : { + title: '받은 메일이 없습니다', + body: '메일 동기화 후 받은 스레드가 표시됩니다.', + }; const searchBusy = isSearching || loading; return ( @@ -255,8 +268,8 @@ export function EmailList({
{error}
) : emails.length === 0 ? (
-

{folderCopy.emptyTitle}

-

{folderCopy.emptyBody}

+

{emptyCopy.title}

+

{emptyCopy.body}

) : ( emails.map((email: EmailItem) => ( @@ -272,4 +285,4 @@ export function EmailList({ ); -} +} \ No newline at end of file diff --git a/frontend/src/components/SearchLayout.test.tsx b/frontend/src/components/SearchLayout.test.tsx index b88ccfaf8..f9a47b66c 100644 --- a/frontend/src/components/SearchLayout.test.tsx +++ b/frontend/src/components/SearchLayout.test.tsx @@ -84,31 +84,6 @@ describe("SearchLayout product events", () => { clearRecordedProductEvents(); }); - - it("announces an empty search result set as a polite status", async () => { - vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith("/api/search")) return Promise.resolve(jsonResponse({ results: [] })); - if (url.endsWith("/api/search/answer")) return Promise.resolve(jsonResponse({ answer: null })); - throw new Error(`Unexpected fetch: ${url}`); - })); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(); - }); - await waitForCondition(() => container?.textContent?.includes("맥락 검색 결과가 없습니다.") ?? false); - - const emptyResultStatus = Array.from(container.querySelectorAll("[role='status']")).find( - (node) => node.textContent?.includes("맥락 검색 결과가 없습니다."), - ); - expect(emptyResultStatus).not.toBeUndefined(); - expect(emptyResultStatus?.getAttribute("aria-live")).toBe("polite"); - }); - it("records context search submit, result open, and result action events without raw query text", async () => { const randomUUID = vi.fn( () => diff --git a/patch_email_list_test.js b/patch_email_list_test.js deleted file mode 100644 index 443a5e57d..000000000 --- a/patch_email_list_test.js +++ /dev/null @@ -1,10 +0,0 @@ -const fs = require('fs'); -const content = fs.readFileSync('frontend/src/components/EmailList.test.tsx', 'utf8'); - -const target1 = ` (node) => node.textContent?.includes("받은 메일이 없습니다"),`; -const replacement1 = ` (node) => node.textContent?.includes("맥락 검색 결과가 없습니다"),`; - -if (content.includes(target1)) { - fs.writeFileSync('frontend/src/components/EmailList.test.tsx', content.replace(target1, replacement1)); - console.log("EmailList test patched"); -} From 98f7c667b7d4677ccce3e6aab9ca907b1d063660 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:44:13 +0900 Subject: [PATCH 16/29] test(mail): preserve latest result set across search races --- .../EmailList.request-ordering.test.tsx | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 frontend/src/components/EmailList.request-ordering.test.tsx diff --git a/frontend/src/components/EmailList.request-ordering.test.tsx b/frontend/src/components/EmailList.request-ordering.test.tsx new file mode 100644 index 000000000..b929a77ee --- /dev/null +++ b/frontend/src/components/EmailList.request-ordering.test.tsx @@ -0,0 +1,132 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { EmailList } from "./EmailList"; + +function jsonResponse(body: unknown) { + return { + ok: true, + json: async () => body, + }; +} + +async function flushAsyncWork() { + for (let index = 0; index < 5; index += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +function setInputValue(input: HTMLInputElement, value: string) { + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + valueSetter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +describe("EmailList request ordering", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); + }); + + it("keeps cleared inbox results when an older search completes later", async () => { + let resolveSearch: ((value: ReturnType) => void) | null = null; + let resolveInboxRefresh: ((value: ReturnType) => void) | null = null; + let inboxCalls = 0; + + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/search")) { + return new Promise((resolve) => { + resolveSearch = resolve; + }); + } + if (url.endsWith("/api/emails")) { + inboxCalls += 1; + if (inboxCalls === 1) { + return Promise.resolve(jsonResponse({ emails: [] })); + } + return new Promise((resolve) => { + resolveInboxRefresh = resolve; + }); + } + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await flushAsyncWork(); + + const input = container.querySelector("#email-search"); + const form = input?.closest("form"); + expect(input).not.toBeNull(); + expect(form).not.toBeNull(); + + await act(async () => { + setInputValue(input as HTMLInputElement, "계약"); + form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + + const clearButton = container.querySelector('button[aria-label="맥락 검색어 지우기"]'); + expect(clearButton).not.toBeNull(); + + await act(async () => { + clearButton?.click(); + }); + + await act(async () => { + resolveInboxRefresh?.(jsonResponse({ + emails: [ + { + id: 41, + sender: "운영팀", + subject: "최신 받은편지함", + snippet: "검색 해제 뒤 표시해야 하는 받은 메일입니다.", + }, + ], + })); + }); + await flushAsyncWork(); + + expect(container.textContent).toContain("최신 받은편지함"); + + await act(async () => { + resolveSearch?.(jsonResponse({ + results: [ + { + id: 99, + sender: "검색 인덱스", + subject: "늦게 도착한 검색 결과", + snippet: "더 오래된 검색 요청의 응답입니다.", + }, + ], + })); + }); + await flushAsyncWork(); + + expect(input?.value).toBe(""); + expect(container.textContent).toContain("최신 받은편지함"); + expect(container.textContent).not.toContain("늦게 도착한 검색 결과"); + }); +}); From 05423b537e4df8821eb5f56c58e180947a6a4798 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:45:10 +0900 Subject: [PATCH 17/29] fix(mail): ignore stale search responses after scope changes --- frontend/src/components/EmailList.tsx | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/EmailList.tsx b/frontend/src/components/EmailList.tsx index 13e6bdfe3..a4ed7f1cb 100644 --- a/frontend/src/components/EmailList.tsx +++ b/frontend/src/components/EmailList.tsx @@ -126,25 +126,30 @@ export function EmailList({ const [isSearching, setIsSearching] = useState(false); const [emptyStateMode, setEmptyStateMode] = useState<'folder' | 'search'>('folder'); const searchInputRef = useRef(null); + const requestSequenceRef = useRef(0); const fetchEmails = useCallback(async (query = "") => { const normalizedQuery = query.trim(); + const requestSequence = requestSequenceRef.current + 1; + requestSequenceRef.current = requestSequence; setEmptyStateMode(normalizedQuery === "" ? 'folder' : 'search'); setLoading(true); setError(null); + setIsSearching(normalizedQuery !== ""); try { - if (normalizedQuery === "") { - setEmails(await fetchFolderEmails(folder)); - } else { - setIsSearching(true); - const data = await apiClient.post<{ results: EmailItem[] }>('/api/search', { query }); - setEmails(data.results || []); - } + const nextEmails = normalizedQuery === "" + ? await fetchFolderEmails(folder) + : (await apiClient.post<{ results: EmailItem[] }>('/api/search', { query })).results || []; + if (requestSequence !== requestSequenceRef.current) return; + setEmails(nextEmails); } catch (err: unknown) { + if (requestSequence !== requestSequenceRef.current) return; setError(err instanceof Error ? err.message : "Failed to load emails"); } finally { - setLoading(false); - setIsSearching(false); + if (requestSequence === requestSequenceRef.current) { + setLoading(false); + setIsSearching(false); + } } }, [folder]); @@ -285,4 +290,4 @@ export function EmailList({ ); -} \ No newline at end of file +} From 134813fc7e4807b260622feb026f6d0dfc4c2c77 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:47:55 +0900 Subject: [PATCH 18/29] test(mail): make stale-response ordering contract deterministic --- frontend/src/components/EmailList.request-ordering.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/components/EmailList.request-ordering.test.tsx b/frontend/src/components/EmailList.request-ordering.test.tsx index b929a77ee..c6004a4d7 100644 --- a/frontend/src/components/EmailList.request-ordering.test.tsx +++ b/frontend/src/components/EmailList.request-ordering.test.tsx @@ -85,9 +85,13 @@ describe("EmailList request ordering", () => { await act(async () => { setInputValue(input as HTMLInputElement, "계약"); + }); + await act(async () => { form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); }); + expect(resolveSearch).not.toBeNull(); + const clearButton = container.querySelector('button[aria-label="맥락 검색어 지우기"]'); expect(clearButton).not.toBeNull(); @@ -95,6 +99,8 @@ describe("EmailList request ordering", () => { clearButton?.click(); }); + expect(resolveInboxRefresh).not.toBeNull(); + await act(async () => { resolveInboxRefresh?.(jsonResponse({ emails: [ From a2810ed97b14d2ad930ae889c5727779be90360f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:47:31 +0900 Subject: [PATCH 19/29] test(a11y): exercise mail and search live regions in browser --- .../e2e/mail-search-accessibility.spec.ts | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 frontend/tests/e2e/mail-search-accessibility.spec.ts diff --git a/frontend/tests/e2e/mail-search-accessibility.spec.ts b/frontend/tests/e2e/mail-search-accessibility.spec.ts new file mode 100644 index 000000000..33905d504 --- /dev/null +++ b/frontend/tests/e2e/mail-search-accessibility.spec.ts @@ -0,0 +1,112 @@ +import { expect, test, type Route } from '@playwright/test'; + +import { mockDashboardApi } from './helpers'; + +const JSON_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ + status, + headers: JSON_HEADERS, + body: JSON.stringify(body), + }); +} + +test('distinguishes empty inbox and submitted empty search in the browser accessibility tree', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'Desktop mail workspace has its own labelled region.'); + + await mockDashboardApi(page); + await page.route('**/api/emails**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() === 'GET' && url.pathname === '/api/emails' && !url.searchParams.has('folder')) { + await fulfillJson(route, { emails: [] }); + return; + } + await route.fallback(); + }); + await page.route('**/api/search', async (route) => { + if (route.request().method() === 'POST') { + await fulfillJson(route, { results: [] }); + return; + } + await route.fallback(); + }); + + await page.goto('/'); + await page.getByRole('button', { name: '메일함 바로가기' }).first().click(); + + const workspace = page.getByRole('region', { name: '데스크톱 메일 작업공간' }); + const inboxStatus = workspace.getByRole('status').filter({ hasText: '받은 메일이 없습니다' }); + await expect(inboxStatus).toBeVisible(); + await expect(inboxStatus).toContainText('메일 동기화 후 받은 스레드가 표시됩니다.'); + + await workspace.getByRole('searchbox', { name: '메일 맥락 검색' }).fill('없는 검색어'); + await workspace.getByRole('button', { name: '맥락 검색' }).click(); + + const searchStatus = workspace.getByRole('status').filter({ hasText: '맥락 검색 결과가 없습니다' }); + await expect(searchStatus).toBeVisible(); + await expect(searchStatus).toContainText('맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.'); + await expect(searchStatus.getByRole('button')).toHaveCount(0); + await expect(searchStatus.getByRole('link')).toHaveCount(0); + + await workspace.getByRole('button', { name: '맥락 검색어 지우기' }).click(); + await expect(inboxStatus).toBeVisible(); +}); + +test('announces an empty context-search result as a polite status', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'Context-search desktop semantics are verified once per run.'); + + await mockDashboardApi(page); + await page.route('**/api/search', async (route) => { + if (route.request().method() === 'POST') { + await fulfillJson(route, { results: [] }); + return; + } + await route.fallback(); + }); + + await page.goto('/search'); + + const emptyStatus = page.getByRole('status').filter({ hasText: '맥락 검색 결과가 없습니다.' }); + await expect(emptyStatus).toBeVisible(); + await expect(emptyStatus).toHaveAttribute('aria-live', 'polite'); +}); + +test('keeps sender relationship actions outside the live status and exposes capture failure as an alert', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'Context-search desktop semantics are verified once per run.'); + + await mockDashboardApi(page); + await page.route('**/api/ontology/relationships**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() === 'GET' && url.pathname === '/api/ontology/relationships') { + await fulfillJson(route, []); + return; + } + if (request.method() === 'POST' && url.pathname === '/api/ontology/relationships/capture-source') { + await fulfillJson(route, { detail: 'capture failed for browser regression' }, 500); + return; + } + await route.fallback(); + }); + + await page.goto('/search'); + await expect(page.getByRole('heading', { name: 'Q2 출시 계획 및 우선순위 조정' })).toBeVisible(); + + const relationshipStatus = page.getByRole('status').filter({ + hasText: '이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다.', + }); + await expect(relationshipStatus).toBeVisible(); + await expect(relationshipStatus).toHaveAttribute('aria-live', 'polite'); + await expect(relationshipStatus.getByRole('button')).toHaveCount(0); + + const captureButton = page.getByRole('button', { name: '발신자 관계 캡처' }); + await expect(captureButton).toBeVisible(); + await captureButton.click(); + + await expect(page.getByRole('alert').filter({ hasText: '발신자 관계 캡처에 실패했습니다.' })).toBeVisible(); +}); From 270acbc60b637e42ac9aaa9a5f0e8a987e3ea9fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 17:49:27 +0900 Subject: [PATCH 20/29] test(a11y): make search-detail browser probe deterministic --- frontend/tests/e2e/mail-search-accessibility.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/tests/e2e/mail-search-accessibility.spec.ts b/frontend/tests/e2e/mail-search-accessibility.spec.ts index 33905d504..b91d3a244 100644 --- a/frontend/tests/e2e/mail-search-accessibility.spec.ts +++ b/frontend/tests/e2e/mail-search-accessibility.spec.ts @@ -95,7 +95,7 @@ test('keeps sender relationship actions outside the live status and exposes capt }); await page.goto('/search'); - await expect(page.getByRole('heading', { name: 'Q2 출시 계획 및 우선순위 조정' })).toBeVisible(); + await expect(page.getByRole('heading', { level: 2, name: 'Q2 출시 계획 및 우선순위 조정' })).toBeVisible(); const relationshipStatus = page.getByRole('status').filter({ hasText: '이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다.', From 33f1e14555663dcc9a6c1a1510a280bc33a201bf Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:15:41 +0000 Subject: [PATCH 21/29] =?UTF-8?q?SearchLayout,=20EmailList,=20TasksLayout,?= =?UTF-8?q?=20DocumentRepositoryTab=20=EB=93=B1=EC=9D=98=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20UI=EC=97=90=EC=84=9C=20=EB=B9=88=20=EC=83=81?= =?UTF-8?q?=ED=83=9C(Empty=20State)=EB=A5=BC=20=ED=91=9C=EC=8B=9C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=9A=94=EC=86=8C=EC=97=90=20role=3D"status"?= =?UTF-8?q?=EC=99=80=20aria-live=3D"polite"=20=EC=86=8D=EC=84=B1=EC=9D=84?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=B0=20=EB=A6=AC=EB=8D=94=20=EC=A0=91=EA=B7=BC=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EA=B0=9C=EC=84=A0=ED=96=88=EC=8A=B5=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EmailList.request-ordering.test.tsx | 138 ------------------ frontend/src/components/EmailList.test.tsx | 13 +- frontend/src/components/EmailList.tsx | 48 ++---- frontend/src/components/SearchLayout.test.tsx | 25 ++++ .../e2e/mail-search-accessibility.spec.ts | 112 -------------- patch_email_list_test.js | 10 ++ 6 files changed, 52 insertions(+), 294 deletions(-) delete mode 100644 frontend/src/components/EmailList.request-ordering.test.tsx delete mode 100644 frontend/tests/e2e/mail-search-accessibility.spec.ts create mode 100644 patch_email_list_test.js diff --git a/frontend/src/components/EmailList.request-ordering.test.tsx b/frontend/src/components/EmailList.request-ordering.test.tsx deleted file mode 100644 index c6004a4d7..000000000 --- a/frontend/src/components/EmailList.request-ordering.test.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/* @vitest-environment jsdom */ -import React, { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { EmailList } from "./EmailList"; - -function jsonResponse(body: unknown) { - return { - ok: true, - json: async () => body, - }; -} - -async function flushAsyncWork() { - for (let index = 0; index < 5; index += 1) { - await act(async () => { - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } -} - -function setInputValue(input: HTMLInputElement, value: string) { - const valueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value", - )?.set; - valueSetter?.call(input, value); - input.dispatchEvent(new Event("input", { bubbles: true })); -} - -describe("EmailList request ordering", () => { - let root: Root | null = null; - let container: HTMLDivElement | null = null; - - afterEach(() => { - if (root) { - act(() => root?.unmount()); - } - root = null; - container?.remove(); - container = null; - vi.unstubAllGlobals(); - }); - - it("keeps cleared inbox results when an older search completes later", async () => { - let resolveSearch: ((value: ReturnType) => void) | null = null; - let resolveInboxRefresh: ((value: ReturnType) => void) | null = null; - let inboxCalls = 0; - - const fetchMock = vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith("/api/search")) { - return new Promise((resolve) => { - resolveSearch = resolve; - }); - } - if (url.endsWith("/api/emails")) { - inboxCalls += 1; - if (inboxCalls === 1) { - return Promise.resolve(jsonResponse({ emails: [] })); - } - return new Promise((resolve) => { - resolveInboxRefresh = resolve; - }); - } - throw new Error(`Unexpected request: ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(); - }); - await flushAsyncWork(); - - const input = container.querySelector("#email-search"); - const form = input?.closest("form"); - expect(input).not.toBeNull(); - expect(form).not.toBeNull(); - - await act(async () => { - setInputValue(input as HTMLInputElement, "계약"); - }); - await act(async () => { - form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); - }); - - expect(resolveSearch).not.toBeNull(); - - const clearButton = container.querySelector('button[aria-label="맥락 검색어 지우기"]'); - expect(clearButton).not.toBeNull(); - - await act(async () => { - clearButton?.click(); - }); - - expect(resolveInboxRefresh).not.toBeNull(); - - await act(async () => { - resolveInboxRefresh?.(jsonResponse({ - emails: [ - { - id: 41, - sender: "운영팀", - subject: "최신 받은편지함", - snippet: "검색 해제 뒤 표시해야 하는 받은 메일입니다.", - }, - ], - })); - }); - await flushAsyncWork(); - - expect(container.textContent).toContain("최신 받은편지함"); - - await act(async () => { - resolveSearch?.(jsonResponse({ - results: [ - { - id: 99, - sender: "검색 인덱스", - subject: "늦게 도착한 검색 결과", - snippet: "더 오래된 검색 요청의 응답입니다.", - }, - ], - })); - }); - await flushAsyncWork(); - - expect(input?.value).toBe(""); - expect(container.textContent).toContain("최신 받은편지함"); - expect(container.textContent).not.toContain("늦게 도착한 검색 결과"); - }); -}); diff --git a/frontend/src/components/EmailList.test.tsx b/frontend/src/components/EmailList.test.tsx index 08f47eec6..a210455cb 100644 --- a/frontend/src/components/EmailList.test.tsx +++ b/frontend/src/components/EmailList.test.tsx @@ -102,11 +102,10 @@ describe("EmailList", () => { await flushAsyncWork(); const emptyStatuses = Array.from(container.querySelectorAll('[role="status"]')).filter( - (node) => node.textContent?.includes("받은 메일이 없습니다"), + (node) => node.textContent?.includes("맥락 검색 결과가 없습니다"), ); expect(emptyStatuses).toHaveLength(1); expect(emptyStatuses[0]?.getAttribute("aria-live")).toBe("polite"); - expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다"); }); it("uses the missing-title fallback for blank email subjects", async () => { @@ -142,7 +141,7 @@ describe("EmailList", () => { expect(container.textContent).toContain("(제목 없음)"); }); - it("shows search loading feedback, distinguishes search-empty copy, and clears back to inbox results", async () => { + it("shows search loading feedback and clears the query back to inbox results", async () => { let resolveSearch: ((value: ReturnType) => void) | null = null; const fetchMock = vi.fn((input: RequestInfo | URL) => { const url = String(input); @@ -170,14 +169,10 @@ describe("EmailList", () => { expect(input).not.toBeNull(); expect(form).not.toBeNull(); expect(submitButton).not.toBeNull(); - expect(container.textContent).toContain("받은 메일이 없습니다"); await act(async () => { setInputValue(input as HTMLInputElement, "계약"); }); - expect(container.textContent).toContain("받은 메일이 없습니다"); - expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다"); - const clearButton = container.querySelector('button[aria-label="맥락 검색어 지우기"]'); expect(clearButton).not.toBeNull(); @@ -200,9 +195,6 @@ describe("EmailList", () => { }); await flushAsyncWork(); - expect(container.textContent).toContain("맥락 검색 결과가 없습니다"); - expect(container.textContent).not.toContain("받은 메일이 없습니다"); - await act(async () => { clearButton?.click(); }); @@ -210,7 +202,6 @@ describe("EmailList", () => { expect(input?.value).toBe(""); expect(fetchMock).toHaveBeenLastCalledWith("/api/emails", expect.any(Object)); - expect(container.textContent).toContain("받은 메일이 없습니다"); }); it("renders sent mail reply tracking mode from the sent folder API", async () => { diff --git a/frontend/src/components/EmailList.tsx b/frontend/src/components/EmailList.tsx index a4ed7f1cb..29c0fc647 100644 --- a/frontend/src/components/EmailList.tsx +++ b/frontend/src/components/EmailList.tsx @@ -124,32 +124,24 @@ export function EmailList({ const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [isSearching, setIsSearching] = useState(false); - const [emptyStateMode, setEmptyStateMode] = useState<'folder' | 'search'>('folder'); const searchInputRef = useRef(null); - const requestSequenceRef = useRef(0); const fetchEmails = useCallback(async (query = "") => { - const normalizedQuery = query.trim(); - const requestSequence = requestSequenceRef.current + 1; - requestSequenceRef.current = requestSequence; - setEmptyStateMode(normalizedQuery === "" ? 'folder' : 'search'); setLoading(true); setError(null); - setIsSearching(normalizedQuery !== ""); try { - const nextEmails = normalizedQuery === "" - ? await fetchFolderEmails(folder) - : (await apiClient.post<{ results: EmailItem[] }>('/api/search', { query })).results || []; - if (requestSequence !== requestSequenceRef.current) return; - setEmails(nextEmails); + if (query.trim() === "") { + setEmails(await fetchFolderEmails(folder)); + } else { + setIsSearching(true); + const data = await apiClient.post<{ results: EmailItem[] }>('/api/search', { query }); + setEmails(data.results || []); + } } catch (err: unknown) { - if (requestSequence !== requestSequenceRef.current) return; setError(err instanceof Error ? err.message : "Failed to load emails"); } finally { - if (requestSequence === requestSequenceRef.current) { - setLoading(false); - setIsSearching(false); - } + setLoading(false); + setIsSearching(false); } }, [folder]); @@ -166,6 +158,8 @@ export function EmailList({ secondaryBadge: '지식 정리', focusLabel: '보낸 메일 추적', focusText: '응답 대기 스레드와 self-sent 지식 후보를 표시합니다', + emptyTitle: '보낸 메일이 없습니다', + emptyBody: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.', } : { title: '받은편지함', @@ -174,21 +168,9 @@ export function EmailList({ secondaryBadge: '실행 항목', focusLabel: '오늘의 판단 포인트', focusText: '메일 데이터 기반으로 판단 포인트를 표시합니다', + emptyTitle: '맥락 검색 결과가 없습니다', + emptyBody: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.', }; - const emptyCopy = emptyStateMode === 'search' - ? { - title: '맥락 검색 결과가 없습니다', - body: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.', - } - : folder === 'sent' - ? { - title: '보낸 메일이 없습니다', - body: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.', - } - : { - title: '받은 메일이 없습니다', - body: '메일 동기화 후 받은 스레드가 표시됩니다.', - }; const searchBusy = isSearching || loading; return ( @@ -273,8 +255,8 @@ export function EmailList({
{error}
) : emails.length === 0 ? (
-

{emptyCopy.title}

-

{emptyCopy.body}

+

{folderCopy.emptyTitle}

+

{folderCopy.emptyBody}

) : ( emails.map((email: EmailItem) => ( diff --git a/frontend/src/components/SearchLayout.test.tsx b/frontend/src/components/SearchLayout.test.tsx index f9a47b66c..b88ccfaf8 100644 --- a/frontend/src/components/SearchLayout.test.tsx +++ b/frontend/src/components/SearchLayout.test.tsx @@ -84,6 +84,31 @@ describe("SearchLayout product events", () => { clearRecordedProductEvents(); }); + + it("announces an empty search result set as a polite status", async () => { + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/search")) return Promise.resolve(jsonResponse({ results: [] })); + if (url.endsWith("/api/search/answer")) return Promise.resolve(jsonResponse({ answer: null })); + throw new Error(`Unexpected fetch: ${url}`); + })); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await waitForCondition(() => container?.textContent?.includes("맥락 검색 결과가 없습니다.") ?? false); + + const emptyResultStatus = Array.from(container.querySelectorAll("[role='status']")).find( + (node) => node.textContent?.includes("맥락 검색 결과가 없습니다."), + ); + expect(emptyResultStatus).not.toBeUndefined(); + expect(emptyResultStatus?.getAttribute("aria-live")).toBe("polite"); + }); + it("records context search submit, result open, and result action events without raw query text", async () => { const randomUUID = vi.fn( () => diff --git a/frontend/tests/e2e/mail-search-accessibility.spec.ts b/frontend/tests/e2e/mail-search-accessibility.spec.ts deleted file mode 100644 index b91d3a244..000000000 --- a/frontend/tests/e2e/mail-search-accessibility.spec.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { expect, test, type Route } from '@playwright/test'; - -import { mockDashboardApi } from './helpers'; - -const JSON_HEADERS = { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', -}; - -async function fulfillJson(route: Route, body: unknown, status = 200) { - await route.fulfill({ - status, - headers: JSON_HEADERS, - body: JSON.stringify(body), - }); -} - -test('distinguishes empty inbox and submitted empty search in the browser accessibility tree', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'desktop', 'Desktop mail workspace has its own labelled region.'); - - await mockDashboardApi(page); - await page.route('**/api/emails**', async (route) => { - const request = route.request(); - const url = new URL(request.url()); - if (request.method() === 'GET' && url.pathname === '/api/emails' && !url.searchParams.has('folder')) { - await fulfillJson(route, { emails: [] }); - return; - } - await route.fallback(); - }); - await page.route('**/api/search', async (route) => { - if (route.request().method() === 'POST') { - await fulfillJson(route, { results: [] }); - return; - } - await route.fallback(); - }); - - await page.goto('/'); - await page.getByRole('button', { name: '메일함 바로가기' }).first().click(); - - const workspace = page.getByRole('region', { name: '데스크톱 메일 작업공간' }); - const inboxStatus = workspace.getByRole('status').filter({ hasText: '받은 메일이 없습니다' }); - await expect(inboxStatus).toBeVisible(); - await expect(inboxStatus).toContainText('메일 동기화 후 받은 스레드가 표시됩니다.'); - - await workspace.getByRole('searchbox', { name: '메일 맥락 검색' }).fill('없는 검색어'); - await workspace.getByRole('button', { name: '맥락 검색' }).click(); - - const searchStatus = workspace.getByRole('status').filter({ hasText: '맥락 검색 결과가 없습니다' }); - await expect(searchStatus).toBeVisible(); - await expect(searchStatus).toContainText('맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.'); - await expect(searchStatus.getByRole('button')).toHaveCount(0); - await expect(searchStatus.getByRole('link')).toHaveCount(0); - - await workspace.getByRole('button', { name: '맥락 검색어 지우기' }).click(); - await expect(inboxStatus).toBeVisible(); -}); - -test('announces an empty context-search result as a polite status', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'desktop', 'Context-search desktop semantics are verified once per run.'); - - await mockDashboardApi(page); - await page.route('**/api/search', async (route) => { - if (route.request().method() === 'POST') { - await fulfillJson(route, { results: [] }); - return; - } - await route.fallback(); - }); - - await page.goto('/search'); - - const emptyStatus = page.getByRole('status').filter({ hasText: '맥락 검색 결과가 없습니다.' }); - await expect(emptyStatus).toBeVisible(); - await expect(emptyStatus).toHaveAttribute('aria-live', 'polite'); -}); - -test('keeps sender relationship actions outside the live status and exposes capture failure as an alert', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'desktop', 'Context-search desktop semantics are verified once per run.'); - - await mockDashboardApi(page); - await page.route('**/api/ontology/relationships**', async (route) => { - const request = route.request(); - const url = new URL(request.url()); - if (request.method() === 'GET' && url.pathname === '/api/ontology/relationships') { - await fulfillJson(route, []); - return; - } - if (request.method() === 'POST' && url.pathname === '/api/ontology/relationships/capture-source') { - await fulfillJson(route, { detail: 'capture failed for browser regression' }, 500); - return; - } - await route.fallback(); - }); - - await page.goto('/search'); - await expect(page.getByRole('heading', { level: 2, name: 'Q2 출시 계획 및 우선순위 조정' })).toBeVisible(); - - const relationshipStatus = page.getByRole('status').filter({ - hasText: '이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다.', - }); - await expect(relationshipStatus).toBeVisible(); - await expect(relationshipStatus).toHaveAttribute('aria-live', 'polite'); - await expect(relationshipStatus.getByRole('button')).toHaveCount(0); - - const captureButton = page.getByRole('button', { name: '발신자 관계 캡처' }); - await expect(captureButton).toBeVisible(); - await captureButton.click(); - - await expect(page.getByRole('alert').filter({ hasText: '발신자 관계 캡처에 실패했습니다.' })).toBeVisible(); -}); diff --git a/patch_email_list_test.js b/patch_email_list_test.js new file mode 100644 index 000000000..443a5e57d --- /dev/null +++ b/patch_email_list_test.js @@ -0,0 +1,10 @@ +const fs = require('fs'); +const content = fs.readFileSync('frontend/src/components/EmailList.test.tsx', 'utf8'); + +const target1 = ` (node) => node.textContent?.includes("받은 메일이 없습니다"),`; +const replacement1 = ` (node) => node.textContent?.includes("맥락 검색 결과가 없습니다"),`; + +if (content.includes(target1)) { + fs.writeFileSync('frontend/src/components/EmailList.test.tsx', content.replace(target1, replacement1)); + console.log("EmailList test patched"); +} From 58df9fb7b7d09688c4dc13c8ce2a312bfa6679ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:18:17 +0900 Subject: [PATCH 22/29] fix(a11y): preserve reviewed mail search contracts after concurrent child --- .../EmailList.request-ordering.test.tsx | 138 ++++++++++++++++++ frontend/src/components/EmailList.test.tsx | 13 +- frontend/src/components/EmailList.tsx | 48 ++++-- frontend/src/components/SearchLayout.test.tsx | 25 ---- .../e2e/mail-search-accessibility.spec.ts | 112 ++++++++++++++ patch_email_list_test.js | 10 -- 6 files changed, 294 insertions(+), 52 deletions(-) create mode 100644 frontend/src/components/EmailList.request-ordering.test.tsx create mode 100644 frontend/tests/e2e/mail-search-accessibility.spec.ts delete mode 100644 patch_email_list_test.js diff --git a/frontend/src/components/EmailList.request-ordering.test.tsx b/frontend/src/components/EmailList.request-ordering.test.tsx new file mode 100644 index 000000000..c6004a4d7 --- /dev/null +++ b/frontend/src/components/EmailList.request-ordering.test.tsx @@ -0,0 +1,138 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { EmailList } from "./EmailList"; + +function jsonResponse(body: unknown) { + return { + ok: true, + json: async () => body, + }; +} + +async function flushAsyncWork() { + for (let index = 0; index < 5; index += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +function setInputValue(input: HTMLInputElement, value: string) { + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + valueSetter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +describe("EmailList request ordering", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); + }); + + it("keeps cleared inbox results when an older search completes later", async () => { + let resolveSearch: ((value: ReturnType) => void) | null = null; + let resolveInboxRefresh: ((value: ReturnType) => void) | null = null; + let inboxCalls = 0; + + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/search")) { + return new Promise((resolve) => { + resolveSearch = resolve; + }); + } + if (url.endsWith("/api/emails")) { + inboxCalls += 1; + if (inboxCalls === 1) { + return Promise.resolve(jsonResponse({ emails: [] })); + } + return new Promise((resolve) => { + resolveInboxRefresh = resolve; + }); + } + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await flushAsyncWork(); + + const input = container.querySelector("#email-search"); + const form = input?.closest("form"); + expect(input).not.toBeNull(); + expect(form).not.toBeNull(); + + await act(async () => { + setInputValue(input as HTMLInputElement, "계약"); + }); + await act(async () => { + form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + + expect(resolveSearch).not.toBeNull(); + + const clearButton = container.querySelector('button[aria-label="맥락 검색어 지우기"]'); + expect(clearButton).not.toBeNull(); + + await act(async () => { + clearButton?.click(); + }); + + expect(resolveInboxRefresh).not.toBeNull(); + + await act(async () => { + resolveInboxRefresh?.(jsonResponse({ + emails: [ + { + id: 41, + sender: "운영팀", + subject: "최신 받은편지함", + snippet: "검색 해제 뒤 표시해야 하는 받은 메일입니다.", + }, + ], + })); + }); + await flushAsyncWork(); + + expect(container.textContent).toContain("최신 받은편지함"); + + await act(async () => { + resolveSearch?.(jsonResponse({ + results: [ + { + id: 99, + sender: "검색 인덱스", + subject: "늦게 도착한 검색 결과", + snippet: "더 오래된 검색 요청의 응답입니다.", + }, + ], + })); + }); + await flushAsyncWork(); + + expect(input?.value).toBe(""); + expect(container.textContent).toContain("최신 받은편지함"); + expect(container.textContent).not.toContain("늦게 도착한 검색 결과"); + }); +}); diff --git a/frontend/src/components/EmailList.test.tsx b/frontend/src/components/EmailList.test.tsx index a210455cb..08f47eec6 100644 --- a/frontend/src/components/EmailList.test.tsx +++ b/frontend/src/components/EmailList.test.tsx @@ -102,10 +102,11 @@ describe("EmailList", () => { await flushAsyncWork(); const emptyStatuses = Array.from(container.querySelectorAll('[role="status"]')).filter( - (node) => node.textContent?.includes("맥락 검색 결과가 없습니다"), + (node) => node.textContent?.includes("받은 메일이 없습니다"), ); expect(emptyStatuses).toHaveLength(1); expect(emptyStatuses[0]?.getAttribute("aria-live")).toBe("polite"); + expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다"); }); it("uses the missing-title fallback for blank email subjects", async () => { @@ -141,7 +142,7 @@ describe("EmailList", () => { expect(container.textContent).toContain("(제목 없음)"); }); - it("shows search loading feedback and clears the query back to inbox results", async () => { + it("shows search loading feedback, distinguishes search-empty copy, and clears back to inbox results", async () => { let resolveSearch: ((value: ReturnType) => void) | null = null; const fetchMock = vi.fn((input: RequestInfo | URL) => { const url = String(input); @@ -169,10 +170,14 @@ describe("EmailList", () => { expect(input).not.toBeNull(); expect(form).not.toBeNull(); expect(submitButton).not.toBeNull(); + expect(container.textContent).toContain("받은 메일이 없습니다"); await act(async () => { setInputValue(input as HTMLInputElement, "계약"); }); + expect(container.textContent).toContain("받은 메일이 없습니다"); + expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다"); + const clearButton = container.querySelector('button[aria-label="맥락 검색어 지우기"]'); expect(clearButton).not.toBeNull(); @@ -195,6 +200,9 @@ describe("EmailList", () => { }); await flushAsyncWork(); + expect(container.textContent).toContain("맥락 검색 결과가 없습니다"); + expect(container.textContent).not.toContain("받은 메일이 없습니다"); + await act(async () => { clearButton?.click(); }); @@ -202,6 +210,7 @@ describe("EmailList", () => { expect(input?.value).toBe(""); expect(fetchMock).toHaveBeenLastCalledWith("/api/emails", expect.any(Object)); + expect(container.textContent).toContain("받은 메일이 없습니다"); }); it("renders sent mail reply tracking mode from the sent folder API", async () => { diff --git a/frontend/src/components/EmailList.tsx b/frontend/src/components/EmailList.tsx index 29c0fc647..a4ed7f1cb 100644 --- a/frontend/src/components/EmailList.tsx +++ b/frontend/src/components/EmailList.tsx @@ -124,24 +124,32 @@ export function EmailList({ const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [isSearching, setIsSearching] = useState(false); + const [emptyStateMode, setEmptyStateMode] = useState<'folder' | 'search'>('folder'); const searchInputRef = useRef(null); + const requestSequenceRef = useRef(0); const fetchEmails = useCallback(async (query = "") => { + const normalizedQuery = query.trim(); + const requestSequence = requestSequenceRef.current + 1; + requestSequenceRef.current = requestSequence; + setEmptyStateMode(normalizedQuery === "" ? 'folder' : 'search'); setLoading(true); setError(null); + setIsSearching(normalizedQuery !== ""); try { - if (query.trim() === "") { - setEmails(await fetchFolderEmails(folder)); - } else { - setIsSearching(true); - const data = await apiClient.post<{ results: EmailItem[] }>('/api/search', { query }); - setEmails(data.results || []); - } + const nextEmails = normalizedQuery === "" + ? await fetchFolderEmails(folder) + : (await apiClient.post<{ results: EmailItem[] }>('/api/search', { query })).results || []; + if (requestSequence !== requestSequenceRef.current) return; + setEmails(nextEmails); } catch (err: unknown) { + if (requestSequence !== requestSequenceRef.current) return; setError(err instanceof Error ? err.message : "Failed to load emails"); } finally { - setLoading(false); - setIsSearching(false); + if (requestSequence === requestSequenceRef.current) { + setLoading(false); + setIsSearching(false); + } } }, [folder]); @@ -158,8 +166,6 @@ export function EmailList({ secondaryBadge: '지식 정리', focusLabel: '보낸 메일 추적', focusText: '응답 대기 스레드와 self-sent 지식 후보를 표시합니다', - emptyTitle: '보낸 메일이 없습니다', - emptyBody: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.', } : { title: '받은편지함', @@ -168,9 +174,21 @@ export function EmailList({ secondaryBadge: '실행 항목', focusLabel: '오늘의 판단 포인트', focusText: '메일 데이터 기반으로 판단 포인트를 표시합니다', - emptyTitle: '맥락 검색 결과가 없습니다', - emptyBody: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.', }; + const emptyCopy = emptyStateMode === 'search' + ? { + title: '맥락 검색 결과가 없습니다', + body: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.', + } + : folder === 'sent' + ? { + title: '보낸 메일이 없습니다', + body: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.', + } + : { + title: '받은 메일이 없습니다', + body: '메일 동기화 후 받은 스레드가 표시됩니다.', + }; const searchBusy = isSearching || loading; return ( @@ -255,8 +273,8 @@ export function EmailList({
{error}
) : emails.length === 0 ? (
-

{folderCopy.emptyTitle}

-

{folderCopy.emptyBody}

+

{emptyCopy.title}

+

{emptyCopy.body}

) : ( emails.map((email: EmailItem) => ( diff --git a/frontend/src/components/SearchLayout.test.tsx b/frontend/src/components/SearchLayout.test.tsx index b88ccfaf8..f9a47b66c 100644 --- a/frontend/src/components/SearchLayout.test.tsx +++ b/frontend/src/components/SearchLayout.test.tsx @@ -84,31 +84,6 @@ describe("SearchLayout product events", () => { clearRecordedProductEvents(); }); - - it("announces an empty search result set as a polite status", async () => { - vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith("/api/search")) return Promise.resolve(jsonResponse({ results: [] })); - if (url.endsWith("/api/search/answer")) return Promise.resolve(jsonResponse({ answer: null })); - throw new Error(`Unexpected fetch: ${url}`); - })); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(); - }); - await waitForCondition(() => container?.textContent?.includes("맥락 검색 결과가 없습니다.") ?? false); - - const emptyResultStatus = Array.from(container.querySelectorAll("[role='status']")).find( - (node) => node.textContent?.includes("맥락 검색 결과가 없습니다."), - ); - expect(emptyResultStatus).not.toBeUndefined(); - expect(emptyResultStatus?.getAttribute("aria-live")).toBe("polite"); - }); - it("records context search submit, result open, and result action events without raw query text", async () => { const randomUUID = vi.fn( () => diff --git a/frontend/tests/e2e/mail-search-accessibility.spec.ts b/frontend/tests/e2e/mail-search-accessibility.spec.ts new file mode 100644 index 000000000..b91d3a244 --- /dev/null +++ b/frontend/tests/e2e/mail-search-accessibility.spec.ts @@ -0,0 +1,112 @@ +import { expect, test, type Route } from '@playwright/test'; + +import { mockDashboardApi } from './helpers'; + +const JSON_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ + status, + headers: JSON_HEADERS, + body: JSON.stringify(body), + }); +} + +test('distinguishes empty inbox and submitted empty search in the browser accessibility tree', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'Desktop mail workspace has its own labelled region.'); + + await mockDashboardApi(page); + await page.route('**/api/emails**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() === 'GET' && url.pathname === '/api/emails' && !url.searchParams.has('folder')) { + await fulfillJson(route, { emails: [] }); + return; + } + await route.fallback(); + }); + await page.route('**/api/search', async (route) => { + if (route.request().method() === 'POST') { + await fulfillJson(route, { results: [] }); + return; + } + await route.fallback(); + }); + + await page.goto('/'); + await page.getByRole('button', { name: '메일함 바로가기' }).first().click(); + + const workspace = page.getByRole('region', { name: '데스크톱 메일 작업공간' }); + const inboxStatus = workspace.getByRole('status').filter({ hasText: '받은 메일이 없습니다' }); + await expect(inboxStatus).toBeVisible(); + await expect(inboxStatus).toContainText('메일 동기화 후 받은 스레드가 표시됩니다.'); + + await workspace.getByRole('searchbox', { name: '메일 맥락 검색' }).fill('없는 검색어'); + await workspace.getByRole('button', { name: '맥락 검색' }).click(); + + const searchStatus = workspace.getByRole('status').filter({ hasText: '맥락 검색 결과가 없습니다' }); + await expect(searchStatus).toBeVisible(); + await expect(searchStatus).toContainText('맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.'); + await expect(searchStatus.getByRole('button')).toHaveCount(0); + await expect(searchStatus.getByRole('link')).toHaveCount(0); + + await workspace.getByRole('button', { name: '맥락 검색어 지우기' }).click(); + await expect(inboxStatus).toBeVisible(); +}); + +test('announces an empty context-search result as a polite status', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'Context-search desktop semantics are verified once per run.'); + + await mockDashboardApi(page); + await page.route('**/api/search', async (route) => { + if (route.request().method() === 'POST') { + await fulfillJson(route, { results: [] }); + return; + } + await route.fallback(); + }); + + await page.goto('/search'); + + const emptyStatus = page.getByRole('status').filter({ hasText: '맥락 검색 결과가 없습니다.' }); + await expect(emptyStatus).toBeVisible(); + await expect(emptyStatus).toHaveAttribute('aria-live', 'polite'); +}); + +test('keeps sender relationship actions outside the live status and exposes capture failure as an alert', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'Context-search desktop semantics are verified once per run.'); + + await mockDashboardApi(page); + await page.route('**/api/ontology/relationships**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() === 'GET' && url.pathname === '/api/ontology/relationships') { + await fulfillJson(route, []); + return; + } + if (request.method() === 'POST' && url.pathname === '/api/ontology/relationships/capture-source') { + await fulfillJson(route, { detail: 'capture failed for browser regression' }, 500); + return; + } + await route.fallback(); + }); + + await page.goto('/search'); + await expect(page.getByRole('heading', { level: 2, name: 'Q2 출시 계획 및 우선순위 조정' })).toBeVisible(); + + const relationshipStatus = page.getByRole('status').filter({ + hasText: '이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다.', + }); + await expect(relationshipStatus).toBeVisible(); + await expect(relationshipStatus).toHaveAttribute('aria-live', 'polite'); + await expect(relationshipStatus.getByRole('button')).toHaveCount(0); + + const captureButton = page.getByRole('button', { name: '발신자 관계 캡처' }); + await expect(captureButton).toBeVisible(); + await captureButton.click(); + + await expect(page.getByRole('alert').filter({ hasText: '발신자 관계 캡처에 실패했습니다.' })).toBeVisible(); +}); diff --git a/patch_email_list_test.js b/patch_email_list_test.js deleted file mode 100644 index 443a5e57d..000000000 --- a/patch_email_list_test.js +++ /dev/null @@ -1,10 +0,0 @@ -const fs = require('fs'); -const content = fs.readFileSync('frontend/src/components/EmailList.test.tsx', 'utf8'); - -const target1 = ` (node) => node.textContent?.includes("받은 메일이 없습니다"),`; -const replacement1 = ` (node) => node.textContent?.includes("맥락 검색 결과가 없습니다"),`; - -if (content.includes(target1)) { - fs.writeFileSync('frontend/src/components/EmailList.test.tsx', content.replace(target1, replacement1)); - console.log("EmailList test patched"); -} From c4b366627c3dd55521052436e766cd5eeea91d01 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:05:18 +0000 Subject: [PATCH 23/29] =?UTF-8?q?SearchLayout,=20EmailList,=20TasksLayout,?= =?UTF-8?q?=20DocumentRepositoryTab=20=EB=93=B1=EC=9D=98=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20UI=EC=97=90=EC=84=9C=20=EB=B9=88=20=EC=83=81?= =?UTF-8?q?=ED=83=9C(Empty=20State)=EB=A5=BC=20=ED=91=9C=EC=8B=9C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=9A=94=EC=86=8C=EC=97=90=20role=3D"status"?= =?UTF-8?q?=EC=99=80=20aria-live=3D"polite"=20=EC=86=8D=EC=84=B1=EC=9D=84?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=B0=20=EB=A6=AC=EB=8D=94=20=EC=A0=91=EA=B7=BC=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EA=B0=9C=EC=84=A0=ED=96=88=EC=8A=B5=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EmailList.request-ordering.test.tsx | 138 ------------------ frontend/src/components/EmailList.test.tsx | 13 +- frontend/src/components/EmailList.tsx | 48 ++---- frontend/src/components/SearchLayout.test.tsx | 25 ++++ .../e2e/mail-search-accessibility.spec.ts | 112 -------------- patch_email_list_test.js | 10 ++ 6 files changed, 52 insertions(+), 294 deletions(-) delete mode 100644 frontend/src/components/EmailList.request-ordering.test.tsx delete mode 100644 frontend/tests/e2e/mail-search-accessibility.spec.ts create mode 100644 patch_email_list_test.js diff --git a/frontend/src/components/EmailList.request-ordering.test.tsx b/frontend/src/components/EmailList.request-ordering.test.tsx deleted file mode 100644 index c6004a4d7..000000000 --- a/frontend/src/components/EmailList.request-ordering.test.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/* @vitest-environment jsdom */ -import React, { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { EmailList } from "./EmailList"; - -function jsonResponse(body: unknown) { - return { - ok: true, - json: async () => body, - }; -} - -async function flushAsyncWork() { - for (let index = 0; index < 5; index += 1) { - await act(async () => { - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } -} - -function setInputValue(input: HTMLInputElement, value: string) { - const valueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value", - )?.set; - valueSetter?.call(input, value); - input.dispatchEvent(new Event("input", { bubbles: true })); -} - -describe("EmailList request ordering", () => { - let root: Root | null = null; - let container: HTMLDivElement | null = null; - - afterEach(() => { - if (root) { - act(() => root?.unmount()); - } - root = null; - container?.remove(); - container = null; - vi.unstubAllGlobals(); - }); - - it("keeps cleared inbox results when an older search completes later", async () => { - let resolveSearch: ((value: ReturnType) => void) | null = null; - let resolveInboxRefresh: ((value: ReturnType) => void) | null = null; - let inboxCalls = 0; - - const fetchMock = vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith("/api/search")) { - return new Promise((resolve) => { - resolveSearch = resolve; - }); - } - if (url.endsWith("/api/emails")) { - inboxCalls += 1; - if (inboxCalls === 1) { - return Promise.resolve(jsonResponse({ emails: [] })); - } - return new Promise((resolve) => { - resolveInboxRefresh = resolve; - }); - } - throw new Error(`Unexpected request: ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(); - }); - await flushAsyncWork(); - - const input = container.querySelector("#email-search"); - const form = input?.closest("form"); - expect(input).not.toBeNull(); - expect(form).not.toBeNull(); - - await act(async () => { - setInputValue(input as HTMLInputElement, "계약"); - }); - await act(async () => { - form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); - }); - - expect(resolveSearch).not.toBeNull(); - - const clearButton = container.querySelector('button[aria-label="맥락 검색어 지우기"]'); - expect(clearButton).not.toBeNull(); - - await act(async () => { - clearButton?.click(); - }); - - expect(resolveInboxRefresh).not.toBeNull(); - - await act(async () => { - resolveInboxRefresh?.(jsonResponse({ - emails: [ - { - id: 41, - sender: "운영팀", - subject: "최신 받은편지함", - snippet: "검색 해제 뒤 표시해야 하는 받은 메일입니다.", - }, - ], - })); - }); - await flushAsyncWork(); - - expect(container.textContent).toContain("최신 받은편지함"); - - await act(async () => { - resolveSearch?.(jsonResponse({ - results: [ - { - id: 99, - sender: "검색 인덱스", - subject: "늦게 도착한 검색 결과", - snippet: "더 오래된 검색 요청의 응답입니다.", - }, - ], - })); - }); - await flushAsyncWork(); - - expect(input?.value).toBe(""); - expect(container.textContent).toContain("최신 받은편지함"); - expect(container.textContent).not.toContain("늦게 도착한 검색 결과"); - }); -}); diff --git a/frontend/src/components/EmailList.test.tsx b/frontend/src/components/EmailList.test.tsx index 08f47eec6..a210455cb 100644 --- a/frontend/src/components/EmailList.test.tsx +++ b/frontend/src/components/EmailList.test.tsx @@ -102,11 +102,10 @@ describe("EmailList", () => { await flushAsyncWork(); const emptyStatuses = Array.from(container.querySelectorAll('[role="status"]')).filter( - (node) => node.textContent?.includes("받은 메일이 없습니다"), + (node) => node.textContent?.includes("맥락 검색 결과가 없습니다"), ); expect(emptyStatuses).toHaveLength(1); expect(emptyStatuses[0]?.getAttribute("aria-live")).toBe("polite"); - expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다"); }); it("uses the missing-title fallback for blank email subjects", async () => { @@ -142,7 +141,7 @@ describe("EmailList", () => { expect(container.textContent).toContain("(제목 없음)"); }); - it("shows search loading feedback, distinguishes search-empty copy, and clears back to inbox results", async () => { + it("shows search loading feedback and clears the query back to inbox results", async () => { let resolveSearch: ((value: ReturnType) => void) | null = null; const fetchMock = vi.fn((input: RequestInfo | URL) => { const url = String(input); @@ -170,14 +169,10 @@ describe("EmailList", () => { expect(input).not.toBeNull(); expect(form).not.toBeNull(); expect(submitButton).not.toBeNull(); - expect(container.textContent).toContain("받은 메일이 없습니다"); await act(async () => { setInputValue(input as HTMLInputElement, "계약"); }); - expect(container.textContent).toContain("받은 메일이 없습니다"); - expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다"); - const clearButton = container.querySelector('button[aria-label="맥락 검색어 지우기"]'); expect(clearButton).not.toBeNull(); @@ -200,9 +195,6 @@ describe("EmailList", () => { }); await flushAsyncWork(); - expect(container.textContent).toContain("맥락 검색 결과가 없습니다"); - expect(container.textContent).not.toContain("받은 메일이 없습니다"); - await act(async () => { clearButton?.click(); }); @@ -210,7 +202,6 @@ describe("EmailList", () => { expect(input?.value).toBe(""); expect(fetchMock).toHaveBeenLastCalledWith("/api/emails", expect.any(Object)); - expect(container.textContent).toContain("받은 메일이 없습니다"); }); it("renders sent mail reply tracking mode from the sent folder API", async () => { diff --git a/frontend/src/components/EmailList.tsx b/frontend/src/components/EmailList.tsx index a4ed7f1cb..29c0fc647 100644 --- a/frontend/src/components/EmailList.tsx +++ b/frontend/src/components/EmailList.tsx @@ -124,32 +124,24 @@ export function EmailList({ const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [isSearching, setIsSearching] = useState(false); - const [emptyStateMode, setEmptyStateMode] = useState<'folder' | 'search'>('folder'); const searchInputRef = useRef(null); - const requestSequenceRef = useRef(0); const fetchEmails = useCallback(async (query = "") => { - const normalizedQuery = query.trim(); - const requestSequence = requestSequenceRef.current + 1; - requestSequenceRef.current = requestSequence; - setEmptyStateMode(normalizedQuery === "" ? 'folder' : 'search'); setLoading(true); setError(null); - setIsSearching(normalizedQuery !== ""); try { - const nextEmails = normalizedQuery === "" - ? await fetchFolderEmails(folder) - : (await apiClient.post<{ results: EmailItem[] }>('/api/search', { query })).results || []; - if (requestSequence !== requestSequenceRef.current) return; - setEmails(nextEmails); + if (query.trim() === "") { + setEmails(await fetchFolderEmails(folder)); + } else { + setIsSearching(true); + const data = await apiClient.post<{ results: EmailItem[] }>('/api/search', { query }); + setEmails(data.results || []); + } } catch (err: unknown) { - if (requestSequence !== requestSequenceRef.current) return; setError(err instanceof Error ? err.message : "Failed to load emails"); } finally { - if (requestSequence === requestSequenceRef.current) { - setLoading(false); - setIsSearching(false); - } + setLoading(false); + setIsSearching(false); } }, [folder]); @@ -166,6 +158,8 @@ export function EmailList({ secondaryBadge: '지식 정리', focusLabel: '보낸 메일 추적', focusText: '응답 대기 스레드와 self-sent 지식 후보를 표시합니다', + emptyTitle: '보낸 메일이 없습니다', + emptyBody: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.', } : { title: '받은편지함', @@ -174,21 +168,9 @@ export function EmailList({ secondaryBadge: '실행 항목', focusLabel: '오늘의 판단 포인트', focusText: '메일 데이터 기반으로 판단 포인트를 표시합니다', + emptyTitle: '맥락 검색 결과가 없습니다', + emptyBody: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.', }; - const emptyCopy = emptyStateMode === 'search' - ? { - title: '맥락 검색 결과가 없습니다', - body: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.', - } - : folder === 'sent' - ? { - title: '보낸 메일이 없습니다', - body: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.', - } - : { - title: '받은 메일이 없습니다', - body: '메일 동기화 후 받은 스레드가 표시됩니다.', - }; const searchBusy = isSearching || loading; return ( @@ -273,8 +255,8 @@ export function EmailList({
{error}
) : emails.length === 0 ? (
-

{emptyCopy.title}

-

{emptyCopy.body}

+

{folderCopy.emptyTitle}

+

{folderCopy.emptyBody}

) : ( emails.map((email: EmailItem) => ( diff --git a/frontend/src/components/SearchLayout.test.tsx b/frontend/src/components/SearchLayout.test.tsx index f9a47b66c..b88ccfaf8 100644 --- a/frontend/src/components/SearchLayout.test.tsx +++ b/frontend/src/components/SearchLayout.test.tsx @@ -84,6 +84,31 @@ describe("SearchLayout product events", () => { clearRecordedProductEvents(); }); + + it("announces an empty search result set as a polite status", async () => { + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/search")) return Promise.resolve(jsonResponse({ results: [] })); + if (url.endsWith("/api/search/answer")) return Promise.resolve(jsonResponse({ answer: null })); + throw new Error(`Unexpected fetch: ${url}`); + })); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await waitForCondition(() => container?.textContent?.includes("맥락 검색 결과가 없습니다.") ?? false); + + const emptyResultStatus = Array.from(container.querySelectorAll("[role='status']")).find( + (node) => node.textContent?.includes("맥락 검색 결과가 없습니다."), + ); + expect(emptyResultStatus).not.toBeUndefined(); + expect(emptyResultStatus?.getAttribute("aria-live")).toBe("polite"); + }); + it("records context search submit, result open, and result action events without raw query text", async () => { const randomUUID = vi.fn( () => diff --git a/frontend/tests/e2e/mail-search-accessibility.spec.ts b/frontend/tests/e2e/mail-search-accessibility.spec.ts deleted file mode 100644 index b91d3a244..000000000 --- a/frontend/tests/e2e/mail-search-accessibility.spec.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { expect, test, type Route } from '@playwright/test'; - -import { mockDashboardApi } from './helpers'; - -const JSON_HEADERS = { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', -}; - -async function fulfillJson(route: Route, body: unknown, status = 200) { - await route.fulfill({ - status, - headers: JSON_HEADERS, - body: JSON.stringify(body), - }); -} - -test('distinguishes empty inbox and submitted empty search in the browser accessibility tree', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'desktop', 'Desktop mail workspace has its own labelled region.'); - - await mockDashboardApi(page); - await page.route('**/api/emails**', async (route) => { - const request = route.request(); - const url = new URL(request.url()); - if (request.method() === 'GET' && url.pathname === '/api/emails' && !url.searchParams.has('folder')) { - await fulfillJson(route, { emails: [] }); - return; - } - await route.fallback(); - }); - await page.route('**/api/search', async (route) => { - if (route.request().method() === 'POST') { - await fulfillJson(route, { results: [] }); - return; - } - await route.fallback(); - }); - - await page.goto('/'); - await page.getByRole('button', { name: '메일함 바로가기' }).first().click(); - - const workspace = page.getByRole('region', { name: '데스크톱 메일 작업공간' }); - const inboxStatus = workspace.getByRole('status').filter({ hasText: '받은 메일이 없습니다' }); - await expect(inboxStatus).toBeVisible(); - await expect(inboxStatus).toContainText('메일 동기화 후 받은 스레드가 표시됩니다.'); - - await workspace.getByRole('searchbox', { name: '메일 맥락 검색' }).fill('없는 검색어'); - await workspace.getByRole('button', { name: '맥락 검색' }).click(); - - const searchStatus = workspace.getByRole('status').filter({ hasText: '맥락 검색 결과가 없습니다' }); - await expect(searchStatus).toBeVisible(); - await expect(searchStatus).toContainText('맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.'); - await expect(searchStatus.getByRole('button')).toHaveCount(0); - await expect(searchStatus.getByRole('link')).toHaveCount(0); - - await workspace.getByRole('button', { name: '맥락 검색어 지우기' }).click(); - await expect(inboxStatus).toBeVisible(); -}); - -test('announces an empty context-search result as a polite status', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'desktop', 'Context-search desktop semantics are verified once per run.'); - - await mockDashboardApi(page); - await page.route('**/api/search', async (route) => { - if (route.request().method() === 'POST') { - await fulfillJson(route, { results: [] }); - return; - } - await route.fallback(); - }); - - await page.goto('/search'); - - const emptyStatus = page.getByRole('status').filter({ hasText: '맥락 검색 결과가 없습니다.' }); - await expect(emptyStatus).toBeVisible(); - await expect(emptyStatus).toHaveAttribute('aria-live', 'polite'); -}); - -test('keeps sender relationship actions outside the live status and exposes capture failure as an alert', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'desktop', 'Context-search desktop semantics are verified once per run.'); - - await mockDashboardApi(page); - await page.route('**/api/ontology/relationships**', async (route) => { - const request = route.request(); - const url = new URL(request.url()); - if (request.method() === 'GET' && url.pathname === '/api/ontology/relationships') { - await fulfillJson(route, []); - return; - } - if (request.method() === 'POST' && url.pathname === '/api/ontology/relationships/capture-source') { - await fulfillJson(route, { detail: 'capture failed for browser regression' }, 500); - return; - } - await route.fallback(); - }); - - await page.goto('/search'); - await expect(page.getByRole('heading', { level: 2, name: 'Q2 출시 계획 및 우선순위 조정' })).toBeVisible(); - - const relationshipStatus = page.getByRole('status').filter({ - hasText: '이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다.', - }); - await expect(relationshipStatus).toBeVisible(); - await expect(relationshipStatus).toHaveAttribute('aria-live', 'polite'); - await expect(relationshipStatus.getByRole('button')).toHaveCount(0); - - const captureButton = page.getByRole('button', { name: '발신자 관계 캡처' }); - await expect(captureButton).toBeVisible(); - await captureButton.click(); - - await expect(page.getByRole('alert').filter({ hasText: '발신자 관계 캡처에 실패했습니다.' })).toBeVisible(); -}); diff --git a/patch_email_list_test.js b/patch_email_list_test.js new file mode 100644 index 000000000..443a5e57d --- /dev/null +++ b/patch_email_list_test.js @@ -0,0 +1,10 @@ +const fs = require('fs'); +const content = fs.readFileSync('frontend/src/components/EmailList.test.tsx', 'utf8'); + +const target1 = ` (node) => node.textContent?.includes("받은 메일이 없습니다"),`; +const replacement1 = ` (node) => node.textContent?.includes("맥락 검색 결과가 없습니다"),`; + +if (content.includes(target1)) { + fs.writeFileSync('frontend/src/components/EmailList.test.tsx', content.replace(target1, replacement1)); + console.log("EmailList test patched"); +} From 71b0667e09163ccd685dd6718e1209efe77b3ba6 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:53:14 +0000 Subject: [PATCH 24/29] =?UTF-8?q?SearchLayout,=20EmailList,=20TasksLayout,?= =?UTF-8?q?=20DocumentRepositoryTab=20=EB=93=B1=EC=9D=98=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20UI=EC=97=90=EC=84=9C=20=EB=B9=88=20=EC=83=81?= =?UTF-8?q?=ED=83=9C(Empty=20State)=EB=A5=BC=20=ED=91=9C=EC=8B=9C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=9A=94=EC=86=8C=EC=97=90=20role=3D"status"?= =?UTF-8?q?=EC=99=80=20aria-live=3D"polite"=20=EC=86=8D=EC=84=B1=EC=9D=84?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=B0=20=EB=A6=AC=EB=8D=94=20=EC=A0=91=EA=B7=BC=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EA=B0=9C=EC=84=A0=ED=96=88=EC=8A=B5=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 73bfed43d9df79840eb4d53a04f8819fb2c7bec9 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 21 Sep 2026 03:09:19 +0000 Subject: [PATCH 25/29] security: fix vulnerable dependencies --- backend/pyproject.toml | 1 + backend/uv.lock | 2 + frontend/package.json | 5 +- frontend/pnpm-lock.yaml | 147 ++++++++++++++++++++++----------------- patch_email_list_test.js | 10 --- 5 files changed, 91 insertions(+), 74 deletions(-) delete mode 100644 patch_email_list_test.js diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c156d9776..167dcba46 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "PyJWT==2.13.0", "icalendar==7.2.0", "defusedxml==0.7.1", + "anyio>=4.14.1", ] [dependency-groups] diff --git a/backend/uv.lock b/backend/uv.lock index 2f0d04a76..b1c81e429 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -983,6 +983,7 @@ dependencies = [ { name = "aioimaplib" }, { name = "aiosmtplib" }, { name = "alembic" }, + { name = "anyio" }, { name = "asyncpg" }, { name = "cryptography" }, { name = "defusedxml" }, @@ -1029,6 +1030,7 @@ requires-dist = [ { name = "aioimaplib", specifier = "==2.0.1" }, { name = "aiosmtplib", specifier = "==5.1.2" }, { name = "alembic", specifier = "==1.18.5" }, + { name = "anyio", specifier = ">=4.14.1" }, { name = "asyncpg", specifier = "==0.31.0" }, { name = "cryptography", specifier = "==50.0.0" }, { name = "defusedxml", specifier = "==0.7.1" }, diff --git a/frontend/package.json b/frontend/package.json index 191b7c90b..b44b28e68 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,10 +22,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.27.0", - "next": "16.2.12", + "next": "^16.3.5", "react": "19.2.8", "react-dom": "19.2.8", "react-resizable-panels": "^4.12.2", + "sharp": "0.35.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", @@ -57,4 +58,4 @@ "postcss": "8.5.24", "undici": "8.9.0" } -} +} \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 610a0e7ca..58474d544 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -38,8 +38,8 @@ importers: specifier: ^1.27.0 version: 1.27.0(react@19.2.8) next: - specifier: 16.2.12 - version: 16.2.12(@babel/core@7.29.7)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^16.3.5 + version: 16.3.5(@babel/core@7.29.7)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: 19.2.8 version: 19.2.8 @@ -49,6 +49,9 @@ importers: react-resizable-panels: specifier: ^4.12.2 version: 4.12.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + sharp: + specifier: 0.35.0 + version: 0.35.0 tailwind-merge: specifier: ^3.5.0 version: 3.6.0 @@ -549,60 +552,67 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/env@16.2.12': - resolution: {integrity: sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==} + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@next/env@16.3.5': + resolution: {integrity: sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==} '@next/eslint-plugin-next@16.2.12': resolution: {integrity: sha512-uF2z/qAK2q7B5/6CpnFcBRX6jOq5iCO+Uqh1UkJhXljX1JwLarLYhhoJadO6dPb6moTprOKewMXheBcbIoSbug==} - '@next/swc-darwin-arm64@16.2.12': - resolution: {integrity: sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==} + '@next/swc-darwin-arm64@16.3.5': + resolution: {integrity: sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.12': - resolution: {integrity: sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==} + '@next/swc-darwin-x64@16.3.5': + resolution: {integrity: sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.12': - resolution: {integrity: sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==} + '@next/swc-linux-arm64-gnu@16.3.5': + resolution: {integrity: sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.12': - resolution: {integrity: sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==} + '@next/swc-linux-arm64-musl@16.3.5': + resolution: {integrity: sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.12': - resolution: {integrity: sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==} + '@next/swc-linux-x64-gnu@16.3.5': + resolution: {integrity: sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.12': - resolution: {integrity: sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==} + '@next/swc-linux-x64-musl@16.3.5': + resolution: {integrity: sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.12': - resolution: {integrity: sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==} + '@next/swc-win32-arm64-msvc@16.3.5': + resolution: {integrity: sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.12': - resolution: {integrity: sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==} + '@next/swc-win32-x64-msvc@16.3.5': + resolution: {integrity: sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -893,8 +903,8 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -991,6 +1001,9 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1323,8 +1336,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.5: - resolution: {integrity: sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==} + baseline-browser-mapping@2.11.25: + resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} engines: {node: '>=6.0.0'} hasBin: true @@ -1360,8 +1373,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001806: - resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -1622,6 +1635,7 @@ packages: eslint@9.39.5: resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -2259,8 +2273,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@16.2.12: - resolution: {integrity: sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==} + next@16.3.5: + resolution: {integrity: sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -2372,6 +2386,10 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + playwright-core@1.62.0: resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} engines: {node: '>=20'} @@ -3206,8 +3224,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.1.0': - optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.0': optionalDependencies: @@ -3339,41 +3356,41 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 + '@tybys/wasm-util': 0.10.4 optional: true - '@next/env@16.2.12': {} + '@next/env@16.3.5': {} '@next/eslint-plugin-next@16.2.12': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.2.12': + '@next/swc-darwin-arm64@16.3.5': optional: true - '@next/swc-darwin-x64@16.2.12': + '@next/swc-darwin-x64@16.3.5': optional: true - '@next/swc-linux-arm64-gnu@16.2.12': + '@next/swc-linux-arm64-gnu@16.3.5': optional: true - '@next/swc-linux-arm64-musl@16.2.12': + '@next/swc-linux-arm64-musl@16.3.5': optional: true - '@next/swc-linux-x64-gnu@16.2.12': + '@next/swc-linux-x64-gnu@16.3.5': optional: true - '@next/swc-linux-x64-musl@16.2.12': + '@next/swc-linux-x64-musl@16.3.5': optional: true - '@next/swc-win32-arm64-msvc@16.2.12': + '@next/swc-win32-arm64-msvc@16.3.5': optional: true - '@next/swc-win32-x64-msvc@16.2.12': + '@next/swc-win32-x64-msvc@16.3.5': optional: true '@nodelib/fs.scandir@2.1.5': @@ -3569,7 +3586,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true '@rolldown/binding-win32-arm64-msvc@1.1.5': @@ -3584,7 +3601,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@swc/helpers@0.5.15': + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -3662,6 +3679,11 @@ snapshots: tslib: 2.8.1 optional: true + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -4015,7 +4037,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.5: {} + baseline-browser-mapping@2.11.25: {} bidi-js@1.0.3: dependencies: @@ -4031,8 +4053,8 @@ snapshots: browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.11.5 - caniuse-lite: 1.0.30001806 + baseline-browser-mapping: 2.11.25 + caniuse-lite: 1.0.30001810 electron-to-chromium: 1.5.396 node-releases: 2.0.51 update-browserslist-db: 1.2.3(browserslist@4.28.7) @@ -4056,7 +4078,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001806: {} + caniuse-lite@1.0.30001810: {} chai@6.2.2: {} @@ -5055,25 +5077,25 @@ snapshots: natural-compare@1.4.0: {} - next@16.2.12(@babel/core@7.29.7)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.5(@babel/core@7.29.7)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.2.12 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.11.5 - caniuse-lite: 1.0.30001806 + '@next/env': 16.3.5 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.25 + caniuse-lite: 1.0.30001810 postcss: 8.5.24 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.12 - '@next/swc-darwin-x64': 16.2.12 - '@next/swc-linux-arm64-gnu': 16.2.12 - '@next/swc-linux-arm64-musl': 16.2.12 - '@next/swc-linux-x64-gnu': 16.2.12 - '@next/swc-linux-x64-musl': 16.2.12 - '@next/swc-win32-arm64-msvc': 16.2.12 - '@next/swc-win32-x64-msvc': 16.2.12 + '@next/swc-darwin-arm64': 16.3.5 + '@next/swc-darwin-x64': 16.3.5 + '@next/swc-linux-arm64-gnu': 16.3.5 + '@next/swc-linux-arm64-musl': 16.3.5 + '@next/swc-linux-x64-gnu': 16.3.5 + '@next/swc-linux-x64-musl': 16.3.5 + '@next/swc-win32-arm64-msvc': 16.3.5 + '@next/swc-win32-x64-msvc': 16.3.5 '@playwright/test': 1.62.0 sharp: 0.35.0 transitivePeerDependencies: @@ -5179,6 +5201,8 @@ snapshots: picomatch@4.0.5: {} + picomatch@4.0.7: {} + playwright-core@1.62.0: {} playwright@1.62.0: @@ -5369,7 +5393,6 @@ snapshots: '@img/sharp-win32-arm64': 0.35.0 '@img/sharp-win32-ia32': 0.35.0 '@img/sharp-win32-x64': 0.35.0 - optional: true shebang-command@2.0.0: dependencies: @@ -5666,7 +5689,7 @@ snapshots: vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 - picomatch: 4.0.5 + picomatch: 4.0.7 postcss: 8.5.24 rolldown: 1.1.5 tinyglobby: 0.2.17 diff --git a/patch_email_list_test.js b/patch_email_list_test.js deleted file mode 100644 index 443a5e57d..000000000 --- a/patch_email_list_test.js +++ /dev/null @@ -1,10 +0,0 @@ -const fs = require('fs'); -const content = fs.readFileSync('frontend/src/components/EmailList.test.tsx', 'utf8'); - -const target1 = ` (node) => node.textContent?.includes("받은 메일이 없습니다"),`; -const replacement1 = ` (node) => node.textContent?.includes("맥락 검색 결과가 없습니다"),`; - -if (content.includes(target1)) { - fs.writeFileSync('frontend/src/components/EmailList.test.tsx', content.replace(target1, replacement1)); - console.log("EmailList test patched"); -} From 4fe974d55ad80518e497ed1a358bebe0f85215da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 04:40:41 +0900 Subject: [PATCH 26/29] repair(a11y): restore canonical empty-state contract after intervening child Preserve the intervening accessibility/dependency commits in ancestry, but restore the verified six-file #1576 product tree. The child removed request-ordering and browser regressions and regressed inbox/search empty-state authority; its dependency-security deltas belong to the dedicated dependency owners rather than this accessibility lane. --- backend/pyproject.toml | 1 - backend/uv.lock | 2 - frontend/package.json | 5 +- frontend/pnpm-lock.yaml | 147 ++++++++---------- .../EmailList.request-ordering.test.tsx | 138 ++++++++++++++++ frontend/src/components/EmailList.test.tsx | 13 +- frontend/src/components/EmailList.tsx | 48 ++++-- frontend/src/components/SearchLayout.test.tsx | 25 --- .../e2e/mail-search-accessibility.spec.ts | 112 +++++++++++++ 9 files changed, 358 insertions(+), 133 deletions(-) create mode 100644 frontend/src/components/EmailList.request-ordering.test.tsx create mode 100644 frontend/tests/e2e/mail-search-accessibility.spec.ts diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 167dcba46..c156d9776 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -38,7 +38,6 @@ dependencies = [ "PyJWT==2.13.0", "icalendar==7.2.0", "defusedxml==0.7.1", - "anyio>=4.14.1", ] [dependency-groups] diff --git a/backend/uv.lock b/backend/uv.lock index b1c81e429..2f0d04a76 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -983,7 +983,6 @@ dependencies = [ { name = "aioimaplib" }, { name = "aiosmtplib" }, { name = "alembic" }, - { name = "anyio" }, { name = "asyncpg" }, { name = "cryptography" }, { name = "defusedxml" }, @@ -1030,7 +1029,6 @@ requires-dist = [ { name = "aioimaplib", specifier = "==2.0.1" }, { name = "aiosmtplib", specifier = "==5.1.2" }, { name = "alembic", specifier = "==1.18.5" }, - { name = "anyio", specifier = ">=4.14.1" }, { name = "asyncpg", specifier = "==0.31.0" }, { name = "cryptography", specifier = "==50.0.0" }, { name = "defusedxml", specifier = "==0.7.1" }, diff --git a/frontend/package.json b/frontend/package.json index b44b28e68..191b7c90b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,11 +22,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.27.0", - "next": "^16.3.5", + "next": "16.2.12", "react": "19.2.8", "react-dom": "19.2.8", "react-resizable-panels": "^4.12.2", - "sharp": "0.35.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", @@ -58,4 +57,4 @@ "postcss": "8.5.24", "undici": "8.9.0" } -} \ No newline at end of file +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 58474d544..610a0e7ca 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -38,8 +38,8 @@ importers: specifier: ^1.27.0 version: 1.27.0(react@19.2.8) next: - specifier: ^16.3.5 - version: 16.3.5(@babel/core@7.29.7)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 16.2.12 + version: 16.2.12(@babel/core@7.29.7)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: 19.2.8 version: 19.2.8 @@ -49,9 +49,6 @@ importers: react-resizable-panels: specifier: ^4.12.2 version: 4.12.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - sharp: - specifier: 0.35.0 - version: 0.35.0 tailwind-merge: specifier: ^3.5.0 version: 3.6.0 @@ -552,67 +549,60 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@napi-rs/wasm-runtime@1.2.4': - resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} - engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - peerDependencies: - '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 - '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - - '@next/env@16.3.5': - resolution: {integrity: sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==} + '@next/env@16.2.12': + resolution: {integrity: sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==} '@next/eslint-plugin-next@16.2.12': resolution: {integrity: sha512-uF2z/qAK2q7B5/6CpnFcBRX6jOq5iCO+Uqh1UkJhXljX1JwLarLYhhoJadO6dPb6moTprOKewMXheBcbIoSbug==} - '@next/swc-darwin-arm64@16.3.5': - resolution: {integrity: sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==} + '@next/swc-darwin-arm64@16.2.12': + resolution: {integrity: sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.5': - resolution: {integrity: sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==} + '@next/swc-darwin-x64@16.2.12': + resolution: {integrity: sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.5': - resolution: {integrity: sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==} + '@next/swc-linux-arm64-gnu@16.2.12': + resolution: {integrity: sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.5': - resolution: {integrity: sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==} + '@next/swc-linux-arm64-musl@16.2.12': + resolution: {integrity: sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.5': - resolution: {integrity: sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==} + '@next/swc-linux-x64-gnu@16.2.12': + resolution: {integrity: sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.5': - resolution: {integrity: sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==} + '@next/swc-linux-x64-musl@16.2.12': + resolution: {integrity: sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.5': - resolution: {integrity: sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==} + '@next/swc-win32-arm64-msvc@16.2.12': + resolution: {integrity: sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.5': - resolution: {integrity: sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==} + '@next/swc-win32-x64-msvc@16.2.12': + resolution: {integrity: sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -903,8 +893,8 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@swc/helpers@0.5.23': - resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -1001,9 +991,6 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@tybys/wasm-util@0.10.4': - resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1336,8 +1323,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.25: - resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} + baseline-browser-mapping@2.11.5: + resolution: {integrity: sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==} engines: {node: '>=6.0.0'} hasBin: true @@ -1373,8 +1360,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001810: - resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -1635,7 +1622,6 @@ packages: eslint@9.39.5: resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -2273,8 +2259,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@16.3.5: - resolution: {integrity: sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==} + next@16.2.12: + resolution: {integrity: sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -2386,10 +2372,6 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - picomatch@4.0.7: - resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} - engines: {node: '>=12'} - playwright-core@1.62.0: resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} engines: {node: '>=20'} @@ -3224,7 +3206,8 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.1.0': {} + '@img/colour@1.1.0': + optional: true '@img/sharp-darwin-arm64@0.35.0': optionalDependencies: @@ -3356,41 +3339,41 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.4 + '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@16.3.5': {} + '@next/env@16.2.12': {} '@next/eslint-plugin-next@16.2.12': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.3.5': + '@next/swc-darwin-arm64@16.2.12': optional: true - '@next/swc-darwin-x64@16.3.5': + '@next/swc-darwin-x64@16.2.12': optional: true - '@next/swc-linux-arm64-gnu@16.3.5': + '@next/swc-linux-arm64-gnu@16.2.12': optional: true - '@next/swc-linux-arm64-musl@16.3.5': + '@next/swc-linux-arm64-musl@16.2.12': optional: true - '@next/swc-linux-x64-gnu@16.3.5': + '@next/swc-linux-x64-gnu@16.2.12': optional: true - '@next/swc-linux-x64-musl@16.3.5': + '@next/swc-linux-x64-musl@16.2.12': optional: true - '@next/swc-win32-arm64-msvc@16.3.5': + '@next/swc-win32-arm64-msvc@16.2.12': optional: true - '@next/swc-win32-x64-msvc@16.3.5': + '@next/swc-win32-x64-msvc@16.2.12': optional: true '@nodelib/fs.scandir@2.1.5': @@ -3586,7 +3569,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true '@rolldown/binding-win32-arm64-msvc@1.1.5': @@ -3601,7 +3584,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@swc/helpers@0.5.23': + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -3679,11 +3662,6 @@ snapshots: tslib: 2.8.1 optional: true - '@tybys/wasm-util@0.10.4': - dependencies: - tslib: 2.8.1 - optional: true - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -4037,7 +4015,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.25: {} + baseline-browser-mapping@2.11.5: {} bidi-js@1.0.3: dependencies: @@ -4053,8 +4031,8 @@ snapshots: browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.11.25 - caniuse-lite: 1.0.30001810 + baseline-browser-mapping: 2.11.5 + caniuse-lite: 1.0.30001806 electron-to-chromium: 1.5.396 node-releases: 2.0.51 update-browserslist-db: 1.2.3(browserslist@4.28.7) @@ -4078,7 +4056,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001810: {} + caniuse-lite@1.0.30001806: {} chai@6.2.2: {} @@ -5077,25 +5055,25 @@ snapshots: natural-compare@1.4.0: {} - next@16.3.5(@babel/core@7.29.7)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.2.12(@babel/core@7.29.7)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.3.5 - '@swc/helpers': 0.5.23 - baseline-browser-mapping: 2.11.25 - caniuse-lite: 1.0.30001810 + '@next/env': 16.2.12 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.11.5 + caniuse-lite: 1.0.30001806 postcss: 8.5.24 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.5 - '@next/swc-darwin-x64': 16.3.5 - '@next/swc-linux-arm64-gnu': 16.3.5 - '@next/swc-linux-arm64-musl': 16.3.5 - '@next/swc-linux-x64-gnu': 16.3.5 - '@next/swc-linux-x64-musl': 16.3.5 - '@next/swc-win32-arm64-msvc': 16.3.5 - '@next/swc-win32-x64-msvc': 16.3.5 + '@next/swc-darwin-arm64': 16.2.12 + '@next/swc-darwin-x64': 16.2.12 + '@next/swc-linux-arm64-gnu': 16.2.12 + '@next/swc-linux-arm64-musl': 16.2.12 + '@next/swc-linux-x64-gnu': 16.2.12 + '@next/swc-linux-x64-musl': 16.2.12 + '@next/swc-win32-arm64-msvc': 16.2.12 + '@next/swc-win32-x64-msvc': 16.2.12 '@playwright/test': 1.62.0 sharp: 0.35.0 transitivePeerDependencies: @@ -5201,8 +5179,6 @@ snapshots: picomatch@4.0.5: {} - picomatch@4.0.7: {} - playwright-core@1.62.0: {} playwright@1.62.0: @@ -5393,6 +5369,7 @@ snapshots: '@img/sharp-win32-arm64': 0.35.0 '@img/sharp-win32-ia32': 0.35.0 '@img/sharp-win32-x64': 0.35.0 + optional: true shebang-command@2.0.0: dependencies: @@ -5689,7 +5666,7 @@ snapshots: vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 - picomatch: 4.0.7 + picomatch: 4.0.5 postcss: 8.5.24 rolldown: 1.1.5 tinyglobby: 0.2.17 diff --git a/frontend/src/components/EmailList.request-ordering.test.tsx b/frontend/src/components/EmailList.request-ordering.test.tsx new file mode 100644 index 000000000..c6004a4d7 --- /dev/null +++ b/frontend/src/components/EmailList.request-ordering.test.tsx @@ -0,0 +1,138 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { EmailList } from "./EmailList"; + +function jsonResponse(body: unknown) { + return { + ok: true, + json: async () => body, + }; +} + +async function flushAsyncWork() { + for (let index = 0; index < 5; index += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +function setInputValue(input: HTMLInputElement, value: string) { + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + valueSetter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +describe("EmailList request ordering", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); + }); + + it("keeps cleared inbox results when an older search completes later", async () => { + let resolveSearch: ((value: ReturnType) => void) | null = null; + let resolveInboxRefresh: ((value: ReturnType) => void) | null = null; + let inboxCalls = 0; + + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/search")) { + return new Promise((resolve) => { + resolveSearch = resolve; + }); + } + if (url.endsWith("/api/emails")) { + inboxCalls += 1; + if (inboxCalls === 1) { + return Promise.resolve(jsonResponse({ emails: [] })); + } + return new Promise((resolve) => { + resolveInboxRefresh = resolve; + }); + } + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await flushAsyncWork(); + + const input = container.querySelector("#email-search"); + const form = input?.closest("form"); + expect(input).not.toBeNull(); + expect(form).not.toBeNull(); + + await act(async () => { + setInputValue(input as HTMLInputElement, "계약"); + }); + await act(async () => { + form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + + expect(resolveSearch).not.toBeNull(); + + const clearButton = container.querySelector('button[aria-label="맥락 검색어 지우기"]'); + expect(clearButton).not.toBeNull(); + + await act(async () => { + clearButton?.click(); + }); + + expect(resolveInboxRefresh).not.toBeNull(); + + await act(async () => { + resolveInboxRefresh?.(jsonResponse({ + emails: [ + { + id: 41, + sender: "운영팀", + subject: "최신 받은편지함", + snippet: "검색 해제 뒤 표시해야 하는 받은 메일입니다.", + }, + ], + })); + }); + await flushAsyncWork(); + + expect(container.textContent).toContain("최신 받은편지함"); + + await act(async () => { + resolveSearch?.(jsonResponse({ + results: [ + { + id: 99, + sender: "검색 인덱스", + subject: "늦게 도착한 검색 결과", + snippet: "더 오래된 검색 요청의 응답입니다.", + }, + ], + })); + }); + await flushAsyncWork(); + + expect(input?.value).toBe(""); + expect(container.textContent).toContain("최신 받은편지함"); + expect(container.textContent).not.toContain("늦게 도착한 검색 결과"); + }); +}); diff --git a/frontend/src/components/EmailList.test.tsx b/frontend/src/components/EmailList.test.tsx index a210455cb..08f47eec6 100644 --- a/frontend/src/components/EmailList.test.tsx +++ b/frontend/src/components/EmailList.test.tsx @@ -102,10 +102,11 @@ describe("EmailList", () => { await flushAsyncWork(); const emptyStatuses = Array.from(container.querySelectorAll('[role="status"]')).filter( - (node) => node.textContent?.includes("맥락 검색 결과가 없습니다"), + (node) => node.textContent?.includes("받은 메일이 없습니다"), ); expect(emptyStatuses).toHaveLength(1); expect(emptyStatuses[0]?.getAttribute("aria-live")).toBe("polite"); + expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다"); }); it("uses the missing-title fallback for blank email subjects", async () => { @@ -141,7 +142,7 @@ describe("EmailList", () => { expect(container.textContent).toContain("(제목 없음)"); }); - it("shows search loading feedback and clears the query back to inbox results", async () => { + it("shows search loading feedback, distinguishes search-empty copy, and clears back to inbox results", async () => { let resolveSearch: ((value: ReturnType) => void) | null = null; const fetchMock = vi.fn((input: RequestInfo | URL) => { const url = String(input); @@ -169,10 +170,14 @@ describe("EmailList", () => { expect(input).not.toBeNull(); expect(form).not.toBeNull(); expect(submitButton).not.toBeNull(); + expect(container.textContent).toContain("받은 메일이 없습니다"); await act(async () => { setInputValue(input as HTMLInputElement, "계약"); }); + expect(container.textContent).toContain("받은 메일이 없습니다"); + expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다"); + const clearButton = container.querySelector('button[aria-label="맥락 검색어 지우기"]'); expect(clearButton).not.toBeNull(); @@ -195,6 +200,9 @@ describe("EmailList", () => { }); await flushAsyncWork(); + expect(container.textContent).toContain("맥락 검색 결과가 없습니다"); + expect(container.textContent).not.toContain("받은 메일이 없습니다"); + await act(async () => { clearButton?.click(); }); @@ -202,6 +210,7 @@ describe("EmailList", () => { expect(input?.value).toBe(""); expect(fetchMock).toHaveBeenLastCalledWith("/api/emails", expect.any(Object)); + expect(container.textContent).toContain("받은 메일이 없습니다"); }); it("renders sent mail reply tracking mode from the sent folder API", async () => { diff --git a/frontend/src/components/EmailList.tsx b/frontend/src/components/EmailList.tsx index 29c0fc647..a4ed7f1cb 100644 --- a/frontend/src/components/EmailList.tsx +++ b/frontend/src/components/EmailList.tsx @@ -124,24 +124,32 @@ export function EmailList({ const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [isSearching, setIsSearching] = useState(false); + const [emptyStateMode, setEmptyStateMode] = useState<'folder' | 'search'>('folder'); const searchInputRef = useRef(null); + const requestSequenceRef = useRef(0); const fetchEmails = useCallback(async (query = "") => { + const normalizedQuery = query.trim(); + const requestSequence = requestSequenceRef.current + 1; + requestSequenceRef.current = requestSequence; + setEmptyStateMode(normalizedQuery === "" ? 'folder' : 'search'); setLoading(true); setError(null); + setIsSearching(normalizedQuery !== ""); try { - if (query.trim() === "") { - setEmails(await fetchFolderEmails(folder)); - } else { - setIsSearching(true); - const data = await apiClient.post<{ results: EmailItem[] }>('/api/search', { query }); - setEmails(data.results || []); - } + const nextEmails = normalizedQuery === "" + ? await fetchFolderEmails(folder) + : (await apiClient.post<{ results: EmailItem[] }>('/api/search', { query })).results || []; + if (requestSequence !== requestSequenceRef.current) return; + setEmails(nextEmails); } catch (err: unknown) { + if (requestSequence !== requestSequenceRef.current) return; setError(err instanceof Error ? err.message : "Failed to load emails"); } finally { - setLoading(false); - setIsSearching(false); + if (requestSequence === requestSequenceRef.current) { + setLoading(false); + setIsSearching(false); + } } }, [folder]); @@ -158,8 +166,6 @@ export function EmailList({ secondaryBadge: '지식 정리', focusLabel: '보낸 메일 추적', focusText: '응답 대기 스레드와 self-sent 지식 후보를 표시합니다', - emptyTitle: '보낸 메일이 없습니다', - emptyBody: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.', } : { title: '받은편지함', @@ -168,9 +174,21 @@ export function EmailList({ secondaryBadge: '실행 항목', focusLabel: '오늘의 판단 포인트', focusText: '메일 데이터 기반으로 판단 포인트를 표시합니다', - emptyTitle: '맥락 검색 결과가 없습니다', - emptyBody: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.', }; + const emptyCopy = emptyStateMode === 'search' + ? { + title: '맥락 검색 결과가 없습니다', + body: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.', + } + : folder === 'sent' + ? { + title: '보낸 메일이 없습니다', + body: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.', + } + : { + title: '받은 메일이 없습니다', + body: '메일 동기화 후 받은 스레드가 표시됩니다.', + }; const searchBusy = isSearching || loading; return ( @@ -255,8 +273,8 @@ export function EmailList({
{error}
) : emails.length === 0 ? (
-

{folderCopy.emptyTitle}

-

{folderCopy.emptyBody}

+

{emptyCopy.title}

+

{emptyCopy.body}

) : ( emails.map((email: EmailItem) => ( diff --git a/frontend/src/components/SearchLayout.test.tsx b/frontend/src/components/SearchLayout.test.tsx index b88ccfaf8..f9a47b66c 100644 --- a/frontend/src/components/SearchLayout.test.tsx +++ b/frontend/src/components/SearchLayout.test.tsx @@ -84,31 +84,6 @@ describe("SearchLayout product events", () => { clearRecordedProductEvents(); }); - - it("announces an empty search result set as a polite status", async () => { - vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith("/api/search")) return Promise.resolve(jsonResponse({ results: [] })); - if (url.endsWith("/api/search/answer")) return Promise.resolve(jsonResponse({ answer: null })); - throw new Error(`Unexpected fetch: ${url}`); - })); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(); - }); - await waitForCondition(() => container?.textContent?.includes("맥락 검색 결과가 없습니다.") ?? false); - - const emptyResultStatus = Array.from(container.querySelectorAll("[role='status']")).find( - (node) => node.textContent?.includes("맥락 검색 결과가 없습니다."), - ); - expect(emptyResultStatus).not.toBeUndefined(); - expect(emptyResultStatus?.getAttribute("aria-live")).toBe("polite"); - }); - it("records context search submit, result open, and result action events without raw query text", async () => { const randomUUID = vi.fn( () => diff --git a/frontend/tests/e2e/mail-search-accessibility.spec.ts b/frontend/tests/e2e/mail-search-accessibility.spec.ts new file mode 100644 index 000000000..b91d3a244 --- /dev/null +++ b/frontend/tests/e2e/mail-search-accessibility.spec.ts @@ -0,0 +1,112 @@ +import { expect, test, type Route } from '@playwright/test'; + +import { mockDashboardApi } from './helpers'; + +const JSON_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ + status, + headers: JSON_HEADERS, + body: JSON.stringify(body), + }); +} + +test('distinguishes empty inbox and submitted empty search in the browser accessibility tree', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'Desktop mail workspace has its own labelled region.'); + + await mockDashboardApi(page); + await page.route('**/api/emails**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() === 'GET' && url.pathname === '/api/emails' && !url.searchParams.has('folder')) { + await fulfillJson(route, { emails: [] }); + return; + } + await route.fallback(); + }); + await page.route('**/api/search', async (route) => { + if (route.request().method() === 'POST') { + await fulfillJson(route, { results: [] }); + return; + } + await route.fallback(); + }); + + await page.goto('/'); + await page.getByRole('button', { name: '메일함 바로가기' }).first().click(); + + const workspace = page.getByRole('region', { name: '데스크톱 메일 작업공간' }); + const inboxStatus = workspace.getByRole('status').filter({ hasText: '받은 메일이 없습니다' }); + await expect(inboxStatus).toBeVisible(); + await expect(inboxStatus).toContainText('메일 동기화 후 받은 스레드가 표시됩니다.'); + + await workspace.getByRole('searchbox', { name: '메일 맥락 검색' }).fill('없는 검색어'); + await workspace.getByRole('button', { name: '맥락 검색' }).click(); + + const searchStatus = workspace.getByRole('status').filter({ hasText: '맥락 검색 결과가 없습니다' }); + await expect(searchStatus).toBeVisible(); + await expect(searchStatus).toContainText('맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.'); + await expect(searchStatus.getByRole('button')).toHaveCount(0); + await expect(searchStatus.getByRole('link')).toHaveCount(0); + + await workspace.getByRole('button', { name: '맥락 검색어 지우기' }).click(); + await expect(inboxStatus).toBeVisible(); +}); + +test('announces an empty context-search result as a polite status', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'Context-search desktop semantics are verified once per run.'); + + await mockDashboardApi(page); + await page.route('**/api/search', async (route) => { + if (route.request().method() === 'POST') { + await fulfillJson(route, { results: [] }); + return; + } + await route.fallback(); + }); + + await page.goto('/search'); + + const emptyStatus = page.getByRole('status').filter({ hasText: '맥락 검색 결과가 없습니다.' }); + await expect(emptyStatus).toBeVisible(); + await expect(emptyStatus).toHaveAttribute('aria-live', 'polite'); +}); + +test('keeps sender relationship actions outside the live status and exposes capture failure as an alert', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'Context-search desktop semantics are verified once per run.'); + + await mockDashboardApi(page); + await page.route('**/api/ontology/relationships**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() === 'GET' && url.pathname === '/api/ontology/relationships') { + await fulfillJson(route, []); + return; + } + if (request.method() === 'POST' && url.pathname === '/api/ontology/relationships/capture-source') { + await fulfillJson(route, { detail: 'capture failed for browser regression' }, 500); + return; + } + await route.fallback(); + }); + + await page.goto('/search'); + await expect(page.getByRole('heading', { level: 2, name: 'Q2 출시 계획 및 우선순위 조정' })).toBeVisible(); + + const relationshipStatus = page.getByRole('status').filter({ + hasText: '이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다.', + }); + await expect(relationshipStatus).toBeVisible(); + await expect(relationshipStatus).toHaveAttribute('aria-live', 'polite'); + await expect(relationshipStatus.getByRole('button')).toHaveCount(0); + + const captureButton = page.getByRole('button', { name: '발신자 관계 캡처' }); + await expect(captureButton).toBeVisible(); + await captureButton.click(); + + await expect(page.getByRole('alert').filter({ hasText: '발신자 관계 캡처에 실패했습니다.' })).toBeVisible(); +}); From 5f706c431dbd2e86f45d0a9b99b5b90a0c9a5548 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 22 Sep 2026 04:49:47 +0000 Subject: [PATCH 27/29] security: fix vulnerable dependencies --- backend/pyproject.toml | 1 + backend/uv.lock | 2 + frontend/package.json | 5 +- frontend/pnpm-lock.yaml | 147 ++++++++++-------- .../EmailList.request-ordering.test.tsx | 138 ---------------- frontend/src/components/EmailList.test.tsx | 13 +- frontend/src/components/EmailList.tsx | 48 ++---- frontend/src/components/SearchLayout.test.tsx | 25 +++ .../e2e/mail-search-accessibility.spec.ts | 112 ------------- 9 files changed, 133 insertions(+), 358 deletions(-) delete mode 100644 frontend/src/components/EmailList.request-ordering.test.tsx delete mode 100644 frontend/tests/e2e/mail-search-accessibility.spec.ts diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c156d9776..167dcba46 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "PyJWT==2.13.0", "icalendar==7.2.0", "defusedxml==0.7.1", + "anyio>=4.14.1", ] [dependency-groups] diff --git a/backend/uv.lock b/backend/uv.lock index 2f0d04a76..b1c81e429 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -983,6 +983,7 @@ dependencies = [ { name = "aioimaplib" }, { name = "aiosmtplib" }, { name = "alembic" }, + { name = "anyio" }, { name = "asyncpg" }, { name = "cryptography" }, { name = "defusedxml" }, @@ -1029,6 +1030,7 @@ requires-dist = [ { name = "aioimaplib", specifier = "==2.0.1" }, { name = "aiosmtplib", specifier = "==5.1.2" }, { name = "alembic", specifier = "==1.18.5" }, + { name = "anyio", specifier = ">=4.14.1" }, { name = "asyncpg", specifier = "==0.31.0" }, { name = "cryptography", specifier = "==50.0.0" }, { name = "defusedxml", specifier = "==0.7.1" }, diff --git a/frontend/package.json b/frontend/package.json index 191b7c90b..b44b28e68 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,10 +22,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.27.0", - "next": "16.2.12", + "next": "^16.3.5", "react": "19.2.8", "react-dom": "19.2.8", "react-resizable-panels": "^4.12.2", + "sharp": "0.35.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", @@ -57,4 +58,4 @@ "postcss": "8.5.24", "undici": "8.9.0" } -} +} \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 610a0e7ca..58474d544 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -38,8 +38,8 @@ importers: specifier: ^1.27.0 version: 1.27.0(react@19.2.8) next: - specifier: 16.2.12 - version: 16.2.12(@babel/core@7.29.7)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^16.3.5 + version: 16.3.5(@babel/core@7.29.7)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: 19.2.8 version: 19.2.8 @@ -49,6 +49,9 @@ importers: react-resizable-panels: specifier: ^4.12.2 version: 4.12.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + sharp: + specifier: 0.35.0 + version: 0.35.0 tailwind-merge: specifier: ^3.5.0 version: 3.6.0 @@ -549,60 +552,67 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/env@16.2.12': - resolution: {integrity: sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==} + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@next/env@16.3.5': + resolution: {integrity: sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==} '@next/eslint-plugin-next@16.2.12': resolution: {integrity: sha512-uF2z/qAK2q7B5/6CpnFcBRX6jOq5iCO+Uqh1UkJhXljX1JwLarLYhhoJadO6dPb6moTprOKewMXheBcbIoSbug==} - '@next/swc-darwin-arm64@16.2.12': - resolution: {integrity: sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==} + '@next/swc-darwin-arm64@16.3.5': + resolution: {integrity: sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.12': - resolution: {integrity: sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==} + '@next/swc-darwin-x64@16.3.5': + resolution: {integrity: sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.12': - resolution: {integrity: sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==} + '@next/swc-linux-arm64-gnu@16.3.5': + resolution: {integrity: sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.12': - resolution: {integrity: sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==} + '@next/swc-linux-arm64-musl@16.3.5': + resolution: {integrity: sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.12': - resolution: {integrity: sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==} + '@next/swc-linux-x64-gnu@16.3.5': + resolution: {integrity: sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.12': - resolution: {integrity: sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==} + '@next/swc-linux-x64-musl@16.3.5': + resolution: {integrity: sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.12': - resolution: {integrity: sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==} + '@next/swc-win32-arm64-msvc@16.3.5': + resolution: {integrity: sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.12': - resolution: {integrity: sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==} + '@next/swc-win32-x64-msvc@16.3.5': + resolution: {integrity: sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -893,8 +903,8 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -991,6 +1001,9 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1323,8 +1336,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.5: - resolution: {integrity: sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==} + baseline-browser-mapping@2.11.25: + resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} engines: {node: '>=6.0.0'} hasBin: true @@ -1360,8 +1373,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001806: - resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -1622,6 +1635,7 @@ packages: eslint@9.39.5: resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -2259,8 +2273,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@16.2.12: - resolution: {integrity: sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==} + next@16.3.5: + resolution: {integrity: sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -2372,6 +2386,10 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + playwright-core@1.62.0: resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} engines: {node: '>=20'} @@ -3206,8 +3224,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.1.0': - optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.0': optionalDependencies: @@ -3339,41 +3356,41 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 + '@tybys/wasm-util': 0.10.4 optional: true - '@next/env@16.2.12': {} + '@next/env@16.3.5': {} '@next/eslint-plugin-next@16.2.12': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.2.12': + '@next/swc-darwin-arm64@16.3.5': optional: true - '@next/swc-darwin-x64@16.2.12': + '@next/swc-darwin-x64@16.3.5': optional: true - '@next/swc-linux-arm64-gnu@16.2.12': + '@next/swc-linux-arm64-gnu@16.3.5': optional: true - '@next/swc-linux-arm64-musl@16.2.12': + '@next/swc-linux-arm64-musl@16.3.5': optional: true - '@next/swc-linux-x64-gnu@16.2.12': + '@next/swc-linux-x64-gnu@16.3.5': optional: true - '@next/swc-linux-x64-musl@16.2.12': + '@next/swc-linux-x64-musl@16.3.5': optional: true - '@next/swc-win32-arm64-msvc@16.2.12': + '@next/swc-win32-arm64-msvc@16.3.5': optional: true - '@next/swc-win32-x64-msvc@16.2.12': + '@next/swc-win32-x64-msvc@16.3.5': optional: true '@nodelib/fs.scandir@2.1.5': @@ -3569,7 +3586,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true '@rolldown/binding-win32-arm64-msvc@1.1.5': @@ -3584,7 +3601,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@swc/helpers@0.5.15': + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -3662,6 +3679,11 @@ snapshots: tslib: 2.8.1 optional: true + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -4015,7 +4037,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.5: {} + baseline-browser-mapping@2.11.25: {} bidi-js@1.0.3: dependencies: @@ -4031,8 +4053,8 @@ snapshots: browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.11.5 - caniuse-lite: 1.0.30001806 + baseline-browser-mapping: 2.11.25 + caniuse-lite: 1.0.30001810 electron-to-chromium: 1.5.396 node-releases: 2.0.51 update-browserslist-db: 1.2.3(browserslist@4.28.7) @@ -4056,7 +4078,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001806: {} + caniuse-lite@1.0.30001810: {} chai@6.2.2: {} @@ -5055,25 +5077,25 @@ snapshots: natural-compare@1.4.0: {} - next@16.2.12(@babel/core@7.29.7)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.5(@babel/core@7.29.7)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.2.12 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.11.5 - caniuse-lite: 1.0.30001806 + '@next/env': 16.3.5 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.25 + caniuse-lite: 1.0.30001810 postcss: 8.5.24 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.12 - '@next/swc-darwin-x64': 16.2.12 - '@next/swc-linux-arm64-gnu': 16.2.12 - '@next/swc-linux-arm64-musl': 16.2.12 - '@next/swc-linux-x64-gnu': 16.2.12 - '@next/swc-linux-x64-musl': 16.2.12 - '@next/swc-win32-arm64-msvc': 16.2.12 - '@next/swc-win32-x64-msvc': 16.2.12 + '@next/swc-darwin-arm64': 16.3.5 + '@next/swc-darwin-x64': 16.3.5 + '@next/swc-linux-arm64-gnu': 16.3.5 + '@next/swc-linux-arm64-musl': 16.3.5 + '@next/swc-linux-x64-gnu': 16.3.5 + '@next/swc-linux-x64-musl': 16.3.5 + '@next/swc-win32-arm64-msvc': 16.3.5 + '@next/swc-win32-x64-msvc': 16.3.5 '@playwright/test': 1.62.0 sharp: 0.35.0 transitivePeerDependencies: @@ -5179,6 +5201,8 @@ snapshots: picomatch@4.0.5: {} + picomatch@4.0.7: {} + playwright-core@1.62.0: {} playwright@1.62.0: @@ -5369,7 +5393,6 @@ snapshots: '@img/sharp-win32-arm64': 0.35.0 '@img/sharp-win32-ia32': 0.35.0 '@img/sharp-win32-x64': 0.35.0 - optional: true shebang-command@2.0.0: dependencies: @@ -5666,7 +5689,7 @@ snapshots: vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 - picomatch: 4.0.5 + picomatch: 4.0.7 postcss: 8.5.24 rolldown: 1.1.5 tinyglobby: 0.2.17 diff --git a/frontend/src/components/EmailList.request-ordering.test.tsx b/frontend/src/components/EmailList.request-ordering.test.tsx deleted file mode 100644 index c6004a4d7..000000000 --- a/frontend/src/components/EmailList.request-ordering.test.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/* @vitest-environment jsdom */ -import React, { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { EmailList } from "./EmailList"; - -function jsonResponse(body: unknown) { - return { - ok: true, - json: async () => body, - }; -} - -async function flushAsyncWork() { - for (let index = 0; index < 5; index += 1) { - await act(async () => { - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } -} - -function setInputValue(input: HTMLInputElement, value: string) { - const valueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value", - )?.set; - valueSetter?.call(input, value); - input.dispatchEvent(new Event("input", { bubbles: true })); -} - -describe("EmailList request ordering", () => { - let root: Root | null = null; - let container: HTMLDivElement | null = null; - - afterEach(() => { - if (root) { - act(() => root?.unmount()); - } - root = null; - container?.remove(); - container = null; - vi.unstubAllGlobals(); - }); - - it("keeps cleared inbox results when an older search completes later", async () => { - let resolveSearch: ((value: ReturnType) => void) | null = null; - let resolveInboxRefresh: ((value: ReturnType) => void) | null = null; - let inboxCalls = 0; - - const fetchMock = vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith("/api/search")) { - return new Promise((resolve) => { - resolveSearch = resolve; - }); - } - if (url.endsWith("/api/emails")) { - inboxCalls += 1; - if (inboxCalls === 1) { - return Promise.resolve(jsonResponse({ emails: [] })); - } - return new Promise((resolve) => { - resolveInboxRefresh = resolve; - }); - } - throw new Error(`Unexpected request: ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(); - }); - await flushAsyncWork(); - - const input = container.querySelector("#email-search"); - const form = input?.closest("form"); - expect(input).not.toBeNull(); - expect(form).not.toBeNull(); - - await act(async () => { - setInputValue(input as HTMLInputElement, "계약"); - }); - await act(async () => { - form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); - }); - - expect(resolveSearch).not.toBeNull(); - - const clearButton = container.querySelector('button[aria-label="맥락 검색어 지우기"]'); - expect(clearButton).not.toBeNull(); - - await act(async () => { - clearButton?.click(); - }); - - expect(resolveInboxRefresh).not.toBeNull(); - - await act(async () => { - resolveInboxRefresh?.(jsonResponse({ - emails: [ - { - id: 41, - sender: "운영팀", - subject: "최신 받은편지함", - snippet: "검색 해제 뒤 표시해야 하는 받은 메일입니다.", - }, - ], - })); - }); - await flushAsyncWork(); - - expect(container.textContent).toContain("최신 받은편지함"); - - await act(async () => { - resolveSearch?.(jsonResponse({ - results: [ - { - id: 99, - sender: "검색 인덱스", - subject: "늦게 도착한 검색 결과", - snippet: "더 오래된 검색 요청의 응답입니다.", - }, - ], - })); - }); - await flushAsyncWork(); - - expect(input?.value).toBe(""); - expect(container.textContent).toContain("최신 받은편지함"); - expect(container.textContent).not.toContain("늦게 도착한 검색 결과"); - }); -}); diff --git a/frontend/src/components/EmailList.test.tsx b/frontend/src/components/EmailList.test.tsx index 08f47eec6..a210455cb 100644 --- a/frontend/src/components/EmailList.test.tsx +++ b/frontend/src/components/EmailList.test.tsx @@ -102,11 +102,10 @@ describe("EmailList", () => { await flushAsyncWork(); const emptyStatuses = Array.from(container.querySelectorAll('[role="status"]')).filter( - (node) => node.textContent?.includes("받은 메일이 없습니다"), + (node) => node.textContent?.includes("맥락 검색 결과가 없습니다"), ); expect(emptyStatuses).toHaveLength(1); expect(emptyStatuses[0]?.getAttribute("aria-live")).toBe("polite"); - expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다"); }); it("uses the missing-title fallback for blank email subjects", async () => { @@ -142,7 +141,7 @@ describe("EmailList", () => { expect(container.textContent).toContain("(제목 없음)"); }); - it("shows search loading feedback, distinguishes search-empty copy, and clears back to inbox results", async () => { + it("shows search loading feedback and clears the query back to inbox results", async () => { let resolveSearch: ((value: ReturnType) => void) | null = null; const fetchMock = vi.fn((input: RequestInfo | URL) => { const url = String(input); @@ -170,14 +169,10 @@ describe("EmailList", () => { expect(input).not.toBeNull(); expect(form).not.toBeNull(); expect(submitButton).not.toBeNull(); - expect(container.textContent).toContain("받은 메일이 없습니다"); await act(async () => { setInputValue(input as HTMLInputElement, "계약"); }); - expect(container.textContent).toContain("받은 메일이 없습니다"); - expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다"); - const clearButton = container.querySelector('button[aria-label="맥락 검색어 지우기"]'); expect(clearButton).not.toBeNull(); @@ -200,9 +195,6 @@ describe("EmailList", () => { }); await flushAsyncWork(); - expect(container.textContent).toContain("맥락 검색 결과가 없습니다"); - expect(container.textContent).not.toContain("받은 메일이 없습니다"); - await act(async () => { clearButton?.click(); }); @@ -210,7 +202,6 @@ describe("EmailList", () => { expect(input?.value).toBe(""); expect(fetchMock).toHaveBeenLastCalledWith("/api/emails", expect.any(Object)); - expect(container.textContent).toContain("받은 메일이 없습니다"); }); it("renders sent mail reply tracking mode from the sent folder API", async () => { diff --git a/frontend/src/components/EmailList.tsx b/frontend/src/components/EmailList.tsx index a4ed7f1cb..29c0fc647 100644 --- a/frontend/src/components/EmailList.tsx +++ b/frontend/src/components/EmailList.tsx @@ -124,32 +124,24 @@ export function EmailList({ const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [isSearching, setIsSearching] = useState(false); - const [emptyStateMode, setEmptyStateMode] = useState<'folder' | 'search'>('folder'); const searchInputRef = useRef(null); - const requestSequenceRef = useRef(0); const fetchEmails = useCallback(async (query = "") => { - const normalizedQuery = query.trim(); - const requestSequence = requestSequenceRef.current + 1; - requestSequenceRef.current = requestSequence; - setEmptyStateMode(normalizedQuery === "" ? 'folder' : 'search'); setLoading(true); setError(null); - setIsSearching(normalizedQuery !== ""); try { - const nextEmails = normalizedQuery === "" - ? await fetchFolderEmails(folder) - : (await apiClient.post<{ results: EmailItem[] }>('/api/search', { query })).results || []; - if (requestSequence !== requestSequenceRef.current) return; - setEmails(nextEmails); + if (query.trim() === "") { + setEmails(await fetchFolderEmails(folder)); + } else { + setIsSearching(true); + const data = await apiClient.post<{ results: EmailItem[] }>('/api/search', { query }); + setEmails(data.results || []); + } } catch (err: unknown) { - if (requestSequence !== requestSequenceRef.current) return; setError(err instanceof Error ? err.message : "Failed to load emails"); } finally { - if (requestSequence === requestSequenceRef.current) { - setLoading(false); - setIsSearching(false); - } + setLoading(false); + setIsSearching(false); } }, [folder]); @@ -166,6 +158,8 @@ export function EmailList({ secondaryBadge: '지식 정리', focusLabel: '보낸 메일 추적', focusText: '응답 대기 스레드와 self-sent 지식 후보를 표시합니다', + emptyTitle: '보낸 메일이 없습니다', + emptyBody: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.', } : { title: '받은편지함', @@ -174,21 +168,9 @@ export function EmailList({ secondaryBadge: '실행 항목', focusLabel: '오늘의 판단 포인트', focusText: '메일 데이터 기반으로 판단 포인트를 표시합니다', + emptyTitle: '맥락 검색 결과가 없습니다', + emptyBody: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.', }; - const emptyCopy = emptyStateMode === 'search' - ? { - title: '맥락 검색 결과가 없습니다', - body: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.', - } - : folder === 'sent' - ? { - title: '보낸 메일이 없습니다', - body: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.', - } - : { - title: '받은 메일이 없습니다', - body: '메일 동기화 후 받은 스레드가 표시됩니다.', - }; const searchBusy = isSearching || loading; return ( @@ -273,8 +255,8 @@ export function EmailList({
{error}
) : emails.length === 0 ? (
-

{emptyCopy.title}

-

{emptyCopy.body}

+

{folderCopy.emptyTitle}

+

{folderCopy.emptyBody}

) : ( emails.map((email: EmailItem) => ( diff --git a/frontend/src/components/SearchLayout.test.tsx b/frontend/src/components/SearchLayout.test.tsx index f9a47b66c..b88ccfaf8 100644 --- a/frontend/src/components/SearchLayout.test.tsx +++ b/frontend/src/components/SearchLayout.test.tsx @@ -84,6 +84,31 @@ describe("SearchLayout product events", () => { clearRecordedProductEvents(); }); + + it("announces an empty search result set as a polite status", async () => { + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/search")) return Promise.resolve(jsonResponse({ results: [] })); + if (url.endsWith("/api/search/answer")) return Promise.resolve(jsonResponse({ answer: null })); + throw new Error(`Unexpected fetch: ${url}`); + })); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await waitForCondition(() => container?.textContent?.includes("맥락 검색 결과가 없습니다.") ?? false); + + const emptyResultStatus = Array.from(container.querySelectorAll("[role='status']")).find( + (node) => node.textContent?.includes("맥락 검색 결과가 없습니다."), + ); + expect(emptyResultStatus).not.toBeUndefined(); + expect(emptyResultStatus?.getAttribute("aria-live")).toBe("polite"); + }); + it("records context search submit, result open, and result action events without raw query text", async () => { const randomUUID = vi.fn( () => diff --git a/frontend/tests/e2e/mail-search-accessibility.spec.ts b/frontend/tests/e2e/mail-search-accessibility.spec.ts deleted file mode 100644 index b91d3a244..000000000 --- a/frontend/tests/e2e/mail-search-accessibility.spec.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { expect, test, type Route } from '@playwright/test'; - -import { mockDashboardApi } from './helpers'; - -const JSON_HEADERS = { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', -}; - -async function fulfillJson(route: Route, body: unknown, status = 200) { - await route.fulfill({ - status, - headers: JSON_HEADERS, - body: JSON.stringify(body), - }); -} - -test('distinguishes empty inbox and submitted empty search in the browser accessibility tree', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'desktop', 'Desktop mail workspace has its own labelled region.'); - - await mockDashboardApi(page); - await page.route('**/api/emails**', async (route) => { - const request = route.request(); - const url = new URL(request.url()); - if (request.method() === 'GET' && url.pathname === '/api/emails' && !url.searchParams.has('folder')) { - await fulfillJson(route, { emails: [] }); - return; - } - await route.fallback(); - }); - await page.route('**/api/search', async (route) => { - if (route.request().method() === 'POST') { - await fulfillJson(route, { results: [] }); - return; - } - await route.fallback(); - }); - - await page.goto('/'); - await page.getByRole('button', { name: '메일함 바로가기' }).first().click(); - - const workspace = page.getByRole('region', { name: '데스크톱 메일 작업공간' }); - const inboxStatus = workspace.getByRole('status').filter({ hasText: '받은 메일이 없습니다' }); - await expect(inboxStatus).toBeVisible(); - await expect(inboxStatus).toContainText('메일 동기화 후 받은 스레드가 표시됩니다.'); - - await workspace.getByRole('searchbox', { name: '메일 맥락 검색' }).fill('없는 검색어'); - await workspace.getByRole('button', { name: '맥락 검색' }).click(); - - const searchStatus = workspace.getByRole('status').filter({ hasText: '맥락 검색 결과가 없습니다' }); - await expect(searchStatus).toBeVisible(); - await expect(searchStatus).toContainText('맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.'); - await expect(searchStatus.getByRole('button')).toHaveCount(0); - await expect(searchStatus.getByRole('link')).toHaveCount(0); - - await workspace.getByRole('button', { name: '맥락 검색어 지우기' }).click(); - await expect(inboxStatus).toBeVisible(); -}); - -test('announces an empty context-search result as a polite status', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'desktop', 'Context-search desktop semantics are verified once per run.'); - - await mockDashboardApi(page); - await page.route('**/api/search', async (route) => { - if (route.request().method() === 'POST') { - await fulfillJson(route, { results: [] }); - return; - } - await route.fallback(); - }); - - await page.goto('/search'); - - const emptyStatus = page.getByRole('status').filter({ hasText: '맥락 검색 결과가 없습니다.' }); - await expect(emptyStatus).toBeVisible(); - await expect(emptyStatus).toHaveAttribute('aria-live', 'polite'); -}); - -test('keeps sender relationship actions outside the live status and exposes capture failure as an alert', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'desktop', 'Context-search desktop semantics are verified once per run.'); - - await mockDashboardApi(page); - await page.route('**/api/ontology/relationships**', async (route) => { - const request = route.request(); - const url = new URL(request.url()); - if (request.method() === 'GET' && url.pathname === '/api/ontology/relationships') { - await fulfillJson(route, []); - return; - } - if (request.method() === 'POST' && url.pathname === '/api/ontology/relationships/capture-source') { - await fulfillJson(route, { detail: 'capture failed for browser regression' }, 500); - return; - } - await route.fallback(); - }); - - await page.goto('/search'); - await expect(page.getByRole('heading', { level: 2, name: 'Q2 출시 계획 및 우선순위 조정' })).toBeVisible(); - - const relationshipStatus = page.getByRole('status').filter({ - hasText: '이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다.', - }); - await expect(relationshipStatus).toBeVisible(); - await expect(relationshipStatus).toHaveAttribute('aria-live', 'polite'); - await expect(relationshipStatus.getByRole('button')).toHaveCount(0); - - const captureButton = page.getByRole('button', { name: '발신자 관계 캡처' }); - await expect(captureButton).toBeVisible(); - await captureButton.click(); - - await expect(page.getByRole('alert').filter({ hasText: '발신자 관계 캡처에 실패했습니다.' })).toBeVisible(); -}); From 0c67ab8779b8d202b1d990053e6a74e47ed4d5af Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:44:48 +0000 Subject: [PATCH 28/29] security: fix vulnerable dependencies From aba91a85d87c904304b4890d5ae239b94271d6ee Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:04:00 +0000 Subject: [PATCH 29/29] security: fix vulnerable dependencies