diff --git a/AGENTS.md b/AGENTS.md index 2a9f10c65..8e0813739 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,11 @@ - Calendar writeback UI must fail closed while the signed source registry is loading or errored; do not emit intent POSTs without a confirmed opaque `target_source_id`, and keep tests covering the loading/error boundary. +- Calendar and WebDAV workspaces must expose the current opaque writeback source + as a deliberate user selection with capability and ETag/If-Match state. + Automatic first-source fallback may initialize the control, but intent POSTs + must use the selected opaque source and `409` responses must render as + conflicts, not generic errors or completed writes. - Calendar and WebDAV writeback source selection must resolve through opaque `source_uid` values, signed-session organization scope, and persisted writeback eligibility, not sequential CalDAV or WebDAV account ids. @@ -135,6 +140,9 @@ must not reveal or be deterministically derived from account primary keys, and provider mutations remain future work until connector execution can enforce capability, consent, and ETag/If-Match checks. +- Mobile workspace drawers must lock background body scroll while open and keep + the drawer itself scrollable; responsive E2E screenshots should cover the open + hamburger state after scrolling the drawer. - Self-sent knowledge extraction must first prove true self-to-self addressing, stay idempotent per source email, preserve email/thread provenance, and store only plain-text task titles. Do not create unlinked knowledge tasks from raw diff --git a/README.md b/README.md index 6f2c5ad9d..5bb966130 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,9 @@ provenance. Calendar source selection now reads opaque `calendar_writeback_sources.source_uid` rows instead of exposing sequential CalDAV account ids, and the Calendar workspace loads those rows through signed `/api/calendar/writeback-sources` before posting an opaque `target_source_id`. +The workspace now presents those sources as explicit selectable writeback +targets and shows the selected source ETag/capability state before intent +creation. The browser no longer claims `/api/calendar/sync` success from the mail-detail action path; direct provider writes stay deferred until connector execution can enforce ETag/If-Match and owner capability checks. @@ -282,7 +285,10 @@ WebDAV writeback and self-sent knowledge materialization use `webdav_accounts.source_uid` as the browser-visible source id, scope lookup by the signed session organization, honor persisted `writeback_enabled` eligibility, reject legacy `target_account_id` payloads, and keep sequential -`account_id` values internal-only. +`account_id` values internal-only. The Data workspace exposes the WebDAV source +as an explicit selected target and treats `409` If-Match/ETag responses as +conflicts instead of generic failures, so UI copy never implies a provider write +overwrote customer-owned files. ## Operations and release docs diff --git a/docs/plans/2026-05-27-calendar-writeback-intent-ui.md b/docs/plans/2026-05-27-calendar-writeback-intent-ui.md index e5ff168bd..a96880528 100644 --- a/docs/plans/2026-05-27-calendar-writeback-intent-ui.md +++ b/docs/plans/2026-05-27-calendar-writeback-intent-ui.md @@ -26,8 +26,11 @@ is the calendar server or directly writing provider data from the browser. ## Implemented Slice - `/calendar` now exposes a CalDAV/CardDAV/WebDAV intent check panel. -- The intent panel lists signed-session CalDAV registry sources and chooses an - eligible write-capable customer source before posting intent. +- The intent panel lists signed-session CalDAV registry sources, initializes an + eligible write-capable customer source, and lets the user deliberately select + the opaque target source before posting intent. +- Source cards show provider, protocol, capabilities, persisted ETag state, and + writeback eligibility so source selection is visible before any intent POST. - The monthly, weekly, detail, coordination, and candidate calendar tabs all render concrete surfaces instead of an inert "under implementation" state. - Mobile layout keeps the header and monthly grid bounded, and adds bottom safe diff --git a/docs/plans/2026-05-27-data-webdav-writeback-intent-ui.md b/docs/plans/2026-05-27-data-webdav-writeback-intent-ui.md index 00475e0e4..3a1dabdcf 100644 --- a/docs/plans/2026-05-27-data-webdav-writeback-intent-ui.md +++ b/docs/plans/2026-05-27-data-webdav-writeback-intent-ui.md @@ -21,10 +21,15 @@ AI-organized files or attachments are written back to their own storage. ## Implementation - `/data` now exposes a WebDAV writeback intent approval panel. -- The intent panel uses the first connected customer WebDAV account as the - target source through `webdav_accounts.source_uid`, only chooses accounts with - persisted `writeback_enabled=true`, and fails closed when no eligible source - or signed session is available. +- The intent panel initializes from the first connected customer WebDAV account + but exposes it as an explicit selected source through + `webdav_accounts.source_uid`, only permits accounts with persisted + `writeback_enabled=true`, and fails closed when no eligible source or signed + session is available. +- The source card shows the WebDAV endpoint, opaque source id, username, + writeback eligibility, and current ETag state before the intent request. +- `409` responses now render as If-Match/ETag conflicts instead of generic + failures, preserving the customer-owned source-of-truth boundary. - Data repository cards now use responsive grid tracks so mobile verification does not depend on desktop-only three-column layouts. - Unit and Playwright tests assert signed `Authorization: Bearer` handling and diff --git a/docs/plans/2026-05-28-dav-source-selection-conflict-ui.md b/docs/plans/2026-05-28-dav-source-selection-conflict-ui.md new file mode 100644 index 000000000..1eba951df --- /dev/null +++ b/docs/plans/2026-05-28-dav-source-selection-conflict-ui.md @@ -0,0 +1,72 @@ +# DAV Source Selection and Conflict UI Phase + +## Goal + +Close the next verified `docs/plans` and `frontend/branding` gap without +changing Naruon's source-of-truth boundary: Naruon remains a signed web +workspace/control plane over customer-owned CalDAV/CardDAV/WebDAV providers. + +## Verified Inputs + +- `frontend/branding/naruon-ux-mockup-1.png` shows a dense operational home + workspace with navigable decisions, tasks, calendar, and mail surfaces rather + than static hero copy. +- `frontend/branding/naruon-ux-mockup-3.png` shows the Calendar workspace as a + month/week/detail/coordination/candidate surface with provider-backed + scheduling states. +- `docs/plans/2026-05-27-calendar-writeback-intent-ui.md` and + `docs/plans/2026-05-27-data-webdav-writeback-intent-ui.md` already require + signed source-registry reads, opaque target ids, ETag/If-Match visibility, and + no direct browser provider writes. +- Subagent audits confirmed that Calendar and Data already call signed intent + APIs, but Calendar auto-selected the first source and Data collapsed WebDAV + `409` conflicts into a generic error. + +## Implemented Slice + +- Calendar source cards are selectable controls. The UI still initializes from + the first eligible source, but the selected opaque `source_id`, protocol, + capability list, ETag value, and writeback eligibility are visible before + `POST /api/calendar/writeback-intent`. +- Data WebDAV source cards are selectable controls. `POST + /api/webdav/writeback-intent` uses the selected opaque `source_id` and never + exposes sequential account ids in the browser request. +- WebDAV `409` responses render as If-Match/ETag conflicts so the UI does not + imply that Naruon overwrote a customer-owned file. +- The mobile hamburger drawer locks background body scroll while open and keeps + the drawer itself scrollable, matching the branding requirement for usable + mobile navigation across all primary destinations. +- README and AGENTS guardrails now record this bug pattern so future copied + Calendar/WebDAV examples do not revert to implicit source selection or generic + conflict errors. + +## Still Future Work + +- Real connector/provider write execution remains out of scope until the + connector can enforce consent, capability, credential reference, remote href, + and ETag/If-Match checks server-side. +- `CaldavAccount` and `/dav` still need deeper registry consolidation so legacy + account ids and skeleton PUT behavior cannot be mistaken for a complete + provider write path. +- WebDAV project folder responses still need opaque folder ids in a backend + slice; this UI phase did not change that API contract. + +## Verification + +```bash +npm test -- src/app/calendar/page.test.tsx src/app/data/page.test.tsx +npm run typecheck +npm run build +npx playwright test tests/e2e/dashboard-branding.spec.ts --project=desktop -g "calendar writeback|data WebDAV" +npx playwright test tests/e2e/mobile-hamburger.spec.ts --project=desktop +``` + +Screenshots to inspect: + +- `calendar-writeback-intent-desktop.png` +- `calendar-writeback-intent-mobile.png` +- `calendar-writeback-intent-mobile-scroll.png` +- `data-webdav-writeback-intent-desktop.png` +- `data-webdav-writeback-intent-mobile.png` +- `data-webdav-writeback-intent-mobile-scroll.png` +- `mobile-hamburger-open.png` diff --git a/frontend/src/app/calendar/page.test.tsx b/frontend/src/app/calendar/page.test.tsx index 080da948d..310f914c0 100644 --- a/frontend/src/app/calendar/page.test.tsx +++ b/frontend/src/app/calendar/page.test.tsx @@ -50,6 +50,16 @@ const calendarSourceList = [ writeback_enabled: true, etag: "etag-caldav-1", }, + { + source_id: "caldav-team", + provider: "Team CalDAV", + protocol: "caldav", + owner_id: "user-1", + organization_id: "org-acme", + capabilities: ["read", "write", "etag"], + writeback_enabled: true, + etag: "etag-team-2", + }, ]; describe("CalendarPage", () => { @@ -139,6 +149,7 @@ describe("CalendarPage", () => { }); await flushAsyncWork(); expect(container.textContent).toContain("Customer CalDAV"); + expect(container.textContent).toContain("etag=etag-caldav-1"); const button = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("새 일정 intent 점검")); expect(button).toBeTruthy(); @@ -153,6 +164,68 @@ describe("CalendarPage", () => { expect(container.textContent).toContain("calendar.writeback_intent.created"); }); + it("lets the user choose a specific customer-owned calendar source before intent creation", async () => { + localStorage.setItem("naruon_session_token", "signed-calendar-source-selection"); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === "/api/calendar/writeback-sources") { + expect(init?.headers).toEqual(expect.objectContaining({ + "Content-Type": "application/json", + Authorization: "Bearer signed-calendar-source-selection", + })); + return jsonResponse(calendarSourceList); + } + expect(String(input)).toBe("/api/calendar/writeback-intent"); + expect(init?.headers).toEqual(expect.objectContaining({ + "Content-Type": "application/json", + Authorization: "Bearer signed-calendar-source-selection", + })); + expect(JSON.parse(String(init?.body))).toEqual({ + action: "create", + summary: "Naruon 일정 후보 writeback intent 점검", + target_source_id: "caldav-team", + }); + return jsonResponse({ + workspace_id: "workspace-org-acme", + target_source_id: "caldav-team", + protocol: "caldav", + writeback_mode: "customer_owned", + requires_if_match: false, + if_match: null, + provenance: { + created_by: "user-1", + source_provider: "Team CalDAV", + source_protocol: "caldav", + }, + audit_event: "calendar.writeback_intent.created", + }); + }); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render(); + }); + await flushAsyncWork(); + + const teamSourceButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Team CalDAV")); + expect(teamSourceButton).toBeTruthy(); + await act(async () => { + teamSourceButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const createButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("새 일정 intent 점검")); + await act(async () => { + createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushAsyncWork(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain("caldav-team"); + expect(container.textContent).toContain("Team CalDAV"); + }); + it("does not post writeback intent before source registry readiness", async () => { const fetchMock = vi.fn((input: RequestInfo | URL) => { expect(String(input)).toBe("/api/calendar/writeback-sources"); diff --git a/frontend/src/app/data/page.test.tsx b/frontend/src/app/data/page.test.tsx index 6ebbb68bd..1e67744fe 100644 --- a/frontend/src/app/data/page.test.tsx +++ b/frontend/src/app/data/page.test.tsx @@ -23,9 +23,11 @@ vi.mock("lucide-react", () => ({ import DataPage from "./page"; -function jsonResponse(body: unknown) { +function jsonResponse(body: unknown, ok = true, status = ok ? 200 : 500) { return { - ok: true, + ok, + status, + statusText: ok ? "OK" : "Error", json: async () => body, }; } @@ -40,6 +42,14 @@ function mockWebdavFetch() { server_url: "https://webdav.naruon.net", username: "demo_user", writeback_enabled: true, + etag: "etag-webdav-primary", + }, + { + source_id: "webdav_src_team", + server_url: "https://files.acme.example", + username: "team_user", + writeback_enabled: true, + etag: "etag-webdav-team", }, ]); } @@ -123,6 +133,7 @@ describe("DataPage", () => { expect(container.textContent).toContain("WebDAV 원본"); expect(container.textContent).toContain("로컬 캐시"); expect(container.textContent).toContain("WebDAV writeback intent 승인"); + expect(container.textContent).toContain("etag=etag-webdav-primary"); }); it("creates a signed customer-owned WebDAV writeback intent", async () => { @@ -178,6 +189,63 @@ describe("DataPage", () => { expect(container.textContent).toContain("https://webdav.naruon.net"); }); + it("lets the user choose a specific WebDAV source and distinguishes If-Match conflicts", async () => { + localStorage.setItem("naruon_session_token", "signed-webdav-conflict-session"); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path === "/api/webdav/accounts") { + return jsonResponse([ + { + source_id: "webdav_src_primary", + server_url: "https://webdav.naruon.net", + username: "demo_user", + writeback_enabled: true, + etag: "etag-webdav-primary", + }, + { + source_id: "webdav_src_team", + server_url: "https://files.acme.example", + username: "team_user", + writeback_enabled: true, + etag: "etag-webdav-team", + }, + ]); + } + if (path === "/api/webdav/folders") return jsonResponse([]); + expect(path).toBe("/api/webdav/writeback-intent"); + expect(JSON.parse(String(init?.body))).toEqual({ + target_source_id: "webdav_src_team", + }); + return jsonResponse({ detail: "If-Match conflict" }, false, 409); + }); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + const teamSourceButton = Array.from(container.querySelectorAll("button")).find((candidate) => + candidate.textContent?.includes("webdav_src_team"), + ); + expect(teamSourceButton).toBeDefined(); + await act(async () => { + teamSourceButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const writebackButton = Array.from(container.querySelectorAll("button")).find((candidate) => + candidate.textContent?.includes("WebDAV intent 승인 점검"), + ); + await act(async () => { + writebackButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("If-Match/ETag 충돌"); + expect(container.textContent).toContain("webdav_src_team"); + }); + it("keeps WebDAV writeback disabled when account loading fails", async () => { localStorage.setItem("naruon_session_token", "signed-webdav-session"); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); diff --git a/frontend/src/components/CalendarLayout.tsx b/frontend/src/components/CalendarLayout.tsx index 7993b1689..334321110 100644 --- a/frontend/src/components/CalendarLayout.tsx +++ b/frontend/src/components/CalendarLayout.tsx @@ -29,6 +29,12 @@ type CalendarWritebackSource = { type WritebackStatus = 'idle' | 'loading' | 'success' | 'no_source' | 'conflict' | 'auth' | 'error'; +function isCustomerOwnedWritableSource(source: CalendarWritebackSource) { + return source.writeback_enabled + && source.protocol !== 'local' + && source.capabilities.includes('write'); +} + function getApiErrorStatus(error: unknown) { const shapedError = error as { status?: unknown; response?: { status?: unknown } } | null; if (typeof shapedError?.status === 'number') return shapedError.status; @@ -42,6 +48,7 @@ export function CalendarLayout() { const [writebackResult, setWritebackResult] = useState(null); const [writebackSources, setWritebackSources] = useState([]); const [sourceLoadStatus, setSourceLoadStatus] = useState<'loading' | 'ready' | 'error'>('loading'); + const [selectedSourceId, setSelectedSourceId] = useState(null); useEffect(() => { let isMounted = true; @@ -49,12 +56,14 @@ export function CalendarLayout() { .then((sources) => { if (!isMounted) return; setWritebackSources(sources); + setSelectedSourceId(sources.find(isCustomerOwnedWritableSource)?.source_id ?? null); setSourceLoadStatus('ready'); }) .catch(() => { if (!isMounted) return; setWritebackSources([]); setSourceLoadStatus('error'); + setSelectedSourceId(null); }); return () => { @@ -62,14 +71,11 @@ export function CalendarLayout() { }; }, []); - const selectedWritebackSource = useMemo( - () => writebackSources.find((source) => ( - source.writeback_enabled - && source.protocol !== 'local' - && source.capabilities.includes('write') - )) ?? null, - [writebackSources], - ); + const selectedWritebackSource = useMemo(() => { + const selectedSource = writebackSources.find((source) => source.source_id === selectedSourceId); + if (selectedSource && isCustomerOwnedWritableSource(selectedSource)) return selectedSource; + return writebackSources.find(isCustomerOwnedWritableSource) ?? null; + }, [selectedSourceId, writebackSources]); const isSourceRegistryReady = sourceLoadStatus === 'ready'; const requestWritebackIntent = useCallback(async (action: 'create' | 'update') => { @@ -208,25 +214,45 @@ export function CalendarLayout() {
- {writebackSources.map((source) => ( -
-
-
-

{source.source_id}

-

{source.provider}

+ {writebackSources.map((source) => { + const sourceWritable = isCustomerOwnedWritableSource(source); + const sourceSelected = selectedWritebackSource?.source_id === source.source_id; + return ( +
-

- {source.protocol} · {source.capabilities.join(', ')} -

-
- ))} +

+ {source.protocol} · {source.capabilities.join(', ')} +

+

+ etag={source.etag ?? 'missing'} · writeback={sourceWritable ? 'eligible' : 'blocked'} +

+ + ); + })} + {sourceLoadStatus === 'ready' && writebackSources.length === 0 && ( +

+ 연결된 CalDAV/CardDAV/WebDAV source가 없습니다. +

+ )} {sourceLoadStatus === 'loading' && (

CalDAV source registry 확인 중입니다. diff --git a/frontend/src/components/DashboardLayout.tsx b/frontend/src/components/DashboardLayout.tsx index 0a9e922e0..237243743 100644 --- a/frontend/src/components/DashboardLayout.tsx +++ b/frontend/src/components/DashboardLayout.tsx @@ -298,6 +298,15 @@ export function DashboardLayout({ const activeMobileView = useMobileWorkspaceView(); const startupView = useWorkspaceStartupView(); + useEffect(() => { + if (!isWorkspaceMenuOpen || typeof document === 'undefined') return undefined; + const previousBodyOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previousBodyOverflow; + }; + }, [isWorkspaceMenuOpen]); + function closeMobileWorkspaceMenu() { const menu = document.getElementById('mobile-workspace-menu') as (HTMLElement & { hidePopover?: () => void }) | null; menu?.hidePopover?.(); diff --git a/frontend/src/components/DataLayout.tsx b/frontend/src/components/DataLayout.tsx index 1f36acacc..70d2d6b5a 100644 --- a/frontend/src/components/DataLayout.tsx +++ b/frontend/src/components/DataLayout.tsx @@ -9,12 +9,13 @@ type WebdavWritebackIntentResponse = { source_id: string | null; server_url: string | null; requires_if_match: boolean; + if_match?: string | null; provenance: string; status?: string | null; message?: string | null; }; -type WritebackStatus = 'idle' | 'loading' | 'success' | 'no_source' | 'fetch_error' | 'auth' | 'error'; +type WritebackStatus = 'idle' | 'loading' | 'success' | 'no_source' | 'fetch_error' | 'conflict' | 'auth' | 'error'; type WebdavAccountStatus = 'loading' | 'ready' | 'error'; type UniqueThreadIntentResponse = { @@ -76,6 +77,7 @@ export function DataLayout() { server_url: string; username: string; writeback_enabled: boolean; + etag?: string | null; } interface ProjectFolder { @@ -86,6 +88,7 @@ export function DataLayout() { const [webdavAccounts, setWebdavAccounts] = useState([]); const [webdavAccountStatus, setWebdavAccountStatus] = useState('loading'); + const [selectedWebdavSourceId, setSelectedWebdavSourceId] = useState(null); const [projectFolders, setProjectFolders] = useState([]); const [writebackStatus, setWritebackStatus] = useState('idle'); const [writebackResult, setWritebackResult] = useState(null); @@ -97,12 +100,14 @@ export function DataLayout() { .then((data) => { if (!Array.isArray(data)) throw new Error('Invalid WebDAV accounts response'); setWebdavAccounts(data); + setSelectedWebdavSourceId(data.find((account) => account.writeback_enabled)?.source_id ?? null); setWebdavAccountStatus('ready'); }) .catch((error: unknown) => { console.error('WebDAV accounts fetch error', getSafeErrorSummary(error)); setWebdavAccounts([]); setWebdavAccountStatus('error'); + setSelectedWebdavSourceId(null); }); apiClient.get('/api/webdav/folders') @@ -118,7 +123,9 @@ export function DataLayout() { setWritebackStatus('fetch_error'); return; } - const targetSourceId = webdavAccounts.find(acc => acc.writeback_enabled)?.source_id; + const targetSourceId = webdavAccounts.find((account) => ( + account.source_id === selectedWebdavSourceId && account.writeback_enabled + ))?.source_id ?? webdavAccounts.find((account) => account.writeback_enabled)?.source_id; if (!targetSourceId) { setWritebackStatus('no_source'); return; @@ -133,13 +140,15 @@ export function DataLayout() { const status = getApiErrorStatus(error); if (status === 422) { setWritebackStatus('no_source'); + } else if (status === 409) { + setWritebackStatus('conflict'); } else if (status === 401 || status === 403) { setWritebackStatus('auth'); } else { setWritebackStatus('error'); } } - }, [webdavAccounts, webdavAccountStatus]); + }, [selectedWebdavSourceId, webdavAccounts, webdavAccountStatus]); const requestUniqueThreadIntent = useCallback(async () => { setUniqueThreadStatus('loading'); @@ -161,6 +170,9 @@ export function DataLayout() { const isWebdavSourceLoading = webdavAccountStatus === 'loading'; const canRequestWebdavWriteback = webdavAccountStatus === 'ready'; const isUniqueThreadLoading = uniqueThreadStatus === 'loading'; + const selectedWebdavAccount = webdavAccounts.find((account) => ( + account.source_id === selectedWebdavSourceId && account.writeback_enabled + )) ?? webdavAccounts.find((account) => account.writeback_enabled) ?? null; return (

@@ -211,12 +223,31 @@ export function DataLayout() {

- {webdavAccounts.map(acc => ( -
- - {acc.server_url} ({acc.username}) -
- ))} +
+ {webdavAccounts.map((account) => { + const accountSelected = selectedWebdavAccount?.source_id === account.source_id; + return ( + + ); + })} +
@@ -265,6 +296,9 @@ export function DataLayout() { {writebackStatus === 'fetch_error' && (

WebDAV 원본 계정 목록을 확인하지 못해 intent를 만들 수 없습니다.

)} + {writebackStatus === 'conflict' && ( +

If-Match/ETag 충돌이 감지되어 고객 WebDAV 원본 파일을 덮어쓰지 않았습니다.

+ )} {writebackStatus === 'auth' && (

signed session이 필요합니다. 공개 identity header로는 WebDAV intent를 만들 수 없습니다.

)} @@ -283,7 +317,7 @@ export function DataLayout() {
IF_MATCH
-
{writebackResult.requires_if_match ? 'required' : 'not_required'}
+
{writebackResult.if_match ?? (writebackResult.requires_if_match ? 'required' : 'not_required')}
PROVENANCE
diff --git a/frontend/tests/e2e/dashboard-branding.spec.ts b/frontend/tests/e2e/dashboard-branding.spec.ts index 213e0c80f..e7a2e6834 100644 --- a/frontend/tests/e2e/dashboard-branding.spec.ts +++ b/frontend/tests/e2e/dashboard-branding.spec.ts @@ -741,6 +741,7 @@ test('renders calendar writeback intent status without direct provider writes', await page.goto('/calendar'); await expect(page.getByText('Customer CalDAV').first()).toBeVisible(); + await expect(page.getByText('etag=etag-caldav-primary')).toBeVisible(); const desktopWritebackRequest = page.waitForRequest((request) => { const url = new URL(request.url()); return url.pathname === '/api/calendar/writeback-intent' && request.method() === 'POST'; @@ -828,6 +829,7 @@ test('renders data WebDAV writeback intent status without direct provider writes }, expectedNaruonToken); await page.goto('/data'); + await expect(page.getByText('etag=etag-webdav-primary')).toBeVisible(); const desktopWritebackRequest = page.waitForRequest((request) => { const url = new URL(request.url()); return url.pathname === '/api/webdav/writeback-intent' && request.method() === 'POST'; diff --git a/frontend/tests/e2e/helpers.ts b/frontend/tests/e2e/helpers.ts index 65a3f5a8b..e3215b251 100644 --- a/frontend/tests/e2e/helpers.ts +++ b/frontend/tests/e2e/helpers.ts @@ -406,6 +406,7 @@ export async function mockDashboardApi(page: Page, onApiRequest?: (path: string, server_url: 'https://webdav.naruon.net', username: 'demo_user', writeback_enabled: true, + etag: 'etag-webdav-primary', }, ]); return; diff --git a/frontend/tests/e2e/mobile-hamburger.spec.ts b/frontend/tests/e2e/mobile-hamburger.spec.ts index e32671566..859d4b944 100644 --- a/frontend/tests/e2e/mobile-hamburger.spec.ts +++ b/frontend/tests/e2e/mobile-hamburger.spec.ts @@ -18,19 +18,36 @@ test.describe('Mobile Workspace Navigation', () => { const mobileMenu = page.locator('#mobile-workspace-menu'); await expect(mobileMenu).toBeVisible(); + await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe('hidden'); // Verify some expected elements in the menu await expect(mobileMenu.getByText('시작 화면', { exact: true })).toBeVisible(); await expect(mobileMenu.getByText('워크스페이스 메뉴', { exact: true })).toBeVisible(); + await expect(mobileMenu.getByRole('link', { name: /데이터/ })).toBeVisible(); + await expect(mobileMenu.getByRole('link', { name: /보안/ })).toBeVisible(); - // Take a screenshot of the opened menu await page.screenshot({ path: testInfo.outputPath('mobile-hamburger-open.png'), fullPage: false }); + const drawerScrollMetrics = await mobileMenu.evaluate((drawer) => { + const before = drawer.scrollTop; + drawer.scrollTop = drawer.scrollHeight; + return { + before, + after: drawer.scrollTop, + maxScroll: drawer.scrollHeight - drawer.clientHeight, + }; + }); + expect(drawerScrollMetrics.maxScroll).toBeGreaterThan(0); + expect(drawerScrollMetrics.after).toBeGreaterThan(drawerScrollMetrics.before); + + await page.screenshot({ path: testInfo.outputPath('mobile-hamburger-open-scrolled.png'), fullPage: false }); + // Close the menu const closeButton = page.locator('button[aria-label="모바일 워크스페이스 메뉴 닫기"]'); await closeButton.click(); // Playwright popover might need a moment to hide await expect(mobileMenu).not.toBeVisible(); + await expect.poll(() => page.evaluate(() => document.body.style.overflow)).not.toBe('hidden'); }); });