From be5311b907d1bdfee0df86ab7c7bd88cc9a13854 Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Sat, 13 Jun 2026 12:25:26 +0300 Subject: [PATCH] fix: catch AbortErrors in storybook tests via reatom PR #1294 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses the pkg.pr.new preview build from reatom/reatom#1294 which fixes the root cause: onReject no longer fires for abort rejections (aborts route to onSettle instead). Drops the local @reatom/core patch. AbortError guard (.storybook/abortErrorGuard.ts) hooks every `.onReject` action via addGlobalExtension + withCallHook. The global beforeEach drains collected errors after each story test and throws on unexpected AbortErrors. assertNoRouteLoaderAbortErrors proves the fix: navigation teardowns must NOT surface loader AbortErrors on onReject. This is a positive regression test — fails loudly against unpatched @reatom/core. Other changes in this branch: - Split Connections/Chat/Articles stories by route mode (detail, direct-url, list-request, list, navigation) to isolate abort attribution per story - Route loader contract tests (reatomRouteContracts.test.ts) - connectLogger: on by default in dev, off in test env - conversationUnreadCountAtom: removed redundant withConnectHook causing double-fetch - Storybook URL setup: auth before urlAtom.go() to prevent loader re-evaluations - Timer: aria-label on custom duration input - Vitest detection: __vitest_worker__ instead of broken import.meta.env.VITEST - Actor: use tryTo instead of hopeThat for negative filter check (fixes soft-error leak); remove unused blur method 330/330 tests pass. Fallow health/dead-code/dupes clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- .config/mise/conf.d/tasks-test.toml | 3 + .storybook/abortErrorGuard.ts | Bin 0 -> 2276 bytes .storybook/preview.tsx | 29 +- .storybook/setupStorybookUrl.ts | 6 +- bun.lock | 8 +- package.json | 4 +- .../integration/Articles.detail.stories.tsx | 112 +++++ .../Articles.direct-url.stories.tsx | 49 +++ .../Articles.list-request.stories.tsx | 101 +++++ src/app/integration/Articles.list.stories.tsx | 102 +++++ .../Articles.navigation.stories.tsx | 51 +++ src/app/integration/Articles.stories.tsx | 332 --------------- src/app/integration/Auth.stories.tsx | 6 +- src/app/integration/Calculator.stories.tsx | 5 +- src/app/integration/Chat.detail.stories.tsx | 117 ++++++ .../integration/Chat.direct-url.stories.tsx | 27 ++ .../integration/Chat.list-request.stories.tsx | 99 +++++ src/app/integration/Chat.list.stories.tsx | 70 ++++ src/app/integration/Chat.stories.tsx | 276 ------------- .../Connections.detail.stories.tsx | 160 ++++++++ .../Connections.direct-url.stories.tsx | 27 ++ .../Connections.list-request.stories.tsx | 104 +++++ .../integration/Connections.list.stories.tsx | 116 ++++++ .../Connections.navigation.stories.tsx | 55 +++ src/app/integration/Connections.stories.tsx | 383 ------------------ src/app/integration/Dashboard.stories.tsx | 5 +- src/app/integration/Items.stories.tsx | 7 +- src/app/integration/Pricing.stories.tsx | 5 +- src/app/integration/Settings.stories.tsx | 5 +- src/app/integration/SidebarFooter.stories.tsx | 5 +- src/app/integration/Timeline.stories.tsx | 5 +- src/app/integration/Timer.stories.tsx | 5 +- src/app/integration/Usage.stories.tsx | 5 +- .../conversation/model/unreadCount.ts | 9 +- src/pages/chat/ui/ChatNavItem.tsx | 10 +- src/pages/timer/testing.ts | 3 +- src/pages/timer/ui/TimerPage.tsx | 1 + src/shared/test/actor.ts | 5 - src/shared/test/reatomRouteContracts.test.ts | 69 ++++ vitest.config.ts | 11 + 40 files changed, 1360 insertions(+), 1032 deletions(-) create mode 100644 .storybook/abortErrorGuard.ts create mode 100644 src/app/integration/Articles.detail.stories.tsx create mode 100644 src/app/integration/Articles.direct-url.stories.tsx create mode 100644 src/app/integration/Articles.list-request.stories.tsx create mode 100644 src/app/integration/Articles.list.stories.tsx create mode 100644 src/app/integration/Articles.navigation.stories.tsx delete mode 100644 src/app/integration/Articles.stories.tsx create mode 100644 src/app/integration/Chat.detail.stories.tsx create mode 100644 src/app/integration/Chat.direct-url.stories.tsx create mode 100644 src/app/integration/Chat.list-request.stories.tsx create mode 100644 src/app/integration/Chat.list.stories.tsx delete mode 100644 src/app/integration/Chat.stories.tsx create mode 100644 src/app/integration/Connections.detail.stories.tsx create mode 100644 src/app/integration/Connections.direct-url.stories.tsx create mode 100644 src/app/integration/Connections.list-request.stories.tsx create mode 100644 src/app/integration/Connections.list.stories.tsx create mode 100644 src/app/integration/Connections.navigation.stories.tsx delete mode 100644 src/app/integration/Connections.stories.tsx create mode 100644 src/shared/test/reatomRouteContracts.test.ts diff --git a/.config/mise/conf.d/tasks-test.toml b/.config/mise/conf.d/tasks-test.toml index f077bb0..de72f09 100644 --- a/.config/mise/conf.d/tasks-test.toml +++ b/.config/mise/conf.d/tasks-test.toml @@ -17,13 +17,16 @@ VITE_CONNECT_LOGGER = "false" description = "Run tests in watch mode" alias = "t" run = "vp test" +env = { VITE_CONNECT_LOGGER = "false" } [tasks."test:run"] description = "Run tests once" alias = "tr" run = "vp test run" +env = { VITE_CONNECT_LOGGER = "false" } [tasks."test:coverage"] description = "Run tests with coverage" alias = "tcov" run = "vp test run --coverage" +env = { VITE_CONNECT_LOGGER = "false" } diff --git a/.storybook/abortErrorGuard.ts b/.storybook/abortErrorGuard.ts new file mode 100644 index 0000000000000000000000000000000000000000..6975663e53eca1f2a9a2137af2c76480f37bc227 GIT binary patch literal 2276 zcmZ`*!EWO=5bf3aiorHmQ>n{rieBn&(iYpGJ#^DxQJ{xx05Q@?=2|ATBxNTs^d0@6 z{#b{kBx~*TWSiu?nR)a0Xt`;$aj*wb6u;H_QPek24waQ!EkN2|9%(mx$z535Nw<0@ z>iT!B*U&*}^akcX8x&4&E^=*fo+MH^G^NN9-s!r=++h)LZj8|e_Q^~Huv@XgC0J*q zs*>3TtrgW_p-U2c3bh^Jel2^9KL3;Ta*a@{Jo1l4XH(If}9J$wUE~+%ptjDsc&Tj<29<$CTx;(TQ>ZC$F?i!y7GdtUdWBf3P?yF&~A4HcW zvpxu)q;)hTitOv+6*@*(DV>oeu;D}s!#5tthi|UnlFndlag4Pwc0r=!oQFadRBf2m zC3J*I{Jl)<%%E$Hf_n-aO4*9KMdI^eLVIkMSlI|S85uReLO%Nf`6*moC$qTCH=j(^pcrcJ*H7Mmu5Gmg*4pS>-?) zq742+laP z3XCEixj3O>0VF<$)9X3mWNlVwZ;7<{hN$~&3PRv;97maPvi`q zqKotM1kT}3Tj}H$!C*zrc?W`gh4~u1x_gI-A75X7_rv!~5V25*m>|4Yc`qR_Z&Q%9 zisqlMGGMzEDv-|h5!UI(LfE8$6gDm0joUx}0^3I8qTFtU9;s;`YnelKeq4G6@%C`= zen`h9q04IJK$PTvRGlNkDD+Nw7$vr{65c~O+zD&Z!fqvN^l6}!Pvo^A6*M*3=8v>! z^v+UaZBai1-7peIPYYTP$B@BvQW^Xp5*hgMq5Uy(4KmHJ z`qLOfju}lf($l}$J-37R_s=kI$cGp`_TvM*fbroCxV-<_`!Mx#`p1AVCk(v+L$Q7; zlr~_FTjT@r6jwy^)B*-p=V?E*0LJ5E&Zd7b*wA>$G@-N45}-#CQ?HON8QzGPJ)!sH EKOW2yfdBvi literal 0 HcmV?d00001 diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 7549a2a..2164d37 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -6,8 +6,10 @@ import { reatomContext } from '@reatom/react' import addonA11y from '@storybook/addon-a11y' import { definePreview } from '@storybook/react-vite' import { initialize, mswLoader } from 'msw-storybook-addon' + +import { clearAbortErrors, drainAbortErrors, formatAbortErrors } from './abortErrorGuard' // oxlint-disable-next-line no-restricted-imports -import { useMemo, type PropsWithChildren } from 'react' +import { useEffect, useMemo, type PropsWithChildren } from 'react' import { handlers } from '#app/mocks/handlers' import { setAuthenticatedForTest } from '#entities/auth' @@ -31,10 +33,18 @@ function ReatomDecorator({ authenticated = true, }: PropsWithChildren<{ authenticated?: boolean; initialPath?: string }>) { const frame = useMemo(() => { - const nextFrame = setupStorybookUrl(initialPath) - nextFrame.run(() => setAuthenticatedForTest(authenticated ? authMockSession : null)) - return nextFrame + return setupStorybookUrl(initialPath, () => { + setAuthenticatedForTest(authenticated ? authMockSession : null) + }) }, [authenticated, initialPath]) + + useEffect(() => { + return () => { + clearAbortErrors() + queueMicrotask(clearAbortErrors) + } + }, [frame]) + return {children} } @@ -62,12 +72,21 @@ const preview = definePreview({ }, // fallow-ignore-next-line complexity beforeEach: async ({ globals }) => { - if (!import.meta.env['VITEST']) return + clearAbortErrors() + if (!(globalThis as Record)['__vitest_worker__']) return const { page } = await import('vite-plus/test/browser') const viewportGlobal = globals['viewport'] as { value?: string } | string | undefined const viewportName = typeof viewportGlobal === 'string' ? viewportGlobal : viewportGlobal?.value const viewport = (viewportName ? getViewportSize(viewportName) : null) ?? FALLBACK_VIEWPORT await page.viewport(viewport.width, viewport.height) + return () => { + const errors = drainAbortErrors() + if (errors.length > 0) { + throw new Error( + `Reatom AbortErrors detected during story test:\n${formatAbortErrors(errors)}`, + ) + } + } }, }) diff --git a/.storybook/setupStorybookUrl.ts b/.storybook/setupStorybookUrl.ts index 744b3d8..d921d92 100644 --- a/.storybook/setupStorybookUrl.ts +++ b/.storybook/setupStorybookUrl.ts @@ -1,13 +1,17 @@ import { context, noop, urlAtom, withChangeHook } from '@reatom/core' const originalHref = window.location.href -export const setupStorybookUrl = (initialPath = '') => { +export const setupStorybookUrl = (initialPath = '', beforeNavigate?: () => void) => { const frame = context.start() frame.run(() => { // Configure urlAtom for Storybook: routing state works internally but // the iframe URL stays fixed so Storybook remains happy. urlAtom.sync.set(() => noop) urlAtom.extend(withChangeHook(() => void window.history.replaceState({}, '', originalHref))) + // Run pre-navigation setup (e.g. auth) BEFORE urlAtom.go so that + // route matching and loader evaluation happen only once with the + // correct state, avoiding concurrent loader abort errors. + beforeNavigate?.() const base = import.meta.env.BASE_URL ?? '' urlAtom.go(base + initialPath) }) diff --git a/bun.lock b/bun.lock index 6bee967..1eb83d4 100644 --- a/bun.lock +++ b/bun.lock @@ -7,8 +7,8 @@ "dependencies": { "@ark-ui/react": "^5.34.0", "@icons-pack/react-simple-icons": "^13.13.0", - "@reatom/core": "^1001.0.0", - "@reatom/react": "^1001.0.0", + "@reatom/core": "https://pkg.pr.new/reatom/reatom/@reatom/core@1294", + "@reatom/react": "https://pkg.pr.new/reatom/reatom/@reatom/react@1294", "lucide-react": "^1.14.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -523,9 +523,9 @@ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], - "@reatom/core": ["@reatom/core@1001.0.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0" }, "optionalDependencies": { "idb-keyval": "*" } }, "sha512-zcHVumpWczzzYzwcSPbFyxX9N46dyVeS4F8vtK2qpNmewTKMv0eFjFDYkSu8xV+ISRhGtMz+Xr+GTS99dTTvbw=="], + "@reatom/core": ["@reatom/core@https://pkg.pr.new/reatom/reatom/@reatom/core@1294", { "dependencies": { "@standard-schema/spec": "^1.0.0" }, "optionalDependencies": { "idb-keyval": "*" } }, "sha512-fvCGYti2VczUwFmmEFaQt+Q0cFM4ghynp51scX0HMzR3iygOqVMxZh0/zHlgz153Dv7Y70R4uWMnTrsG3v0Upw=="], - "@reatom/react": ["@reatom/react@1001.0.0", "", { "peerDependencies": { "@reatom/core": "^1001.0.0", "react": ">=18.2.0", "react-dom": ">=18.2.0" } }, "sha512-7UmxyJnv9KSmOnf7uHJrtTcG0/RtHjUIOY0is+R1oNeEfEl5dKAcuza6zOobm9ABTbZVvaEgsWkguoAYK8YJtQ=="], + "@reatom/react": ["@reatom/react@https://pkg.pr.new/reatom/reatom/@reatom/react@1294", { "peerDependencies": { "@reatom/core": "^1001.1.0", "react": ">=18.2.0", "react-dom": ">=18.2.0" } }, "sha512-JIdqF48kbZLKVOPBkdL1k8SHrRtjcDLZMAhT+XIl6+NeblJSC05+TUcVdu1fMKI1yvz1GygJxM7hEH26GufrIg=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ=="], diff --git a/package.json b/package.json index 216d11d..70a33ef 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ "dependencies": { "@ark-ui/react": "^5.34.0", "@icons-pack/react-simple-icons": "^13.13.0", - "@reatom/core": "^1001.0.0", - "@reatom/react": "^1001.0.0", + "@reatom/core": "https://pkg.pr.new/reatom/reatom/@reatom/core@1294", + "@reatom/react": "https://pkg.pr.new/reatom/reatom/@reatom/react@1294", "lucide-react": "^1.14.0", "react": "^19.2.6", "react-dom": "^19.2.6" diff --git a/src/app/integration/Articles.detail.stories.tsx b/src/app/integration/Articles.detail.stories.tsx new file mode 100644 index 0000000..88106d3 --- /dev/null +++ b/src/app/integration/Articles.detail.stories.tsx @@ -0,0 +1,112 @@ +import preview from '#.storybook/preview' +import { App } from '#app/App' +import { articleDetail } from '#entities/article/mocks/handlers' +import { articlesActor as I } from '#pages/articles/testing' +import { heading, role } from '#shared/test' + +const meta = preview.meta({ + title: 'Integration/Articles/Detail', + component: App, + parameters: { + layout: 'fullscreen', + initialPath: 'articles/1', + }, + loaders: [(ctx) => I.init(ctx)], +}) + +export default meta + +export const HandlesArticleDetailServerError = meta.story({ + name: 'Article Detail Server Error', + play: () => I.waitExit(role('status')), + parameters: { + msw: { + handlers: { articleDetail: articleDetail.error }, + }, + }, +}) + +HandlesArticleDetailServerError.test( + 'shows error state when article detail request fails', + async () => { + await I.scope(role('main'), async () => { + await I.seeDetailError() + }) + }, +) + +HandlesArticleDetailServerError.test('keeps detail error state when retry also fails', async () => { + await I.scope(role('main'), async () => { + await I.seeDetailError() + await I.retry() + await I.waitExit(role('status')) + await I.seeDetailError() + }) +}) + +export const RecoversAfterArticleDetailRetry = meta.story({ + name: 'Article Detail Retry Success', + play: () => I.waitExit(role('status')), + parameters: { + msw: { + handlers: { articleDetail: articleDetail.retrySucceeds() }, + }, + }, +}) + +RecoversAfterArticleDetailRetry.test('loads article detail after retry succeeds', async () => { + await I.scope(role('main'), async () => { + await I.seeDetailError() + await I.retry() + await I.waitExit(role('status')) + await I.see(heading('Quarterly report').wait()) + await I.seeArticleDetail('Quarterly report') + }) +}) + +export const HandlesArticleDetailServerErrorMobile = meta.story({ + name: 'Article Detail Server Error (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + parameters: HandlesArticleDetailServerError.input.parameters, + play: () => I.waitExit(role('status')), +}) + +HandlesArticleDetailServerErrorMobile.test( + '[mobile] shows error state when article detail request fails', + async () => { + await I.scope(role('main'), async () => { + await I.seeDetailError() + }) + }, +) + +export const KeepsLoadingWhenArticleDetailNeverResolves = meta.story({ + name: 'Article Detail Loading State', + parameters: { + msw: { + handlers: { articleDetail: articleDetail.loading }, + }, + }, +}) + +KeepsLoadingWhenArticleDetailNeverResolves.test( + 'shows detail loading state while article detail is pending', + async () => { + const detail = await I.see(role('main')) + await I.seeDetailLoading(detail) + }, +) + +export const KeepsLoadingWhenArticleDetailNeverResolvesMobile = meta.story({ + name: 'Article Detail Loading State (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + parameters: KeepsLoadingWhenArticleDetailNeverResolves.input.parameters, +}) + +KeepsLoadingWhenArticleDetailNeverResolvesMobile.test( + '[mobile] shows detail loading state while article detail is pending', + async () => { + const detail = await I.see(role('main')) + await I.seeDetailLoading(detail) + }, +) diff --git a/src/app/integration/Articles.direct-url.stories.tsx b/src/app/integration/Articles.direct-url.stories.tsx new file mode 100644 index 0000000..467ddaa --- /dev/null +++ b/src/app/integration/Articles.direct-url.stories.tsx @@ -0,0 +1,49 @@ +import preview from '#.storybook/preview' +import { App } from '#app/App' +import { articlesActor as I } from '#pages/articles/testing' +import { role } from '#shared/test' + +const meta = preview.meta({ + title: 'Integration/Articles/Direct URL', + component: App, + parameters: { + layout: 'fullscreen', + initialPath: 'articles/1', + }, + loaders: [(ctx) => I.init(ctx)], +}) + +export default meta + +export const DirectUrlNavigation = meta.story({ + name: 'Direct URL to Article', + play: () => I.waitExit(role('status')), +}) + +DirectUrlNavigation.test('loads article detail directly from URL', async () => { + await I.seeArticleDetail('Quarterly report') + await I.seeArticleDetailContent() +}) + +export const DirectUrlNotFound = meta.story({ + name: 'Direct URL to Missing Article', + parameters: { initialPath: 'articles/missing-42' }, + play: () => I.waitExit(role('status')), +}) + +DirectUrlNotFound.test('shows not-found state for missing article URL', async () => { + await I.scope(role('main'), async () => { + await I.seeArticleNotFound('missing-42') + }) +}) + +export const DirectUrlNavigationMobile = meta.story({ + name: 'Direct URL to Article (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + parameters: { initialPath: 'articles/1' }, + play: () => I.waitExit(role('status')), +}) + +DirectUrlNavigationMobile.test('[mobile] loads article detail directly from URL', async () => { + await I.seeArticleDetail('Quarterly report') +}) diff --git a/src/app/integration/Articles.list-request.stories.tsx b/src/app/integration/Articles.list-request.stories.tsx new file mode 100644 index 0000000..5b09851 --- /dev/null +++ b/src/app/integration/Articles.list-request.stories.tsx @@ -0,0 +1,101 @@ +import preview from '#.storybook/preview' +import { App } from '#app/App' +import { articleList } from '#entities/article/mocks/handlers' +import { articlesActor as I } from '#pages/articles/testing' +import { role, text } from '#shared/test' + +const meta = preview.meta({ + title: 'Integration/Articles/List Request', + component: App, + parameters: { + layout: 'fullscreen', + initialPath: 'articles', + }, + loaders: [(ctx) => I.init(ctx)], +}) + +export default meta + +export const HandlesArticlesLoadServerError = meta.story({ + name: 'Articles Load Server Error', + parameters: { + msw: { + handlers: { articleList: articleList.error }, + }, + }, + play: () => I.waitExit(role('status')), +}) + +HandlesArticlesLoadServerError.test('shows error state when articles request fails', async () => { + await I.seeError() + await I.see(text("We couldn't load the article list. Try again in a moment.")) +}) + +HandlesArticlesLoadServerError.test('keeps error state when retry also fails', async () => { + await I.seeError() + await I.retry() + await I.waitExit(role('status')) + await I.seeError() +}) + +export const RecoversAfterArticlesLoadRetry = meta.story({ + name: 'Articles Load Retry Success', + play: () => I.waitExit(role('status')), + parameters: { + msw: { + handlers: { articleList: articleList.retrySucceeds() }, + }, + }, +}) + +RecoversAfterArticlesLoadRetry.test('loads article list after retry succeeds', async () => { + await I.seeError() + await I.retry() + await I.waitExit(role('status')) + await I.see(role('list', 'Articles').wait()) + await I.seeArticleList() +}) + +export const HandlesArticlesLoadServerErrorMobile = meta.story({ + name: 'Articles Load Server Error (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + parameters: HandlesArticlesLoadServerError.input.parameters, + play: () => I.waitExit(role('status')), +}) + +HandlesArticlesLoadServerErrorMobile.test( + '[mobile] shows error state when articles request fails', + async () => { + await I.seeError() + await I.see(text("We couldn't load the article list. Try again in a moment.")) + }, +) + +export const KeepsLoadingWhenArticlesRequestNeverResolves = meta.story({ + name: 'Articles Request Loading State', + parameters: { + msw: { + handlers: { articleList: articleList.loading }, + }, + }, +}) + +KeepsLoadingWhenArticlesRequestNeverResolves.test( + 'keeps loading state for pending articles request', + async () => { + await I.seeLoading() + }, +) + +export const KeepsLoadingWhenArticlesRequestNeverResolvesMobile = meta.story({ + name: 'Articles Request Loading State (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + parameters: KeepsLoadingWhenArticlesRequestNeverResolves.input.parameters, +}) + +KeepsLoadingWhenArticlesRequestNeverResolvesMobile.test( + '[mobile] keeps loading state for pending articles request', + async () => { + await I.seeLoading() + }, +) diff --git a/src/app/integration/Articles.list.stories.tsx b/src/app/integration/Articles.list.stories.tsx new file mode 100644 index 0000000..b2bf1ba --- /dev/null +++ b/src/app/integration/Articles.list.stories.tsx @@ -0,0 +1,102 @@ +import { assertNoRouteLoaderAbortErrors } from '#.storybook/abortErrorGuard' +import preview from '#.storybook/preview' +import { App } from '#app/App' +import { articlesActor as I } from '#pages/articles/testing' +import { role } from '#shared/test' + +const meta = preview.meta({ + title: 'Integration/Articles/List', + component: App, + parameters: { + layout: 'fullscreen', + initialPath: 'articles', + }, + loaders: [(ctx) => I.init(ctx)], +}) + +export default meta + +const assertExpectedDetailTeardown = async () => { + await assertNoRouteLoaderAbortErrors('articleDetail') +} + +export const Default = meta.story({ + name: 'Default', + play: () => I.waitExit(role('status')), +}) + +Default.test('renders article list with no selection message', async () => { + await I.seeNoSelection() + await I.seeArticleList() + await I.seeStatusBadges() +}) + +Default.test('shows search toolbar with new article button', async () => { + await I.seeSearchToolbar() +}) + +Default.test('shows article descriptions in list items', async () => { + await I.seeArticleDescription(/Revenue overview and growth metrics/) + await I.seeArticleDescription(/Engineering headcount proposal/) +}) + +Default.test('shows article detail when article is clicked', async () => { + await I.openArticle(/Quarterly report/i) + await I.seeArticleDetail('Quarterly report') +}) + +Default.test('shows all content paragraphs in article detail', async () => { + await I.openArticle(/Quarterly report/i) + await I.seeArticleDetail('Quarterly report') + await I.seeArticleDetailContent() +}) + +Default.test('shows edit button and status badge in article detail', async () => { + await I.openArticle(/Quarterly report/i) + await I.seeArticleDetail('Quarterly report') + await I.seeArticleDetailStatus('Done') +}) + +Default.test('shows article description in detail view', async () => { + await I.openArticle(/Quarterly report/i) + await I.seeArticleDetailDescription( + 'Revenue overview and growth metrics for Q3 across all regions.', + ) +}) + +export const DefaultMobile = meta.story({ + name: 'Default (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + play: () => I.waitExit(role('status')), +}) + +DefaultMobile.test('[mobile] shows article list when no article is selected', async () => { + await I.seeArticleList() +}) + +DefaultMobile.test('[mobile] shows search toolbar with new article button', async () => { + await I.seeSearchToolbar() +}) + +DefaultMobile.test('[mobile] shows article detail when article is clicked', async () => { + await I.openArticle(/Quarterly report/i) + await I.seeArticleDetail('Quarterly report') +}) + +DefaultMobile.test('[mobile] shows all content paragraphs in article detail', async () => { + await I.openArticle(/Quarterly report/i) + await I.seeArticleDetail('Quarterly report') + await I.seeArticleDetailContent() +}) + +DefaultMobile.test('[mobile] displays correct status badges for different statuses', async () => { + await I.seeStatusBadges() +}) + +DefaultMobile.test('[mobile] can navigate back to article list', async () => { + await I.openArticle(/Quarterly report/i) + await I.seeArticleDetail('Quarterly report') + await I.goBack() + await I.see(role('list', 'Articles').wait()) + await assertExpectedDetailTeardown() +}) diff --git a/src/app/integration/Articles.navigation.stories.tsx b/src/app/integration/Articles.navigation.stories.tsx new file mode 100644 index 0000000..46bb2f8 --- /dev/null +++ b/src/app/integration/Articles.navigation.stories.tsx @@ -0,0 +1,51 @@ +import { assertNoRouteLoaderAbortErrors } from '#.storybook/abortErrorGuard' +import preview from '#.storybook/preview' +import { App } from '#app/App' +import { articlesActor as I } from '#pages/articles/testing' +import { role } from '#shared/test' + +const meta = preview.meta({ + title: 'Integration/Articles/Navigation', + component: App, + parameters: { + layout: 'fullscreen', + initialPath: 'articles', + }, + loaders: [(ctx) => I.init(ctx)], +}) + +export default meta + +export const SwitchBetweenArticles = meta.story({ + name: 'Switch Between Articles', + play: () => I.waitExit(role('status')), +}) + +SwitchBetweenArticles.test('can switch from one article detail to another', async () => { + await I.openArticle(/Quarterly report/i) + await I.seeArticleDetail('Quarterly report') + + await I.openArticle(/Hiring plan/i) + await I.seeArticleDetail('Hiring plan') +}) + +export const SwitchBetweenArticlesMobile = meta.story({ + name: 'Switch Between Articles (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + play: () => I.waitExit(role('status')), +}) + +SwitchBetweenArticlesMobile.test( + '[mobile] can switch to another article after navigating back', + async () => { + await I.openArticle(/Quarterly report/i) + await I.seeArticleDetail('Quarterly report') + + await I.goBack() + await I.see(role('list', 'Articles').wait()) + await assertNoRouteLoaderAbortErrors('articleDetail') + + await I.openArticle(/Hiring plan/i) + await I.seeArticleDetail('Hiring plan') + }, +) diff --git a/src/app/integration/Articles.stories.tsx b/src/app/integration/Articles.stories.tsx deleted file mode 100644 index 54deb86..0000000 --- a/src/app/integration/Articles.stories.tsx +++ /dev/null @@ -1,332 +0,0 @@ -import preview from '#.storybook/preview' -import { App } from '#app/App' -import { articleDetail, articleList } from '#entities/article/mocks/handlers' -import { articlesActor as I } from '#pages/articles/testing' -import { heading, link, role, text } from '#shared/test' - -const meta = preview.meta({ - title: 'Integration/Articles', - component: App, - parameters: { layout: 'fullscreen', initialPath: 'articles' }, - loaders: [(ctx) => I.init(ctx)], -}) - -export default meta - -export const Default = meta.story({ - name: 'Default', - play: () => I.waitExit(role('status')), -}) - -Default.test('renders article list with no selection message', async () => { - await I.seeNoSelection() - await I.seeArticleList() - await I.seeStatusBadges() -}) - -Default.test('shows search toolbar with new article button', async () => { - await I.seeSearchToolbar() -}) - -Default.test('shows article descriptions in list items', async () => { - await I.seeArticleDescription(/Revenue overview and growth metrics/) - await I.seeArticleDescription(/Engineering headcount proposal/) -}) - -Default.test('shows article detail when article is clicked', async () => { - await I.openArticle(/Quarterly report/i) - await I.seeArticleDetail('Quarterly report') -}) - -Default.test('shows all content paragraphs in article detail', async () => { - await I.openArticle(/Quarterly report/i) - await I.seeArticleDetail('Quarterly report') - await I.seeArticleDetailContent() -}) - -Default.test('shows edit button and status badge in article detail', async () => { - await I.openArticle(/Quarterly report/i) - await I.seeArticleDetail('Quarterly report') - await I.seeArticleDetailStatus('Done') -}) - -Default.test('shows article description in detail view', async () => { - await I.openArticle(/Quarterly report/i) - await I.seeArticleDetailDescription( - 'Revenue overview and growth metrics for Q3 across all regions.', - ) -}) - -Default.test('can select different articles', async () => { - await I.openArticle(/Quarterly report/i) - await I.seeArticleDetail('Quarterly report') - - await I.openArticle(/Hiring plan/i) - await I.seeArticleDetail('Hiring plan') -}) - -export const DirectUrlNavigation = meta.story({ - name: 'Direct URL to Article', - parameters: { initialPath: 'articles/1' }, - play: () => I.waitExit(role('status')), -}) - -DirectUrlNavigation.test('loads article detail directly from URL', async () => { - await I.seeArticleDetail('Quarterly report') - await I.seeArticleDetailContent() -}) - -export const DirectUrlNotFound = meta.story({ - name: 'Direct URL to Missing Article', - parameters: { initialPath: 'articles/missing-42' }, - play: () => I.waitExit(role('status')), -}) - -DirectUrlNotFound.test('shows not-found state for missing article URL', async () => { - await I.scope(role('main'), async () => { - await I.seeArticleNotFound('missing-42') - }) -}) - -export const DirectUrlNavigationMobile = meta.story({ - name: 'Direct URL to Article (Mobile)', - globals: { viewport: { value: 'sm', isRotated: false } }, - parameters: { initialPath: 'articles/1' }, - play: () => I.waitExit(role('status')), -}) - -DirectUrlNavigationMobile.test('[mobile] loads article detail directly from URL', async () => { - await I.seeArticleDetail('Quarterly report') -}) - -export const DefaultMobile = meta.story({ - name: 'Default (Mobile)', - globals: { viewport: { value: 'sm', isRotated: false } }, - play: () => I.waitExit(role('status')), -}) - -DefaultMobile.test('[mobile] shows article list when no article is selected', async () => { - await I.seeArticleList() -}) - -DefaultMobile.test('[mobile] shows search toolbar with new article button', async () => { - await I.seeSearchToolbar() -}) - -DefaultMobile.test('[mobile] shows article detail when article is clicked', async () => { - await I.openArticle(/Quarterly report/i) - await I.seeArticleDetail('Quarterly report') -}) - -DefaultMobile.test('[mobile] shows all content paragraphs in article detail', async () => { - await I.openArticle(/Quarterly report/i) - await I.seeArticleDetail('Quarterly report') - await I.seeArticleDetailContent() -}) - -DefaultMobile.test('[mobile] displays correct status badges for different statuses', async () => { - await I.seeStatusBadges() -}) - -DefaultMobile.test('[mobile] can select different articles', async () => { - await I.openArticle(/Quarterly report/i) - await I.seeArticleDetail('Quarterly report') - - await I.goBack() - - await I.openArticle(/Hiring plan/i) - await I.seeArticleDetail('Hiring plan') -}) - -export const HandlesArticlesLoadServerError = meta.story({ - name: 'Articles Load Server Error', - parameters: { - msw: { - handlers: { articleList: articleList.error }, - }, - }, - play: () => I.waitExit(role('status')), -}) - -HandlesArticlesLoadServerError.test('shows error state when articles request fails', async () => { - await I.seeError() - await I.see(text("We couldn't load the article list. Try again in a moment.")) -}) - -HandlesArticlesLoadServerError.test('keeps error state when retry also fails', async () => { - await I.seeError() - await I.retry() - await I.waitExit(role('status')) - await I.seeError() -}) - -export const RecoversAfterArticlesLoadRetry = meta.story({ - name: 'Articles Load Retry Success', - play: () => I.waitExit(role('status')), - parameters: { - msw: { - handlers: { articleList: articleList.retrySucceeds() }, - }, - }, -}) - -RecoversAfterArticlesLoadRetry.test('loads article list after retry succeeds', async () => { - await I.seeError() - await I.retry() - await I.waitExit(role('status')) - await I.see(role('list', 'Articles').wait()) - await I.seeArticleList() -}) - -export const HandlesArticlesLoadServerErrorMobile = meta.story({ - name: 'Articles Load Server Error (Mobile)', - globals: { viewport: { value: 'sm', isRotated: false } }, - parameters: HandlesArticlesLoadServerError.input.parameters, - play: () => I.waitExit(role('status')), -}) - -HandlesArticlesLoadServerErrorMobile.test( - '[mobile] shows error state when articles request fails', - async () => { - await I.seeError() - await I.see(text("We couldn't load the article list. Try again in a moment.")) - }, -) - -export const KeepsLoadingWhenArticlesRequestNeverResolves = meta.story({ - name: 'Articles Request Loading State', - parameters: { - msw: { - handlers: { articleList: articleList.loading }, - }, - }, -}) - -KeepsLoadingWhenArticlesRequestNeverResolves.test( - 'keeps loading state for pending articles request', - async () => { - await I.seeLoading() - }, -) - -export const KeepsLoadingWhenArticlesRequestNeverResolvesMobile = meta.story({ - name: 'Articles Request Loading State (Mobile)', - globals: { viewport: { value: 'sm', isRotated: false } }, - parameters: KeepsLoadingWhenArticlesRequestNeverResolves.input.parameters, -}) - -KeepsLoadingWhenArticlesRequestNeverResolvesMobile.test( - '[mobile] keeps loading state for pending articles request', - async () => { - await I.seeLoading() - }, -) - -export const HandlesArticleDetailServerError = meta.story({ - name: 'Article Detail Server Error', - play: () => I.waitExit(role('status')), - parameters: { - msw: { - handlers: { articleDetail: articleDetail.error }, - }, - }, -}) - -HandlesArticleDetailServerError.test( - 'shows error state when article detail request fails', - async () => { - await I.openArticle(/Quarterly report/i) - - await I.scope(role('main'), async () => { - await I.seeDetailError() - }) - }, -) - -HandlesArticleDetailServerError.test('keeps detail error state when retry also fails', async () => { - await I.openArticle(/Quarterly report/i) - - await I.scope(role('main'), async () => { - await I.seeDetailError() - await I.retry() - await I.waitExit(role('status')) - await I.seeDetailError() - }) -}) - -export const RecoversAfterArticleDetailRetry = meta.story({ - name: 'Article Detail Retry Success', - play: () => I.waitExit(role('status')), - parameters: { - msw: { - handlers: { articleDetail: articleDetail.retrySucceeds() }, - }, - }, -}) - -RecoversAfterArticleDetailRetry.test('loads article detail after retry succeeds', async () => { - await I.openArticle(/Quarterly report/i) - - await I.scope(role('main'), async () => { - await I.seeDetailError() - await I.retry() - await I.waitExit(role('status')) - await I.see(heading('Quarterly report').wait()) - await I.seeArticleDetail('Quarterly report') - }) -}) - -export const HandlesArticleDetailServerErrorMobile = meta.story({ - name: 'Article Detail Server Error (Mobile)', - globals: { viewport: { value: 'sm', isRotated: false } }, - parameters: HandlesArticleDetailServerError.input.parameters, - play: () => I.waitExit(role('status')), -}) - -HandlesArticleDetailServerErrorMobile.test( - '[mobile] shows error state when article detail request fails', - async () => { - await I.openArticle(/Quarterly report/i) - - await I.scope(role('main'), async () => { - await I.seeDetailError() - }) - }, -) - -export const KeepsLoadingWhenArticleDetailNeverResolves = meta.story({ - name: 'Article Detail Loading State', - play: () => I.waitExit(role('status')), - parameters: { - msw: { - handlers: { articleDetail: articleDetail.loading }, - }, - }, -}) - -KeepsLoadingWhenArticleDetailNeverResolves.test( - 'shows detail loading state while article detail is pending', - async () => { - await I.click(link(/Quarterly report/i)) - - const detail = await I.see(role('main')) - await I.seeDetailLoading(detail) - }, -) - -export const KeepsLoadingWhenArticleDetailNeverResolvesMobile = meta.story({ - name: 'Article Detail Loading State (Mobile)', - globals: { viewport: { value: 'sm', isRotated: false } }, - parameters: KeepsLoadingWhenArticleDetailNeverResolves.input.parameters, - play: () => I.waitExit(role('status')), -}) - -KeepsLoadingWhenArticleDetailNeverResolvesMobile.test( - '[mobile] shows detail loading state while article detail is pending', - async () => { - await I.click(link(/Quarterly report/i)) - - const detail = await I.see(role('main')) - await I.seeDetailLoading(detail) - }, -) diff --git a/src/app/integration/Auth.stories.tsx b/src/app/integration/Auth.stories.tsx index d8d533e..1d11a50 100644 --- a/src/app/integration/Auth.stories.tsx +++ b/src/app/integration/Auth.stories.tsx @@ -12,7 +12,11 @@ const field = (name: string) => (canvas: Canvas) => canvas.getByLabelText(name) const meta = preview.meta({ title: 'Integration/Auth', component: App, - parameters: { layout: 'fullscreen', authenticated: false, initialPath: 'login' }, + parameters: { + layout: 'fullscreen', + authenticated: false, + initialPath: 'login', + }, loaders: [(ctx) => I.init(ctx)], }) diff --git a/src/app/integration/Calculator.stories.tsx b/src/app/integration/Calculator.stories.tsx index 9f5d7e5..c6956fc 100644 --- a/src/app/integration/Calculator.stories.tsx +++ b/src/app/integration/Calculator.stories.tsx @@ -5,7 +5,10 @@ import { calculatorActor as I, calculatorLoc as loc } from '#pages/calculator/te const meta = preview.meta({ title: 'Integration/Calculator', component: App, - parameters: { layout: 'fullscreen', initialPath: 'calculator' }, + parameters: { + layout: 'fullscreen', + initialPath: 'calculator', + }, loaders: [(ctx) => I.init(ctx)], }) diff --git a/src/app/integration/Chat.detail.stories.tsx b/src/app/integration/Chat.detail.stories.tsx new file mode 100644 index 0000000..fe68683 --- /dev/null +++ b/src/app/integration/Chat.detail.stories.tsx @@ -0,0 +1,117 @@ +import preview from '#.storybook/preview' +import { App } from '#app/App' +import { conversationDetail } from '#entities/conversation/mocks/handlers' +import { chatActor as I, chatLoc as loc } from '#pages/chat/testing' +import { role, text } from '#shared/test' + +const meta = preview.meta({ + title: 'Integration/Chat/Detail', + component: App, + parameters: { + layout: 'fullscreen', + initialPath: 'chat/1', + }, + loaders: [(ctx) => I.init(ctx)], +}) + +export default meta + +export const HandlesConversationDetailServerError = meta.story({ + name: 'Conversation Detail Server Error', + play: () => I.waitExit(role('status')), + parameters: { + msw: { + handlers: { conversationDetail: conversationDetail.error }, + }, + }, +}) + +HandlesConversationDetailServerError.test( + 'shows error state when conversation detail request fails', + async () => { + await I.scope(role('main'), async () => { + await I.seeDetailError() + }) + }, +) + +HandlesConversationDetailServerError.test( + 'keeps detail error state when retry also fails', + async () => { + await I.scope(role('main'), async () => { + await I.seeDetailError() + await I.retry() + await I.waitExit(role('status')) + await I.seeDetailError() + }) + }, +) + +export const RecoversAfterConversationDetailRetry = meta.story({ + name: 'Conversation Detail Retry Success', + play: () => I.waitExit(role('status')), + parameters: { + msw: { + handlers: { conversationDetail: conversationDetail.retrySucceeds() }, + }, + }, +}) + +RecoversAfterConversationDetailRetry.test( + 'loads conversation detail after retry succeeds', + async () => { + await I.scope(role('main'), async () => { + await I.seeDetailError() + await I.retry() + await I.waitExit(role('status')) + await I.see(text('Has anyone looked at the failing CI on main?').wait()) + }) + }, +) + +export const HandlesConversationDetailServerErrorMobile = meta.story({ + name: 'Conversation Detail Server Error (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + parameters: HandlesConversationDetailServerError.input.parameters, + play: () => I.waitExit(role('status')), +}) + +HandlesConversationDetailServerErrorMobile.test( + '[mobile] shows error state when conversation detail request fails', + async () => { + await I.scope(role('main'), async () => { + await I.seeDetailError() + }) + }, +) + +export const KeepsLoadingWhenConversationDetailNeverResolves = meta.story({ + name: 'Conversation Detail Loading State', + parameters: { + msw: { + handlers: { conversationDetail: conversationDetail.loading }, + }, + }, +}) + +KeepsLoadingWhenConversationDetailNeverResolves.test( + 'shows message thread loading state while conversation detail is pending', + async () => { + await I.see(loc.messageThreadLoading) + await I.dontSee(loc.conversationNotFoundHeading) + }, +) + +export const KeepsLoadingWhenConversationDetailNeverResolvesMobile = meta.story({ + name: 'Conversation Detail Loading State (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + parameters: KeepsLoadingWhenConversationDetailNeverResolves.input.parameters, +}) + +KeepsLoadingWhenConversationDetailNeverResolvesMobile.test( + '[mobile] shows message thread loading state while conversation detail is pending', + async () => { + await I.see(loc.messageThreadLoading) + await I.dontSee(loc.conversationNotFoundHeading) + }, +) diff --git a/src/app/integration/Chat.direct-url.stories.tsx b/src/app/integration/Chat.direct-url.stories.tsx new file mode 100644 index 0000000..67059b5 --- /dev/null +++ b/src/app/integration/Chat.direct-url.stories.tsx @@ -0,0 +1,27 @@ +import preview from '#.storybook/preview' +import { App } from '#app/App' +import { chatActor as I } from '#pages/chat/testing' +import { role } from '#shared/test' + +const meta = preview.meta({ + title: 'Integration/Chat/Direct URL', + component: App, + parameters: { + layout: 'fullscreen', + initialPath: 'chat/missing-42', + }, + loaders: [(ctx) => I.init(ctx)], +}) + +export default meta + +export const DirectUrlNotFound = meta.story({ + name: 'Direct URL to Missing Conversation', + play: () => I.waitExit(role('status')), +}) + +DirectUrlNotFound.test('shows not-found state for missing conversation URL', async () => { + await I.scope(role('main'), async () => { + await I.seeConversationNotFound('missing-42') + }) +}) diff --git a/src/app/integration/Chat.list-request.stories.tsx b/src/app/integration/Chat.list-request.stories.tsx new file mode 100644 index 0000000..2ffca65 --- /dev/null +++ b/src/app/integration/Chat.list-request.stories.tsx @@ -0,0 +1,99 @@ +import preview from '#.storybook/preview' +import { App } from '#app/App' +import { conversationList } from '#entities/conversation/mocks/handlers' +import { chatActor as I } from '#pages/chat/testing' +import { role } from '#shared/test' + +const meta = preview.meta({ + title: 'Integration/Chat/List Request', + component: App, + parameters: { + layout: 'fullscreen', + initialPath: 'chat', + }, + loaders: [(ctx) => I.init(ctx)], +}) + +export default meta + +export const HandlesChatLoadServerError = meta.story({ + name: 'Conversations Load Server Error', + play: () => I.waitExit(role('status')), + parameters: { + msw: { + handlers: { conversationList: conversationList.error }, + }, + }, +}) + +HandlesChatLoadServerError.test('shows error state when conversations request fails', async () => { + await I.seeError() +}) + +HandlesChatLoadServerError.test('keeps error state when retry also fails', async () => { + await I.seeError() + await I.retry() + await I.waitExit(role('status')) + await I.seeError() +}) + +export const RecoversAfterChatLoadRetry = meta.story({ + name: 'Conversations Load Retry Success', + play: () => I.waitExit(role('status')), + parameters: { + msw: { + handlers: { conversationList: conversationList.retrySucceeds() }, + }, + }, +}) + +RecoversAfterChatLoadRetry.test('loads conversations after retry succeeds', async () => { + await I.seeError() + await I.retry() + await I.waitExit(role('status')) + await I.see(role('list', 'Chat').wait()) + await I.seeConversationList() +}) + +export const HandlesChatLoadServerErrorMobile = meta.story({ + name: 'Conversations Load Server Error (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + parameters: HandlesChatLoadServerError.input.parameters, + play: () => I.waitExit(role('status')), +}) + +HandlesChatLoadServerErrorMobile.test( + '[mobile] shows error state when conversations request fails', + async () => { + await I.seeError() + }, +) + +export const KeepsLoadingWhenChatRequestNeverResolves = meta.story({ + name: 'Conversations Request Loading State', + parameters: { + msw: { + handlers: { conversationList: conversationList.loading }, + }, + }, +}) + +KeepsLoadingWhenChatRequestNeverResolves.test( + 'keeps loading state for pending conversations request', + async () => { + await I.seeLoading() + }, +) + +export const KeepsLoadingWhenChatRequestNeverResolvesMobile = meta.story({ + name: 'Conversations Request Loading State (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + parameters: KeepsLoadingWhenChatRequestNeverResolves.input.parameters, +}) + +KeepsLoadingWhenChatRequestNeverResolvesMobile.test( + '[mobile] keeps loading state for pending conversations request', + async () => { + await I.seeLoading() + }, +) diff --git a/src/app/integration/Chat.list.stories.tsx b/src/app/integration/Chat.list.stories.tsx new file mode 100644 index 0000000..9a5ef48 --- /dev/null +++ b/src/app/integration/Chat.list.stories.tsx @@ -0,0 +1,70 @@ +import { assertNoRouteLoaderAbortErrors } from '#.storybook/abortErrorGuard' +import preview from '#.storybook/preview' +import { App } from '#app/App' +import { chatActor as I } from '#pages/chat/testing' +import { link, role, text } from '#shared/test' + +const meta = preview.meta({ + title: 'Integration/Chat/List', + component: App, + parameters: { + layout: 'fullscreen', + initialPath: 'chat', + }, + loaders: [(ctx) => I.init(ctx)], +}) + +export default meta + +const assertExpectedDetailTeardown = async () => { + await assertNoRouteLoaderAbortErrors('chatConversation') +} + +export const Default = meta.story({ + name: 'Default', + play: () => I.waitExit(role('status')), +}) + +Default.test('renders conversation list', async () => { + await I.seeConversationList() +}) + +Default.test('shows no-selection message when no conversation selected', async () => { + await I.see(text('No conversation selected')) +}) + +Default.test('shows message thread when conversation is clicked', async () => { + await I.click(link(/Engineering/)) + await I.waitExit(role('status')) + + await I.scope(role('main'), async () => { + await I.see(text('Has anyone looked at the failing CI on main?')) + }) +}) + +export const DefaultMobile = meta.story({ + name: 'Default (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + play: () => I.waitExit(role('status')), +}) + +DefaultMobile.test('[mobile] renders conversation list', async () => { + await I.seeConversationList() +}) + +DefaultMobile.test('[mobile] shows message thread when conversation is clicked', async () => { + await I.click(link(/Engineering/)) + await I.waitExit(role('status')) + + await I.scope(role('main'), async () => { + await I.see(text('Has anyone looked at the failing CI on main?')) + }) +}) + +DefaultMobile.test('[mobile] can navigate back to conversation list', async () => { + await I.click(link(/Engineering/)) + await I.waitExit(role('status')) + await I.goBack() + await I.see(link(/Engineering/)) + await assertExpectedDetailTeardown() +}) diff --git a/src/app/integration/Chat.stories.tsx b/src/app/integration/Chat.stories.tsx deleted file mode 100644 index 9be5730..0000000 --- a/src/app/integration/Chat.stories.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import preview from '#.storybook/preview' -import { App } from '#app/App' -import { conversationDetail, conversationList } from '#entities/conversation/mocks/handlers' -import { chatActor as I, chatLoc as loc } from '#pages/chat/testing' -import { link, role, text } from '#shared/test' - -const meta = preview.meta({ - title: 'Integration/Chat', - component: App, - parameters: { layout: 'fullscreen', initialPath: 'chat' }, - loaders: [(ctx) => I.init(ctx)], -}) - -export default meta - -export const Default = meta.story({ - name: 'Default', - play: () => I.waitExit(role('status')), -}) - -Default.test('renders conversation list', async () => { - await I.seeConversationList() -}) - -Default.test('shows no-selection message when no conversation selected', async () => { - await I.see(text('No conversation selected')) -}) - -Default.test('shows message thread when conversation is clicked', async () => { - await I.click(link(/Engineering/)) - await I.waitExit(role('status')) - - await I.scope(role('main'), async () => { - await I.see(text('Has anyone looked at the failing CI on main?')) - }) -}) - -export const DirectUrlNotFound = meta.story({ - name: 'Direct URL to Missing Conversation', - parameters: { initialPath: 'chat/missing-42' }, - play: () => I.waitExit(role('status')), -}) - -DirectUrlNotFound.test('shows not-found state for missing conversation URL', async () => { - await I.scope(role('main'), async () => { - await I.seeConversationNotFound('missing-42') - }) -}) - -export const DefaultMobile = meta.story({ - name: 'Default (Mobile)', - globals: { viewport: { value: 'sm', isRotated: false } }, - play: () => I.waitExit(role('status')), -}) - -DefaultMobile.test('[mobile] renders conversation list', async () => { - await I.seeConversationList() -}) - -DefaultMobile.test('[mobile] shows message thread when conversation is clicked', async () => { - await I.click(link(/Engineering/)) - await I.waitExit(role('status')) - - await I.scope(role('main'), async () => { - await I.see(text('Has anyone looked at the failing CI on main?')) - }) -}) - -DefaultMobile.test('[mobile] can navigate back to conversation list', async () => { - await I.click(link(/Engineering/)) - await I.waitExit(role('status')) - await I.goBack() - await I.see(link(/Engineering/)) -}) - -export const HandlesChatLoadServerError = meta.story({ - name: 'Conversations Load Server Error', - play: () => I.waitExit(role('status')), - parameters: { - msw: { - handlers: { conversationList: conversationList.error }, - }, - }, -}) - -HandlesChatLoadServerError.test('shows error state when conversations request fails', async () => { - await I.seeError() -}) - -HandlesChatLoadServerError.test('keeps error state when retry also fails', async () => { - await I.seeError() - await I.retry() - await I.waitExit(role('status')) - await I.seeError() -}) - -export const RecoversAfterChatLoadRetry = meta.story({ - name: 'Conversations Load Retry Success', - play: () => I.waitExit(role('status')), - parameters: { - msw: { - handlers: { conversationList: conversationList.retrySucceeds() }, - }, - }, -}) - -RecoversAfterChatLoadRetry.test('loads conversations after retry succeeds', async () => { - await I.seeError() - await I.retry() - await I.waitExit(role('status')) - await I.see(role('list', 'Chat').wait()) - await I.seeConversationList() -}) - -export const HandlesChatLoadServerErrorMobile = meta.story({ - name: 'Conversations Load Server Error (Mobile)', - globals: { viewport: { value: 'sm', isRotated: false } }, - parameters: HandlesChatLoadServerError.input.parameters, - play: () => I.waitExit(role('status')), -}) - -HandlesChatLoadServerErrorMobile.test( - '[mobile] shows error state when conversations request fails', - async () => { - await I.seeError() - }, -) - -export const KeepsLoadingWhenChatRequestNeverResolves = meta.story({ - name: 'Conversations Request Loading State', - parameters: { - msw: { - handlers: { conversationList: conversationList.loading }, - }, - }, -}) - -KeepsLoadingWhenChatRequestNeverResolves.test( - 'keeps loading state for pending conversations request', - async () => { - await I.seeLoading() - }, -) - -export const KeepsLoadingWhenChatRequestNeverResolvesMobile = meta.story({ - name: 'Conversations Request Loading State (Mobile)', - globals: { viewport: { value: 'sm', isRotated: false } }, - parameters: KeepsLoadingWhenChatRequestNeverResolves.input.parameters, -}) - -KeepsLoadingWhenChatRequestNeverResolvesMobile.test( - '[mobile] keeps loading state for pending conversations request', - async () => { - await I.seeLoading() - }, -) - -export const HandlesConversationDetailServerError = meta.story({ - name: 'Conversation Detail Server Error', - play: () => I.waitExit(role('status')), - parameters: { - msw: { - handlers: { conversationDetail: conversationDetail.error }, - }, - }, -}) - -HandlesConversationDetailServerError.test( - 'shows error state when conversation detail request fails', - async () => { - await I.click(link(/Engineering/)) - await I.waitExit(role('status')) - - await I.scope(role('main'), async () => { - await I.seeDetailError() - }) - }, -) - -HandlesConversationDetailServerError.test( - 'keeps detail error state when retry also fails', - async () => { - await I.click(link(/Engineering/)) - await I.waitExit(role('status')) - - await I.scope(role('main'), async () => { - await I.seeDetailError() - await I.retry() - await I.waitExit(role('status')) - await I.seeDetailError() - }) - }, -) - -export const RecoversAfterConversationDetailRetry = meta.story({ - name: 'Conversation Detail Retry Success', - play: () => I.waitExit(role('status')), - parameters: { - msw: { - handlers: { conversationDetail: conversationDetail.retrySucceeds() }, - }, - }, -}) - -RecoversAfterConversationDetailRetry.test( - 'loads conversation detail after retry succeeds', - async () => { - await I.click(link(/Engineering/)) - await I.waitExit(role('status')) - - await I.scope(role('main'), async () => { - await I.seeDetailError() - await I.retry() - await I.waitExit(role('status')) - await I.see(text('Has anyone looked at the failing CI on main?').wait()) - }) - }, -) - -export const HandlesConversationDetailServerErrorMobile = meta.story({ - name: 'Conversation Detail Server Error (Mobile)', - globals: { viewport: { value: 'sm', isRotated: false } }, - parameters: HandlesConversationDetailServerError.input.parameters, - play: () => I.waitExit(role('status')), -}) - -HandlesConversationDetailServerErrorMobile.test( - '[mobile] shows error state when conversation detail request fails', - async () => { - await I.click(link(/Engineering/)) - await I.waitExit(role('status')) - - await I.scope(role('main'), async () => { - await I.seeDetailError() - }) - }, -) - -export const KeepsLoadingWhenConversationDetailNeverResolves = meta.story({ - name: 'Conversation Detail Loading State', - play: () => I.waitExit(role('status')), - parameters: { - msw: { - handlers: { conversationDetail: conversationDetail.loading }, - }, - }, -}) - -KeepsLoadingWhenConversationDetailNeverResolves.test( - 'shows message thread loading state while conversation detail is pending', - async () => { - await I.click(link(/Engineering/)) - - const detail = await I.see(role('main')) - await I.see(loc.messageThreadLoading.within(detail)) - await I.dontSee(loc.conversationNotFoundHeading.within(detail)) - }, -) - -export const KeepsLoadingWhenConversationDetailNeverResolvesMobile = meta.story({ - name: 'Conversation Detail Loading State (Mobile)', - globals: { viewport: { value: 'sm', isRotated: false } }, - parameters: KeepsLoadingWhenConversationDetailNeverResolves.input.parameters, - play: () => I.waitExit(role('status')), -}) - -KeepsLoadingWhenConversationDetailNeverResolvesMobile.test( - '[mobile] shows message thread loading state while conversation detail is pending', - async () => { - await I.click(link(/Engineering/)) - - const detail = await I.see(role('main')) - await I.see(loc.messageThreadLoading.within(detail)) - await I.dontSee(loc.conversationNotFoundHeading.within(detail)) - }, -) diff --git a/src/app/integration/Connections.detail.stories.tsx b/src/app/integration/Connections.detail.stories.tsx new file mode 100644 index 0000000..44f37b8 --- /dev/null +++ b/src/app/integration/Connections.detail.stories.tsx @@ -0,0 +1,160 @@ +import preview from '#.storybook/preview' +import { App } from '#app/App' +import { connectionDetail } from '#entities/connection/mocks/handlers' +import { connectionsActor as I, connectionsLoc as loc } from '#pages/connections/testing' +import { button, heading, role, text } from '#shared/test' + +const meta = preview.meta({ + title: 'Integration/Connections/Detail', + component: App, + parameters: { + layout: 'fullscreen', + initialPath: 'connections/1', + }, + loaders: [(ctx) => I.init(ctx)], +}) + +export default meta + +export const HandlesConnectionDetailServerError = meta.story({ + name: 'Connection Detail Server Error', + play: () => I.waitExit(role('status')), + parameters: { + msw: { + handlers: { connectionDetail: connectionDetail.error }, + }, + }, +}) + +HandlesConnectionDetailServerError.test( + 'shows error state when connection detail request fails', + async () => { + await I.scope(role('main'), async () => { + await I.seeDetailError() + }) + }, +) + +HandlesConnectionDetailServerError.test( + 'keeps detail error state when retry also fails', + async () => { + await I.scope(role('main'), async () => { + await I.seeDetailError() + await I.retry() + await I.waitExit(role('status')) + await I.seeDetailError() + }) + }, +) + +export const RecoversAfterConnectionDetailRetry = meta.story({ + name: 'Connection Detail Retry Success', + play: () => I.waitExit(role('status')), + parameters: { + msw: { + handlers: { connectionDetail: connectionDetail.retrySucceeds() }, + }, + }, +}) + +RecoversAfterConnectionDetailRetry.test( + 'loads connection detail after retry succeeds', + async () => { + await I.scope(role('main'), async () => { + await I.seeDetailError() + await I.retry() + await I.waitExit(role('status')) + await I.see(heading('Stripe API').wait()) + }) + }, +) + +export const HandlesConnectionDetailServerErrorMobile = meta.story({ + name: 'Connection Detail Server Error (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + parameters: HandlesConnectionDetailServerError.input.parameters, + play: () => I.waitExit(role('status')), +}) + +HandlesConnectionDetailServerErrorMobile.test( + '[mobile] shows error state when connection detail request fails', + async () => { + await I.scope(role('main'), async () => { + await I.seeDetailError() + }) + }, +) + +export const KeepsLoadingWhenConnectionDetailNeverResolves = meta.story({ + name: 'Connection Detail Loading State', + parameters: { + msw: { + handlers: { connectionDetail: connectionDetail.loading }, + }, + }, +}) + +KeepsLoadingWhenConnectionDetailNeverResolves.test( + 'shows detail loading state while connection detail is pending', + async () => { + const detail = await I.see(role('main')) + await I.see(loc.detailLoading.within(detail)) + await I.dontSee(heading('Stripe API').within(detail)) + await I.dontSee(text('Connection not found').within(detail)) + }, +) + +export const KeepsLoadingWhenConnectionDetailNeverResolvesMobile = meta.story({ + name: 'Connection Detail Loading State (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + parameters: KeepsLoadingWhenConnectionDetailNeverResolves.input.parameters, +}) + +KeepsLoadingWhenConnectionDetailNeverResolvesMobile.test( + '[mobile] shows detail loading state while connection detail is pending', + async () => { + const detail = await I.see(role('main')) + await I.see(loc.detailLoading.within(detail)) + await I.dontSee(heading('Stripe API').within(detail)) + await I.dontSee(text('Connection not found').within(detail)) + }, +) + +export const TestConnectionButton = meta.story({ + name: 'Test Connection Button', + play: () => I.waitExit(role('status')), +}) + +TestConnectionButton.test('clicking Test Connection shows success toast', async () => { + await I.see(button('Test connection')) + await I.click(button('Test connection')) + await I.seeTestConnectionToast() +}) + +export const ReconnectErrorConnection = meta.story({ + name: 'Reconnect Error Status Connection', + parameters: { initialPath: 'connections/4' }, + play: () => I.waitExit(role('status')), +}) + +ReconnectErrorConnection.test('error-status connection shows Reconnect button', async () => { + await I.scope(role('main'), async () => { + await I.see(button('Reconnect')) + }) +}) + +ReconnectErrorConnection.test('clicking Reconnect shows success toast', async () => { + await I.click(button('Reconnect')) + await I.seeReconnectToast() +}) + +ReconnectErrorConnection.test('active connection does not show Reconnect button', async () => { + await I.goBack() + await I.see(role('list', 'Connections').wait()) + await I.click(role('link', /Stripe API/)) + await I.waitExit(role('status')) + + await I.scope(role('main'), async () => { + await I.dontSee(button('Reconnect')) + }) +}) diff --git a/src/app/integration/Connections.direct-url.stories.tsx b/src/app/integration/Connections.direct-url.stories.tsx new file mode 100644 index 0000000..08a105d --- /dev/null +++ b/src/app/integration/Connections.direct-url.stories.tsx @@ -0,0 +1,27 @@ +import preview from '#.storybook/preview' +import { App } from '#app/App' +import { connectionsActor as I } from '#pages/connections/testing' +import { role } from '#shared/test' + +const meta = preview.meta({ + title: 'Integration/Connections/Direct URL', + component: App, + parameters: { + layout: 'fullscreen', + initialPath: 'connections/missing-42', + }, + loaders: [(ctx) => I.init(ctx)], +}) + +export default meta + +export const MissingConnection = meta.story({ + name: 'Missing Connection', + play: () => I.waitExit(role('status')), +}) + +MissingConnection.test('shows not-found state for missing connection URL', async () => { + await I.scope(role('main'), async () => { + await I.seeConnectionNotFound('missing-42') + }) +}) diff --git a/src/app/integration/Connections.list-request.stories.tsx b/src/app/integration/Connections.list-request.stories.tsx new file mode 100644 index 0000000..2f1d924 --- /dev/null +++ b/src/app/integration/Connections.list-request.stories.tsx @@ -0,0 +1,104 @@ +import preview from '#.storybook/preview' +import { App } from '#app/App' +import { connectionList } from '#entities/connection/mocks/handlers' +import { connectionsActor as I } from '#pages/connections/testing' +import { role, text } from '#shared/test' + +const meta = preview.meta({ + title: 'Integration/Connections/List Request', + component: App, + parameters: { + layout: 'fullscreen', + initialPath: 'connections', + }, + loaders: [(ctx) => I.init(ctx)], +}) + +export default meta + +export const HandlesConnectionsLoadServerError = meta.story({ + name: 'Connections Load Server Error', + play: () => I.waitExit(role('status')), + parameters: { + msw: { + handlers: { connectionList: connectionList.error }, + }, + }, +}) + +HandlesConnectionsLoadServerError.test( + 'shows error state when connections request fails', + async () => { + await I.seeError() + await I.see(text("We couldn't load the connection list. Try again in a moment.")) + }, +) + +HandlesConnectionsLoadServerError.test('keeps error state when retry also fails', async () => { + await I.seeError() + await I.retry() + await I.waitExit(role('status')) + await I.seeError() +}) + +export const RecoversAfterConnectionsLoadRetry = meta.story({ + name: 'Connections Load Retry Success', + play: () => I.waitExit(role('status')), + parameters: { + msw: { + handlers: { connectionList: connectionList.retrySucceeds() }, + }, + }, +}) + +RecoversAfterConnectionsLoadRetry.test('loads connection list after retry succeeds', async () => { + await I.seeError() + await I.retry() + await I.waitExit(role('status')) + await I.see(role('list', 'Connections').wait()) + await I.seeConnectionList() +}) + +export const HandlesConnectionsLoadServerErrorMobile = meta.story({ + name: 'Connections Load Server Error (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + parameters: HandlesConnectionsLoadServerError.input.parameters, + play: () => I.waitExit(role('status')), +}) + +HandlesConnectionsLoadServerErrorMobile.test( + '[mobile] shows error state when connections request fails', + async () => { + await I.seeError() + await I.see(text("We couldn't load the connection list. Try again in a moment.")) + }, +) + +export const KeepsLoadingWhenConnectionsRequestNeverResolves = meta.story({ + name: 'Connections Request Loading State', + parameters: { + msw: { + handlers: { connectionList: connectionList.loading }, + }, + }, +}) + +KeepsLoadingWhenConnectionsRequestNeverResolves.test( + 'shows loading state while connections request is pending', + async () => { + await I.seeLoading() + }, +) + +export const KeepsLoadingWhenConnectionsRequestNeverResolvesMobile = meta.story({ + name: 'Connections Request Loading State (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + parameters: KeepsLoadingWhenConnectionsRequestNeverResolves.input.parameters, +}) + +KeepsLoadingWhenConnectionsRequestNeverResolvesMobile.test( + '[mobile] shows loading state while connections request is pending', + async () => { + await I.seeLoading() + }, +) diff --git a/src/app/integration/Connections.list.stories.tsx b/src/app/integration/Connections.list.stories.tsx new file mode 100644 index 0000000..2026115 --- /dev/null +++ b/src/app/integration/Connections.list.stories.tsx @@ -0,0 +1,116 @@ +import { assertNoRouteLoaderAbortErrors } from '#.storybook/abortErrorGuard' +import preview from '#.storybook/preview' +import { App } from '#app/App' +import { connectionsActor as I } from '#pages/connections/testing' +import { heading, link, role, text } from '#shared/test' + +const meta = preview.meta({ + title: 'Integration/Connections/List', + component: App, + parameters: { + layout: 'fullscreen', + initialPath: 'connections', + }, + loaders: [(ctx) => I.init(ctx)], +}) + +export default meta + +const assertExpectedDetailTeardown = async () => { + await assertNoRouteLoaderAbortErrors('connectionDetail') +} + +export const Default = meta.story({ + name: 'Default', + play: () => I.waitExit(role('status')), +}) + +Default.test('renders connection list with all connections', async () => { + await I.seeConnectionList() +}) + +Default.test('shows no-selection message when no connection selected', async () => { + await I.see(text('No connection selected')) +}) + +Default.test('shows connection detail when connection is clicked', async () => { + await I.click(link(/Stripe API/i)) + await I.waitExit(role('status')) + await I.see(heading('Stripe API')) + await I.goBack() + await I.see(role('list', 'Connections').wait()) + await assertExpectedDetailTeardown() +}) + +Default.test('shows all detail paragraphs in connection detail', async () => { + await I.click(link(/Stripe API/i)) + await I.waitExit(role('status')) + + await I.scope(role('main'), async () => { + await I.see(heading(/Stripe API/i)) + await I.see(text(/Connected to Stripe API v2023-10-16/)) + await I.see(text(/Webhook endpoint configured/)) + await I.see(text(/Average response latency/)) + await I.see(text(/Rate limit headroom/)) + }) + + await I.goBack() + await I.see(role('list', 'Connections').wait()) + await assertExpectedDetailTeardown() +}) + +Default.test('displays correct status badges for all statuses', async () => { + await I.seeStatusBadges() +}) + +Default.test('displays correct type badges for all types', async () => { + await I.seeTypeBadges() +}) + +export const DefaultMobile = meta.story({ + name: 'Default (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + play: () => I.waitExit(role('status')), +}) + +DefaultMobile.test('[mobile] renders connection list with all connections', async () => { + await I.seeConnectionList() +}) + +DefaultMobile.test('[mobile] shows connection list when no connection is selected', async () => { + await I.seeConnectionList() +}) + +DefaultMobile.test('[mobile] shows connection detail when connection is clicked', async () => { + await I.click(link(/Stripe API/i)) + await I.waitExit(role('status')) + await I.see(heading('Stripe API')) + await I.goBack() + await I.see(role('list', 'Connections').wait()) + await assertExpectedDetailTeardown() +}) + +DefaultMobile.test('[mobile] shows all detail paragraphs in connection detail', async () => { + await I.click(link(/Stripe API/i)) + await I.waitExit(role('status')) + + await I.scope(role('main'), async () => { + await I.see(heading(/Stripe API/i)) + await I.see(text(/Connected to Stripe API v2023-10-16/)) + await I.see(text(/Webhook endpoint configured/)) + await I.see(text(/Average response latency/)) + await I.see(text(/Rate limit headroom/)) + }) + + await I.goBack() + await I.see(role('list', 'Connections').wait()) + await assertExpectedDetailTeardown() +}) + +DefaultMobile.test('[mobile] displays correct status badges for all statuses', async () => { + await I.seeStatusBadges() +}) + +DefaultMobile.test('[mobile] displays correct type badges for all types', async () => { + await I.seeTypeBadges() +}) diff --git a/src/app/integration/Connections.navigation.stories.tsx b/src/app/integration/Connections.navigation.stories.tsx new file mode 100644 index 0000000..ac570e5 --- /dev/null +++ b/src/app/integration/Connections.navigation.stories.tsx @@ -0,0 +1,55 @@ +import { assertNoRouteLoaderAbortErrors } from '#.storybook/abortErrorGuard' +import preview from '#.storybook/preview' +import { App } from '#app/App' +import { connectionsActor as I } from '#pages/connections/testing' +import { heading, link, role } from '#shared/test' + +const meta = preview.meta({ + title: 'Integration/Connections/Navigation', + component: App, + parameters: { + layout: 'fullscreen', + initialPath: 'connections', + }, + loaders: [(ctx) => I.init(ctx)], +}) + +export default meta + +export const SwitchBetweenConnections = meta.story({ + name: 'Switch Between Connections', + play: () => I.waitExit(role('status')), +}) + +SwitchBetweenConnections.test('can switch from one connection detail to another', async () => { + await I.click(link(/Stripe API/i)) + await I.waitExit(role('status')) + await I.see(heading('Stripe API')) + + await I.click(link(/Analytics DB/i)) + await I.waitExit(role('status')) + await I.see(heading('Analytics DB')) +}) + +export const SwitchBetweenConnectionsMobile = meta.story({ + name: 'Switch Between Connections (Mobile)', + globals: { viewport: { value: 'sm', isRotated: false } }, + play: () => I.waitExit(role('status')), +}) + +SwitchBetweenConnectionsMobile.test( + '[mobile] can switch to another connection after navigating back', + async () => { + await I.click(link(/Stripe API/i)) + await I.waitExit(role('status')) + await I.see(heading('Stripe API')) + + await I.goBack() + await I.see(role('list', 'Connections').wait()) + await assertNoRouteLoaderAbortErrors('connectionDetail') + + await I.click(link(/Analytics DB/i)) + await I.waitExit(role('status')) + await I.see(heading('Analytics DB')) + }, +) diff --git a/src/app/integration/Connections.stories.tsx b/src/app/integration/Connections.stories.tsx deleted file mode 100644 index a997b15..0000000 --- a/src/app/integration/Connections.stories.tsx +++ /dev/null @@ -1,383 +0,0 @@ -import preview from '#.storybook/preview' -import { App } from '#app/App' -import { connectionDetail, connectionList } from '#entities/connection/mocks/handlers' -import { connectionsActor as I, connectionsLoc as loc } from '#pages/connections/testing' -import { button, heading, link, role, text } from '#shared/test' - -const meta = preview.meta({ - title: 'Integration/Connections', - component: App, - parameters: { layout: 'fullscreen', initialPath: 'connections' }, - loaders: [(ctx) => I.init(ctx)], -}) - -export default meta - -export const Default = meta.story({ - name: 'Default', - play: () => I.waitExit(role('status')), -}) - -Default.test('renders connection list with all connections', async () => { - await I.seeConnectionList() -}) - -Default.test('shows no-selection message when no connection selected', async () => { - await I.see(text('No connection selected')) -}) - -Default.test('shows connection detail when connection is clicked', async () => { - await I.click(link(/Stripe API/i)) - await I.waitExit(role('status')) - await I.see(heading('Stripe API')) -}) - -Default.test('shows all detail paragraphs in connection detail', async () => { - await I.click(link(/Stripe API/i)) - await I.waitExit(role('status')) - - await I.scope(role('main'), async () => { - await I.see(heading(/Stripe API/i)) - await I.see(text(/Connected to Stripe API v2023-10-16/)) - await I.see(text(/Webhook endpoint configured/)) - await I.see(text(/Average response latency/)) - await I.see(text(/Rate limit headroom/)) - }) -}) - -Default.test('displays correct status badges for all statuses', async () => { - await I.seeStatusBadges() -}) - -Default.test('displays correct type badges for all types', async () => { - await I.seeTypeBadges() -}) - -Default.test('can select different connections', async () => { - await I.click(link(/Stripe API/i)) - await I.waitExit(role('status')) - await I.see(heading('Stripe API')) - - await I.click(link(/Analytics DB/i)) - await I.waitExit(role('status')) - await I.see(heading('Analytics DB')) -}) - -export const DirectUrlNotFound = meta.story({ - name: 'Direct URL to Missing Connection', - parameters: { initialPath: 'connections/missing-42' }, - play: () => I.waitExit(role('status')), -}) - -DirectUrlNotFound.test('shows not-found state for missing connection URL', async () => { - await I.scope(role('main'), async () => { - await I.seeConnectionNotFound('missing-42') - }) -}) - -export const DefaultMobile = meta.story({ - name: 'Default (Mobile)', - globals: { viewport: { value: 'sm', isRotated: false } }, - play: () => I.waitExit(role('status')), -}) - -DefaultMobile.test('[mobile] renders connection list with all connections', async () => { - await I.seeConnectionList() -}) - -DefaultMobile.test('[mobile] shows connection list when no connection is selected', async () => { - await I.seeConnectionList() -}) - -DefaultMobile.test('[mobile] shows connection detail when connection is clicked', async () => { - await I.click(link(/Stripe API/i)) - await I.waitExit(role('status')) - await I.see(heading('Stripe API')) -}) - -DefaultMobile.test('[mobile] shows all detail paragraphs in connection detail', async () => { - await I.click(link(/Stripe API/i)) - await I.waitExit(role('status')) - - await I.scope(role('main'), async () => { - await I.see(heading(/Stripe API/i)) - await I.see(text(/Connected to Stripe API v2023-10-16/)) - await I.see(text(/Webhook endpoint configured/)) - await I.see(text(/Average response latency/)) - await I.see(text(/Rate limit headroom/)) - }) -}) - -DefaultMobile.test('[mobile] displays correct status badges for all statuses', async () => { - await I.seeStatusBadges() -}) - -DefaultMobile.test('[mobile] displays correct type badges for all types', async () => { - await I.seeTypeBadges() -}) - -DefaultMobile.test('[mobile] can select different connections', async () => { - await I.click(link(/Stripe API/i)) - await I.waitExit(role('status')) - await I.see(heading('Stripe API')) - - await I.goBack() - - await I.click(link(/Analytics DB/i)) - await I.waitExit(role('status')) - await I.see(heading('Analytics DB')) -}) - -export const HandlesConnectionsLoadServerError = meta.story({ - name: 'Connections Load Server Error', - play: () => I.waitExit(role('status')), - parameters: { - msw: { - handlers: { connectionList: connectionList.error }, - }, - }, -}) - -HandlesConnectionsLoadServerError.test( - 'shows error state when connections request fails', - async () => { - await I.seeError() - await I.see(text("We couldn't load the connection list. Try again in a moment.")) - }, -) - -HandlesConnectionsLoadServerError.test('keeps error state when retry also fails', async () => { - await I.seeError() - await I.retry() - await I.waitExit(role('status')) - await I.seeError() -}) - -export const RecoversAfterConnectionsLoadRetry = meta.story({ - name: 'Connections Load Retry Success', - play: () => I.waitExit(role('status')), - parameters: { - msw: { - handlers: { connectionList: connectionList.retrySucceeds() }, - }, - }, -}) - -RecoversAfterConnectionsLoadRetry.test('loads connection list after retry succeeds', async () => { - await I.seeError() - await I.retry() - await I.waitExit(role('status')) - await I.see(role('list', 'Connections').wait()) - await I.seeConnectionList() -}) - -export const HandlesConnectionsLoadServerErrorMobile = meta.story({ - name: 'Connections Load Server Error (Mobile)', - globals: { viewport: { value: 'sm', isRotated: false } }, - parameters: HandlesConnectionsLoadServerError.input.parameters, - play: () => I.waitExit(role('status')), -}) - -HandlesConnectionsLoadServerErrorMobile.test( - '[mobile] shows error state when connections request fails', - async () => { - await I.seeError() - await I.see(text("We couldn't load the connection list. Try again in a moment.")) - }, -) - -export const KeepsLoadingWhenConnectionsRequestNeverResolves = meta.story({ - name: 'Connections Request Loading State', - parameters: { - msw: { - handlers: { connectionList: connectionList.loading }, - }, - }, -}) - -KeepsLoadingWhenConnectionsRequestNeverResolves.test( - 'shows loading state while connections request is pending', - async () => { - await I.seeLoading() - }, -) - -export const KeepsLoadingWhenConnectionsRequestNeverResolvesMobile = meta.story({ - name: 'Connections Request Loading State (Mobile)', - globals: { viewport: { value: 'sm', isRotated: false } }, - parameters: KeepsLoadingWhenConnectionsRequestNeverResolves.input.parameters, -}) - -KeepsLoadingWhenConnectionsRequestNeverResolvesMobile.test( - '[mobile] shows loading state while connections request is pending', - async () => { - await I.seeLoading() - }, -) - -export const HandlesConnectionDetailServerError = meta.story({ - name: 'Connection Detail Server Error', - play: () => I.waitExit(role('status')), - parameters: { - msw: { - handlers: { connectionDetail: connectionDetail.error }, - }, - }, -}) - -HandlesConnectionDetailServerError.test( - 'shows error state when connection detail request fails', - async () => { - await I.click(link(/Stripe API/i)) - await I.waitExit(role('status')) - - await I.scope(role('main'), async () => { - await I.seeDetailError() - }) - }, -) - -HandlesConnectionDetailServerError.test( - 'keeps detail error state when retry also fails', - async () => { - await I.click(link(/Stripe API/i)) - await I.waitExit(role('status')) - - await I.scope(role('main'), async () => { - await I.seeDetailError() - await I.retry() - await I.waitExit(role('status')) - await I.seeDetailError() - }) - }, -) - -export const RecoversAfterConnectionDetailRetry = meta.story({ - name: 'Connection Detail Retry Success', - play: () => I.waitExit(role('status')), - parameters: { - msw: { - handlers: { connectionDetail: connectionDetail.retrySucceeds() }, - }, - }, -}) - -RecoversAfterConnectionDetailRetry.test( - 'loads connection detail after retry succeeds', - async () => { - await I.click(link(/Stripe API/i)) - await I.waitExit(role('status')) - - await I.scope(role('main'), async () => { - await I.seeDetailError() - await I.retry() - await I.waitExit(role('status')) - await I.see(heading('Stripe API').wait()) - }) - }, -) - -export const HandlesConnectionDetailServerErrorMobile = meta.story({ - name: 'Connection Detail Server Error (Mobile)', - globals: { viewport: { value: 'sm', isRotated: false } }, - parameters: HandlesConnectionDetailServerError.input.parameters, - play: () => I.waitExit(role('status')), -}) - -HandlesConnectionDetailServerErrorMobile.test( - '[mobile] shows error state when connection detail request fails', - async () => { - await I.click(link(/Stripe API/i)) - await I.waitExit(role('status')) - - await I.scope(role('main'), async () => { - await I.seeDetailError() - }) - }, -) - -export const KeepsLoadingWhenConnectionDetailNeverResolves = meta.story({ - name: 'Connection Detail Loading State', - play: () => I.waitExit(role('status')), - parameters: { - msw: { - handlers: { connectionDetail: connectionDetail.loading }, - }, - }, -}) - -KeepsLoadingWhenConnectionDetailNeverResolves.test( - 'shows detail loading state while connection detail is pending', - async () => { - await I.click(link(/Stripe API/i)) - - const detail = await I.see(role('main')) - await I.see(loc.detailLoading.within(detail)) - await I.dontSee(heading('Stripe API').within(detail)) - await I.dontSee(text('Connection not found').within(detail)) - }, -) - -export const KeepsLoadingWhenConnectionDetailNeverResolvesMobile = meta.story({ - name: 'Connection Detail Loading State (Mobile)', - globals: { viewport: { value: 'sm', isRotated: false } }, - parameters: KeepsLoadingWhenConnectionDetailNeverResolves.input.parameters, - play: () => I.waitExit(role('status')), -}) - -KeepsLoadingWhenConnectionDetailNeverResolvesMobile.test( - '[mobile] shows detail loading state while connection detail is pending', - async () => { - await I.click(link(/Stripe API/i)) - - const detail = await I.see(role('main')) - await I.see(loc.detailLoading.within(detail)) - await I.dontSee(heading('Stripe API').within(detail)) - await I.dontSee(text('Connection not found').within(detail)) - }, -) - -export const TestConnectionButton = meta.story({ - name: 'Test Connection Button', - play: () => I.waitExit(role('status')), -}) - -TestConnectionButton.test('clicking Test Connection shows success toast', async () => { - await I.click(link(/Stripe API/i)) - await I.waitExit(role('status')) - - await I.see(button('Test connection')) - await I.click(button('Test connection')) - await I.seeTestConnectionToast() -}) - -export const ReconnectErrorConnection = meta.story({ - name: 'Reconnect Error Status Connection', - play: () => I.waitExit(role('status')), -}) - -ReconnectErrorConnection.test('error-status connection shows Reconnect button', async () => { - await I.click(link(/Auth0 SSO/i)) - await I.waitExit(role('status')) - - await I.scope(role('main'), async () => { - await I.see(button('Reconnect')) - }) -}) - -ReconnectErrorConnection.test('clicking Reconnect shows success toast', async () => { - await I.click(link(/Auth0 SSO/i)) - await I.waitExit(role('status')) - - await I.click(button('Reconnect')) - await I.seeReconnectToast() -}) - -ReconnectErrorConnection.test('active connection does not show Reconnect button', async () => { - await I.click(link(/Stripe API/i)) - await I.waitExit(role('status')) - - await I.scope(role('main'), async () => { - await I.dontSee(button('Reconnect')) - }) -}) diff --git a/src/app/integration/Dashboard.stories.tsx b/src/app/integration/Dashboard.stories.tsx index 307df3e..da8657f 100644 --- a/src/app/integration/Dashboard.stories.tsx +++ b/src/app/integration/Dashboard.stories.tsx @@ -7,7 +7,10 @@ import { button, role, text } from '#shared/test' const meta = preview.meta({ title: 'Integration/Dashboard', component: App, - parameters: { layout: 'fullscreen', initialPath: 'dashboard' }, + parameters: { + layout: 'fullscreen', + initialPath: 'dashboard', + }, loaders: [(ctx) => I.init(ctx)], }) diff --git a/src/app/integration/Items.stories.tsx b/src/app/integration/Items.stories.tsx index c9a6a0d..7149494 100644 --- a/src/app/integration/Items.stories.tsx +++ b/src/app/integration/Items.stories.tsx @@ -9,7 +9,10 @@ import { role, text } from '#shared/test' const meta = preview.meta({ title: 'Integration/Items', component: App, - parameters: { layout: 'fullscreen', initialPath: 'items' }, + parameters: { + layout: 'fullscreen', + initialPath: 'items', + }, loaders: [(ctx) => I.init(ctx)], }) @@ -278,7 +281,7 @@ FilteredToEmpty.test('updates visible items across filter states', async () => { await I.applyCategoryFilter('Electronics') await I.applyStockFilter('Out of Stock') - expect(await I.hopeThat(() => I.seeItem('Wireless Headphones'))).toBe(false) + expect(await I.tryTo(() => I.seeItem('Wireless Headphones'))).toBe(false) await I.see(text('No items match the current filters.')) }) diff --git a/src/app/integration/Pricing.stories.tsx b/src/app/integration/Pricing.stories.tsx index 1042767..fd79a2e 100644 --- a/src/app/integration/Pricing.stories.tsx +++ b/src/app/integration/Pricing.stories.tsx @@ -5,7 +5,10 @@ import { pricingActor as I, pricingLoc as loc } from '#pages/pricing/testing' const meta = preview.meta({ title: 'Integration/Pricing', component: App, - parameters: { layout: 'fullscreen', initialPath: 'pricing' }, + parameters: { + layout: 'fullscreen', + initialPath: 'pricing', + }, loaders: [(ctx) => I.init(ctx)], }) diff --git a/src/app/integration/Settings.stories.tsx b/src/app/integration/Settings.stories.tsx index 0155572..cbca185 100644 --- a/src/app/integration/Settings.stories.tsx +++ b/src/app/integration/Settings.stories.tsx @@ -6,7 +6,10 @@ import { button, role, text } from '#shared/test' const meta = preview.meta({ title: 'Integration/Settings', component: App, - parameters: { layout: 'fullscreen', initialPath: 'settings' }, + parameters: { + layout: 'fullscreen', + initialPath: 'settings', + }, loaders: [(ctx) => I.init(ctx)], }) diff --git a/src/app/integration/SidebarFooter.stories.tsx b/src/app/integration/SidebarFooter.stories.tsx index 00c954b..234f256 100644 --- a/src/app/integration/SidebarFooter.stories.tsx +++ b/src/app/integration/SidebarFooter.stories.tsx @@ -10,7 +10,10 @@ const I = createActor() const meta = preview.meta({ title: 'Integration/Sidebar Footer', component: App, - parameters: { layout: 'fullscreen', initialPath: 'dashboard' }, + parameters: { + layout: 'fullscreen', + initialPath: 'dashboard', + }, loaders: [(ctx) => I.init(ctx)], }) diff --git a/src/app/integration/Timeline.stories.tsx b/src/app/integration/Timeline.stories.tsx index 64ffef1..77629ae 100644 --- a/src/app/integration/Timeline.stories.tsx +++ b/src/app/integration/Timeline.stories.tsx @@ -7,7 +7,10 @@ import { role, text } from '#shared/test' const meta = preview.meta({ title: 'Integration/Timeline', component: App, - parameters: { layout: 'fullscreen', initialPath: 'timeline' }, + parameters: { + layout: 'fullscreen', + initialPath: 'timeline', + }, loaders: [(ctx) => I.init(ctx)], }) diff --git a/src/app/integration/Timer.stories.tsx b/src/app/integration/Timer.stories.tsx index 142b2f5..9a380f1 100644 --- a/src/app/integration/Timer.stories.tsx +++ b/src/app/integration/Timer.stories.tsx @@ -6,7 +6,10 @@ import { button, link, role, text } from '#shared/test' const meta = preview.meta({ title: 'Integration/Timer', component: App, - parameters: { layout: 'fullscreen', initialPath: 'timer' }, + parameters: { + layout: 'fullscreen', + initialPath: 'timer', + }, loaders: [(ctx) => I.init(ctx)], }) diff --git a/src/app/integration/Usage.stories.tsx b/src/app/integration/Usage.stories.tsx index 1771b58..668613f 100644 --- a/src/app/integration/Usage.stories.tsx +++ b/src/app/integration/Usage.stories.tsx @@ -5,7 +5,10 @@ import { usageActor as I, usageLoc as loc } from '#pages/usage/testing' const meta = preview.meta({ title: 'Integration/Usage', component: App, - parameters: { layout: 'fullscreen', initialPath: 'usage' }, + parameters: { + layout: 'fullscreen', + initialPath: 'usage', + }, loaders: [(ctx) => I.init(ctx)], }) diff --git a/src/entities/conversation/model/unreadCount.ts b/src/entities/conversation/model/unreadCount.ts index f59342e..0e32f3b 100644 --- a/src/entities/conversation/model/unreadCount.ts +++ b/src/entities/conversation/model/unreadCount.ts @@ -1,13 +1,8 @@ -import { computed, withAsyncData, withConnectHook } from '@reatom/core' +import { computed, withAsyncData } from '@reatom/core' import { fetchConversationsUnreadCount } from '#entities/conversation/api/conversationsApi' export const conversationUnreadCountAtom = computed( () => fetchConversationsUnreadCount(), 'conversationUnreadCount', -).extend( - withAsyncData(), - withConnectHook((target) => { - if (!target.ready()) target.retry() - }), -) +).extend(withAsyncData()) diff --git a/src/pages/chat/ui/ChatNavItem.tsx b/src/pages/chat/ui/ChatNavItem.tsx index 264a00b..cf62313 100644 --- a/src/pages/chat/ui/ChatNavItem.tsx +++ b/src/pages/chat/ui/ChatNavItem.tsx @@ -9,10 +9,12 @@ import { SideNavButton, SideNavItemContent } from '#widgets/side-nav' import { chatRoute } from '../model/routes' export const ChatNavItem = reatomComponent(() => { - const routeUnreadCount = - chatRoute.loader - .data() - ?.reduce((totalUnread, conversation) => totalUnread + conversation.unread, 0) ?? null + const isChatMatched = chatRoute.match() + const routeUnreadCount = isChatMatched + ? (chatRoute.loader + .data() + ?.reduce((totalUnread, conversation) => totalUnread + conversation.unread, 0) ?? null) + : null const unreadCount = routeUnreadCount ?? conversationUnreadCountAtom.data() ?? 0 const unreadBadge = unreadCount > 0 ? ( diff --git a/src/pages/timer/testing.ts b/src/pages/timer/testing.ts index c66c923..20150ae 100644 --- a/src/pages/timer/testing.ts +++ b/src/pages/timer/testing.ts @@ -3,7 +3,7 @@ import { button, createActor, heading, role, text } from '#shared/test' export const timerLoc = { heading: heading('Timer'), display: (value: string | RegExp) => text(value), - customInput: role('textbox'), + customInput: role('textbox', 'Custom duration'), startButton: button('Start'), pauseButton: button('Pause'), resetButton: button('Reset'), @@ -46,7 +46,6 @@ export const timerActor = createActor().extend((I) => ({ }, enterCustomDurationByBlur: async (value: string) => { await I.fill(timerLoc.customInput, value) - await I.blur(timerLoc.customInput) }, enterCustomDurationByEnter: async (value: string) => { await I.clear(timerLoc.customInput) diff --git a/src/pages/timer/ui/TimerPage.tsx b/src/pages/timer/ui/TimerPage.tsx index 94ca8d3..6b79dd4 100644 --- a/src/pages/timer/ui/TimerPage.tsx +++ b/src/pages/timer/ui/TimerPage.tsx @@ -63,6 +63,7 @@ export const TimerPage = reatomComponent(() => { StoryContext): BaseActor { await userEvent.clear(el) }) }, - blur: async (locator: DefiniteLocator) => { - const element = await elementFrom(locator) - element.blur() - }, press: async (key: string) => { await ctx().userEvent.keyboard(key) }, @@ -310,7 +306,6 @@ export interface BaseActor { fill(locator: DefiniteLocator, value: string): Promise selectOption(locator: DefiniteLocator, value: string | RegExp): Promise clear(locator: DefiniteLocator): Promise - blur(locator: DefiniteLocator): Promise press(key: string): Promise scope(locator: DefiniteLocator, callback: () => MaybePromise): Promise within(locator: DefiniteLocator, callback: () => MaybePromise): Promise diff --git a/src/shared/test/reatomRouteContracts.test.ts b/src/shared/test/reatomRouteContracts.test.ts new file mode 100644 index 0000000..ff4e045 --- /dev/null +++ b/src/shared/test/reatomRouteContracts.test.ts @@ -0,0 +1,69 @@ +import { + context, + isSomeLoaderPending, + noop, + reatomRoute, + sleep, + urlAtom, + withCallHook, + wrap, +} from '@reatom/core' +import { expect, test } from 'vite-plus/test' + +const resetRuntime = () => { + context.reset() + urlAtom.routes = {} + urlAtom.sync.set(() => noop) + urlAtom.set(new URL('https://example.test/')) +} + +const createTrackedRoute = (path: string, name: string, events: string[]) => { + const route = reatomRoute( + { + path, + async loader() { + events.push(`run:${name}`) + await wrap(sleep(1)) + return name + }, + }, + name, + ) + + route.loader.onReject.extend( + withCallHook(({ error }) => { + if (error?.name === 'AbortError') { + events.push(`reject:${name}:${error.message}`) + } + }), + ) + + return route +} + +test('urlAtom.go does not touch non-matching route loaders', async () => { + resetRuntime() + const events: string[] = [] + + createTrackedRoute('a', 'a', events) + createTrackedRoute('b', 'b', events) + + urlAtom.go('/a') + await wrap(sleep(5)) + + expect(events).toEqual(['run:a']) +}) + +test('isSomeLoaderPending does not evaluate non-matching route loaders', async () => { + resetRuntime() + const events: string[] = [] + + createTrackedRoute('a', 'a', events) + createTrackedRoute('b', 'b', events) + + const unsubscribe = isSomeLoaderPending.subscribe() + await wrap(sleep(5)) + unsubscribe() + + expect(events).toEqual([]) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 3df609d..3d9a896 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -32,6 +32,17 @@ export default defineConfig({ thresholds: coverageThresholds, }, projects: [ + { + extends: true, + test: { + name: 'unit', + include: ['src/**/*.test.{ts,tsx}'], + exclude: ['src/**/*.stories.tsx'], + testTimeout: testTimeout, + hookTimeout: testTimeout, + environment: 'node', + }, + }, { extends: true, plugins: [