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.test.tsx b/frontend/src/components/EmailList.test.tsx index 771a4cd85..a210455cb 100644 --- a/frontend/src/components/EmailList.test.tsx +++ b/frontend/src/components/EmailList.test.tsx @@ -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(); + }); + await flushAsyncWork(); + + const emptyStatuses = Array.from(container.querySelectorAll('[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( @@ -271,4 +291,4 @@ describe("EmailList", () => { expect(container.textContent).not.toContain("alert(1)"); expect(container.textContent).not.toContain("alert(2)"); }); -}); +}); \ No newline at end of file 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.live-region.test.tsx b/frontend/src/components/SearchLayout.live-region.test.tsx new file mode 100644 index 000000000..f199ed6c1 --- /dev/null +++ b/frontend/src/components/SearchLayout.live-region.test.tsx @@ -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
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 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("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(); + }); +}); \ 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/frontend/src/components/SearchLayout.tsx b/frontend/src/components/SearchLayout.tsx index 92f16b184..a29b21fc3 100644 --- a/frontend/src/components/SearchLayout.tsx +++ b/frontend/src/components/SearchLayout.tsx @@ -208,7 +208,7 @@ function SenderDagPanel({ if (relationships.length === 0) { return (
-

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

+

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

{canCapture ? (

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

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

+

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

) : null} @@ -617,7 +617,7 @@ export function SearchLayout() { {error}
) : filteredResults.length === 0 ? ( -
+
맥락 검색 결과가 없습니다.
) : ( @@ -1027,4 +1027,4 @@ export function SearchLayout() {
); -} +} \ No newline at end of file