From 586108e72210db707c34a0ab3339f5bc7189dabc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 12:51:43 +0900 Subject: [PATCH 1/4] test(today): require dashboard empty-state live announcements Add a focused asynchronous loading-to-empty regression on the current Today/navigation ancestry. The unchanged source renders the three primary empty states without status live-region semantics, so this commit is the source-order RED. Signed-off-by: Seongho Bae --- .../WorkspaceHome.empty-live-region.test.tsx | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 frontend/src/components/WorkspaceHome.empty-live-region.test.tsx diff --git a/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx b/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx new file mode 100644 index 000000000..136d2b674 --- /dev/null +++ b/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx @@ -0,0 +1,148 @@ +/* @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("@/components/EmailList", () => ({ + EmailList: () =>
mock email list
, +})); + +vi.mock("@/components/EmailDetail", () => ({ + EmailDetail: () =>
mock email detail
, +})); + +vi.mock("@/components/ui/resizable", () => ({ + ResizablePanelGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + ResizablePanel: ({ children }: { children: React.ReactNode }) =>
{children}
, + ResizableHandle: () =>
, +})); + +vi.mock("@/components/mobile-workspace-panels", () => ({ + MobileCalendarPanel: () =>
mock calendar
, + MobileSearchPanel: () =>
mock search
, +})); + +vi.mock("next/dynamic", () => ({ + default: () => function MockDynamic() { + return
mock graph
; + }, +})); + +vi.mock("lucide-react", () => ({ + CalendarDays: () => , + CheckCircle2: () => , + Inbox: () => , + Network: () => , + Send: () => , + Settings: () => , + Sparkles: () => , +})); + +import { WorkspaceHome } from "./WorkspaceHome"; + +type DeferredResponse = { + promise: Promise<{ ok: true; json: () => Promise }>; + resolve: (body: unknown) => void; +}; + +function deferredResponse(): DeferredResponse { + let resolvePromise!: (response: { ok: true; json: () => Promise }) => void; + const promise = new Promise<{ ok: true; json: () => Promise }>((resolve) => { + resolvePromise = resolve; + }); + return { + promise, + resolve: (body: unknown) => resolvePromise({ ok: true, json: async () => body }), + }; +} + +async function waitForCondition(condition: () => boolean) { + for (let index = 0; index < 20; index += 1) { + if (condition()) return; + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + throw new Error("waitForCondition timed out after 20 attempts"); +} + +function statusWithText(container: HTMLElement, text: string) { + return Array.from(container.querySelectorAll('[role="status"]')).find( + (element) => element.textContent?.includes(text), + ) ?? null; +} + +describe("WorkspaceHome empty-state live regions", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) act(() => root?.unmount()); + root = null; + container?.remove(); + container = null; + localStorage.clear(); + vi.unstubAllGlobals(); + }); + + it("announces primary dashboard empty states after asynchronous loading completes", async () => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + + const emailsResponse = deferredResponse(); + const pendingRepliesResponse = deferredResponse(); + const tasksResponse = deferredResponse(); + + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/emails/pending-replies?limit=3")) return pendingRepliesResponse.promise; + if (url.endsWith("/api/emails")) return emailsResponse.promise; + if (url.endsWith("/api/tasks")) return tasksResponse.promise; + if (url.endsWith("/api/calendar/writeback-sources") || url.endsWith("/api/webdav/folders")) { + return Promise.resolve({ ok: true, json: async () => [] }); + } + if (url.endsWith("/api/search")) { + return Promise.resolve({ ok: true, json: async () => ({ results: [] }) }); + } + throw new Error(`Unexpected fetch: ${url}`); + })); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + expect(container.textContent).toContain("답변 대기 메일을 불러오는 중..."); + expect(container.textContent).toContain("작업을 불러오는 중..."); + expect(container.textContent).toContain("메일을 불러오는 중..."); + + await act(async () => { + emailsResponse.resolve({ emails: [] }); + pendingRepliesResponse.resolve({ emails: [] }); + tasksResponse.resolve([]); + }); + await waitForCondition(() => container?.textContent?.includes("수신된 메일이 없습니다.") ?? false); + + for (const copy of [ + "답변 대기 중인 보낸 메일이 없습니다.", + "대기 작업이 없습니다.", + "수신된 메일이 없습니다.", + ]) { + const emptyStatus = statusWithText(container, copy); + expect(emptyStatus, copy).not.toBeNull(); + expect(emptyStatus?.getAttribute("aria-live"), copy).toBe("polite"); + } + + expect(container.textContent).not.toContain("답변 대기 메일을 불러오는 중..."); + expect(container.textContent).not.toContain("작업을 불러오는 중..."); + expect(container.textContent).not.toContain("메일을 불러오는 중..."); + }); +}); \ No newline at end of file From baf2f38d5c981c5ab494c595a3b80fff72d1b3bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 12:52:15 +0900 Subject: [PATCH 2/4] fix(today): isolate generated empty-status semantic patch Isolate only the three generated WorkspaceHome live-region changes on protected-develop ancestry so Git can perform an ordinary three-way integration into the active Today owner without copying stale dashboard source. Signed-off-by: Seongho Bae --- frontend/src/components/WorkspaceHome.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/WorkspaceHome.tsx b/frontend/src/components/WorkspaceHome.tsx index ad0c58140..5734dfeb5 100644 --- a/frontend/src/components/WorkspaceHome.tsx +++ b/frontend/src/components/WorkspaceHome.tsx @@ -422,7 +422,7 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV {loading ? (
답변 대기 메일을 불러오는 중...
) : pendingReplies.length === 0 ? ( -
답변 대기 중인 보낸 메일이 없습니다.
+
답변 대기 중인 보낸 메일이 없습니다.
) : pendingReplies.map((reply) => { const safeSubject = toSafeReactText(reply.subject?.trim() || null, '(제목 없음)'); const safeSnippet = toSafeReactText(reply.snippet); @@ -451,7 +451,7 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV {loading ? (
작업을 불러오는 중...
) : pendingTasks.length === 0 ? ( -
대기 작업이 없습니다.
+
대기 작업이 없습니다.
) : pendingTasks.slice(0, 3).map((task) => { const pKor = mapPriorityToKorean(task.priority); const pClass = pKor === '긴급' || pKor === '높음' ? 'text-red-500' : pKor === '보통' ? 'text-green-500' : 'text-muted-foreground'; @@ -552,7 +552,7 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV {loading ? (
메일을 불러오는 중...
) : emails.length === 0 ? ( -
수신된 메일이 없습니다.
+
수신된 메일이 없습니다.
) : emails.slice(0, 5).map((mail) => (
From e7e8754b17754f3d85f25477424da9087bf8e9ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:02:31 +0900 Subject: [PATCH 3/4] test(today): flush deferred empty-state responses inside act Repair the current review finding by removing bounded real-timer polling from the dashboard loading-to-empty regression. Resolve and await the controlled response/body promises inside React act so runner speed cannot exhaust a fixed retry loop. Product source remains unchanged. Signed-off-by: Seongho Bae --- .../WorkspaceHome.empty-live-region.test.tsx | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx b/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx index 136d2b674..433f0c5c7 100644 --- a/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx +++ b/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx @@ -42,31 +42,29 @@ import { WorkspaceHome } from "./WorkspaceHome"; type DeferredResponse = { promise: Promise<{ ok: true; json: () => Promise }>; + bodyPromise: Promise; resolve: (body: unknown) => void; }; function deferredResponse(): DeferredResponse { - let resolvePromise!: (response: { ok: true; json: () => Promise }) => void; + let resolveResponse!: (response: { ok: true; json: () => Promise }) => void; + let resolveBody!: (body: unknown) => void; + const bodyPromise = new Promise((resolve) => { + resolveBody = resolve; + }); const promise = new Promise<{ ok: true; json: () => Promise }>((resolve) => { - resolvePromise = resolve; + resolveResponse = resolve; }); return { promise, - resolve: (body: unknown) => resolvePromise({ ok: true, json: async () => body }), + bodyPromise, + resolve: (body: unknown) => { + resolveResponse({ ok: true, json: () => bodyPromise }); + resolveBody(body); + }, }; } -async function waitForCondition(condition: () => boolean) { - for (let index = 0; index < 20; index += 1) { - if (condition()) return; - await act(async () => { - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } - throw new Error("waitForCondition timed out after 20 attempts"); -} - function statusWithText(container: HTMLElement, text: string) { return Array.from(container.querySelectorAll('[role="status"]')).find( (element) => element.textContent?.includes(text), @@ -128,8 +126,15 @@ describe("WorkspaceHome empty-state live regions", () => { emailsResponse.resolve({ emails: [] }); pendingRepliesResponse.resolve({ emails: [] }); tasksResponse.resolve([]); + await Promise.all([ + emailsResponse.promise, + pendingRepliesResponse.promise, + tasksResponse.promise, + emailsResponse.bodyPromise, + pendingRepliesResponse.bodyPromise, + tasksResponse.bodyPromise, + ]); }); - await waitForCondition(() => container?.textContent?.includes("수신된 메일이 없습니다.") ?? false); for (const copy of [ "답변 대기 중인 보낸 메일이 없습니다.", From 6e8c8919be264c0680d66f126d17da7f67071937 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:42:30 +0900 Subject: [PATCH 4/4] test(today): separate response and body completion --- .../WorkspaceHome.empty-live-region.test.tsx | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx b/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx index 433f0c5c7..f590964d9 100644 --- a/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx +++ b/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx @@ -43,25 +43,26 @@ import { WorkspaceHome } from "./WorkspaceHome"; type DeferredResponse = { promise: Promise<{ ok: true; json: () => Promise }>; bodyPromise: Promise; - resolve: (body: unknown) => void; + resolveResponse: () => void; + resolveBody: (body: unknown) => void; }; function deferredResponse(): DeferredResponse { - let resolveResponse!: (response: { ok: true; json: () => Promise }) => void; - let resolveBody!: (body: unknown) => void; + let resolveResponsePromise!: (response: { ok: true; json: () => Promise }) => void; + let resolveBodyPromise!: (body: unknown) => void; const bodyPromise = new Promise((resolve) => { - resolveBody = resolve; + resolveBodyPromise = resolve; }); const promise = new Promise<{ ok: true; json: () => Promise }>((resolve) => { - resolveResponse = resolve; + resolveResponsePromise = resolve; }); + const response = { ok: true as const, json: () => bodyPromise }; + return { promise, bodyPromise, - resolve: (body: unknown) => { - resolveResponse({ ok: true, json: () => bodyPromise }); - resolveBody(body); - }, + resolveResponse: () => resolveResponsePromise(response), + resolveBody: resolveBodyPromise, }; } @@ -84,7 +85,7 @@ describe("WorkspaceHome empty-state live regions", () => { vi.unstubAllGlobals(); }); - it("announces primary dashboard empty states after asynchronous loading completes", async () => { + it("announces primary dashboard empty states only after asynchronous bodies leave loading", async () => { vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ matches: false, media: query, @@ -123,13 +124,32 @@ describe("WorkspaceHome empty-state live regions", () => { expect(container.textContent).toContain("메일을 불러오는 중..."); await act(async () => { - emailsResponse.resolve({ emails: [] }); - pendingRepliesResponse.resolve({ emails: [] }); - tasksResponse.resolve([]); + emailsResponse.resolveResponse(); + pendingRepliesResponse.resolveResponse(); + tasksResponse.resolveResponse(); await Promise.all([ emailsResponse.promise, pendingRepliesResponse.promise, tasksResponse.promise, + ]); + }); + + expect(container.textContent).toContain("답변 대기 메일을 불러오는 중..."); + expect(container.textContent).toContain("작업을 불러오는 중..."); + expect(container.textContent).toContain("메일을 불러오는 중..."); + for (const copy of [ + "답변 대기 중인 보낸 메일이 없습니다.", + "대기 작업이 없습니다.", + "수신된 메일이 없습니다.", + ]) { + expect(statusWithText(container, copy), copy).toBeNull(); + } + + await act(async () => { + emailsResponse.resolveBody({ emails: [] }); + pendingRepliesResponse.resolveBody({ emails: [] }); + tasksResponse.resolveBody([]); + await Promise.all([ emailsResponse.bodyPromise, pendingRepliesResponse.bodyPromise, tasksResponse.bodyPromise, @@ -150,4 +170,4 @@ describe("WorkspaceHome empty-state live regions", () => { expect(container.textContent).not.toContain("작업을 불러오는 중..."); expect(container.textContent).not.toContain("메일을 불러오는 중..."); }); -}); \ No newline at end of file +});