Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3cb31d6
SearchLayout, EmailList, TasksLayout, DocumentRepositoryTab 등의 동적 UI에…
seonghobae Sep 6, 2026
7e221bb
test(search): pin live-region interaction boundary
seonghobae Sep 6, 2026
84a7659
fix(search): isolate live status from interactive controls
seonghobae Sep 6, 2026
69c92ec
chore(a11y): restore canonical Palette guidance
seonghobae Sep 6, 2026
a0c544b
chore(a11y): exactly adopt protected Palette guidance
seonghobae Sep 6, 2026
597790b
SearchLayout, EmailList, TasksLayout, DocumentRepositoryTab 등의 동적 UI에…
seonghobae Sep 6, 2026
bb8209b
fix(a11y): restore reviewed live-region contract
seonghobae Sep 6, 2026
13e3f18
test(a11y): cover empty email live region
seonghobae Sep 6, 2026
3f41ff5
test(a11y): cover empty search live region
seonghobae Sep 6, 2026
dfa3f03
fix(a11y): narrow empty-state announcement scope
seonghobae Sep 6, 2026
6bd0ec5
test(a11y): match inbox empty-state copy
seonghobae Sep 6, 2026
84cf3c9
test(a11y): distinguish inbox and search empty states
seonghobae Sep 6, 2026
65c370d
fix(a11y): distinguish inbox and search empty copy
seonghobae Sep 6, 2026
b243f7c
SearchLayout, EmailList, TasksLayout, DocumentRepositoryTab 등의 동적 UI에…
seonghobae Sep 6, 2026
4d2c0cb
fix(a11y): preserve reviewed empty-state contracts
seonghobae Sep 6, 2026
98f7c66
test(mail): preserve latest result set across search races
seonghobae Sep 6, 2026
05423b5
fix(mail): ignore stale search responses after scope changes
seonghobae Sep 6, 2026
134813f
test(mail): make stale-response ordering contract deterministic
seonghobae Sep 6, 2026
a2810ed
test(a11y): exercise mail and search live regions in browser
seonghobae Sep 6, 2026
270acbc
test(a11y): make search-detail browser probe deterministic
seonghobae Sep 6, 2026
33f1e14
SearchLayout, EmailList, TasksLayout, DocumentRepositoryTab 등의 동적 UI에…
seonghobae Sep 6, 2026
58df9fb
fix(a11y): preserve reviewed mail search contracts after concurrent c…
seonghobae Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion frontend/src/components/EmailList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,26 @@ describe("EmailList", () => {
expect(selectedThread?.className).toContain("min-h-20");
});

it("announces an empty inbox through exactly one polite status region", async () => {
const fetchMock = vi.fn(() => Promise.resolve(jsonResponse({ emails: [] })));
vi.stubGlobal("fetch", fetchMock);

container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<EmailList onSelectEmail={vi.fn()} selectedEmailId={null} />);
});
await flushAsyncWork();

const emptyStatuses = Array.from(container.querySelectorAll<HTMLElement>('[role="status"]')).filter(
(node) => node.textContent?.includes("받은 메일이 없습니다"),
);
expect(emptyStatuses).toHaveLength(1);
expect(emptyStatuses[0]?.getAttribute("aria-live")).toBe("polite");
});

it("uses the missing-title fallback for blank email subjects", async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(
Expand Down Expand Up @@ -271,4 +291,4 @@ describe("EmailList", () => {
expect(container.textContent).not.toContain("alert(1)");
expect(container.textContent).not.toContain("alert(2)");
});
});
});
2 changes: 1 addition & 1 deletion frontend/src/components/EmailList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export function EmailList({
) : error ? (
<div role="alert" className="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-600">{error}</div>
) : emails.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border bg-background/70 p-5 text-sm text-muted-foreground">
<div role="status" aria-live="polite" className="rounded-2xl border border-dashed border-border bg-background/70 p-5 text-sm text-muted-foreground">
<p className="font-bold text-foreground">{folderCopy.emptyTitle}</p>
<p className="mt-1 text-xs leading-5">{folderCopy.emptyBody}</p>
</div>
Expand Down
175 changes: 175 additions & 0 deletions frontend/src/components/SearchLayout.live-region.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/* @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 <div>mock graph</div>;
},
}));

vi.mock("next/link", () => ({
default: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
<a href={href} {...props}>{children}</a>
),
}));

vi.mock("lucide-react", () => ({
AlertCircle: () => <svg aria-hidden="true" />,
CalendarDays: () => <svg aria-hidden="true" />,
CheckCircle2: () => <svg aria-hidden="true" />,
Clock: () => <svg aria-hidden="true" />,
CornerDownRight: () => <svg aria-hidden="true" />,
FileText: () => <svg aria-hidden="true" />,
Loader2: () => <svg aria-hidden="true" />,
Mail: () => <svg aria-hidden="true" />,
Network: () => <svg aria-hidden="true" />,
Search: () => <svg aria-hidden="true" />,
Sparkles: () => <svg aria-hidden="true" />,
X: () => <svg aria-hidden="true" />,
}));

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: "<launch-source@example.com>",
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 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(<SearchLayout />);
});
await waitForCondition(() => container?.textContent?.includes("맥락 검색 결과가 없습니다.") ?? false);

const emptyResultStatus = Array.from(container.querySelectorAll<HTMLElement>("[role='status']")).find(
(node) => node.textContent?.includes("맥락 검색 결과가 없습니다."),
);
expect(emptyResultStatus).not.toBeUndefined();
expect(emptyResultStatus?.getAttribute("aria-live")).toBe("polite");
});

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(<SearchLayout />);
});
await waitForCondition(() => container?.textContent?.includes("발신자 관계 캡처") ?? false);

const emptyRelationshipStatus = Array.from(container.querySelectorAll<HTMLElement>("[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(<SearchLayout />);
});
await waitForCondition(() => container?.textContent?.includes("발신자 관계 캡처") ?? false);

const captureButton = Array.from(container.querySelectorAll<HTMLButtonElement>("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<HTMLElement>("[role='alert']")).find(
(node) => node.textContent?.includes("발신자 관계 캡처에 실패했습니다."),
);
expect(captureAlert).not.toBeUndefined();
});
});
8 changes: 4 additions & 4 deletions frontend/src/components/SearchLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ function SenderDagPanel({
if (relationships.length === 0) {
return (
<div className="rounded-lg border border-border bg-background p-4 text-sm font-semibold text-muted-foreground">
<p>이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다.</p>
<p role="status" aria-live="polite">이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다.</p>
{canCapture ? (
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs">
Expand All @@ -229,7 +229,7 @@ function SenderDagPanel({
</div>
) : null}
{captureStatus === "error" ? (
<p className="mt-2 text-xs font-bold text-destructive">
<p role="alert" className="mt-2 text-xs font-bold text-destructive">
발신자 관계 캡처에 실패했습니다.
</p>
) : null}
Expand Down Expand Up @@ -617,7 +617,7 @@ export function SearchLayout() {
{error}
</div>
) : filteredResults.length === 0 ? (
<div className="p-5 text-sm font-semibold text-muted-foreground">
<div role="status" aria-live="polite" className="p-5 text-sm font-semibold text-muted-foreground">
Comment thread
coderabbitai[bot] marked this conversation as resolved.
맥락 검색 결과가 없습니다.
</div>
) : (
Expand Down Expand Up @@ -1027,4 +1027,4 @@ export function SearchLayout() {
</div>
</div>
);
}
}
Loading