From 0e3494ba15a1847ba56a56a63449950b17ed4ad1 Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Thu, 28 May 2026 17:41:58 +0300 Subject: [PATCH 1/6] feat: enable connectLogger in development mode for improved logging --- src/setup.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/setup.ts b/src/setup.ts index 2d482b0..11b3b27 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,10 +1,10 @@ -import { clearStack, context } from '@reatom/core' +import { clearStack, connectLogger, context } from '@reatom/core' // Don't dare to remove this line! clearStack() export const rootFrame = context.start() -if (import.meta.env['VITE_CONNECT_LOGGER'] === 'true') { - // rootFrame.run(connectLogger) +if (import.meta.env['DEV'] || import.meta.env['VITE_CONNECT_LOGGER'] === 'true') { + rootFrame.run(connectLogger) } From 0fe8f23b74e5957d8aa4c78c8fb0024078a09814 Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Fri, 29 May 2026 01:03:17 +0300 Subject: [PATCH 2/6] feat: implement abort error handling with clear and drain functions --- .storybook/abortErrorGuard.ts | 33 +++++++++++++++++++++++++++++++++ .storybook/preview.tsx | 12 +++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 .storybook/abortErrorGuard.ts diff --git a/.storybook/abortErrorGuard.ts b/.storybook/abortErrorGuard.ts new file mode 100644 index 0000000..2c20ed0 --- /dev/null +++ b/.storybook/abortErrorGuard.ts @@ -0,0 +1,33 @@ +import { addGlobalExtension, isAbort, isAction, withCallHook } from '@reatom/core' + +interface CollectedAbortError { + actionName: string + message: string +} + +const collected: CollectedAbortError[] = [] + +export function clearAbortErrors() { + collected.length = 0 +} + +export function drainAbortErrors() { + return collected.splice(0) +} + +addGlobalExtension((target) => { + if (isAction(target) && target.name.endsWith('.onReject')) { + target.extend( + withCallHook((payload) => { + const error = (payload as { error?: unknown })?.error + if (error && isAbort(error)) { + collected.push({ + actionName: target.name, + message: String((error as Error).message ?? error), + }) + } + }), + ) + } + return target +}) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 7549a2a..6b384ca 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -6,6 +6,8 @@ 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 } from './abortErrorGuard' // oxlint-disable-next-line no-restricted-imports import { useMemo, type PropsWithChildren } from 'react' @@ -62,12 +64,20 @@ 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) { + const summary = errors.map((e) => ` - ${e.actionName}: ${e.message}`).join('\n') + throw new Error(`Reatom AbortErrors detected during story test:\n${summary}`) + } + } }, }) From bbf9994293d6e1b569c15d48b5a78366b778fc89 Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Fri, 29 May 2026 01:07:17 +0300 Subject: [PATCH 3/6] feat: add VITE_CONNECT_LOGGER configuration and improve logger initialization logic --- .config/mise/conf.d/tasks-test.toml | 1 + src/setup.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.config/mise/conf.d/tasks-test.toml b/.config/mise/conf.d/tasks-test.toml index 9563fe1..f077bb0 100644 --- a/.config/mise/conf.d/tasks-test.toml +++ b/.config/mise/conf.d/tasks-test.toml @@ -11,6 +11,7 @@ COVERAGE_THRESHOLD_LINES = "{{ vars.coverage_threshold_lines }}" COVERAGE_THRESHOLD_FUNCTIONS = "{{ vars.coverage_threshold_functions }}" COVERAGE_THRESHOLD_BRANCHES = "{{ vars.coverage_threshold_branches }}" COVERAGE_THRESHOLD_STATEMENTS = "{{ vars.coverage_threshold_statements }}" +VITE_CONNECT_LOGGER = "false" [tasks.test] description = "Run tests in watch mode" diff --git a/src/setup.ts b/src/setup.ts index 11b3b27..194981a 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -5,6 +5,9 @@ clearStack() export const rootFrame = context.start() -if (import.meta.env['DEV'] || import.meta.env['VITE_CONNECT_LOGGER'] === 'true') { +if ( + import.meta.env['VITE_CONNECT_LOGGER'] === 'true' || + (import.meta.env['DEV'] && import.meta.env['VITE_CONNECT_LOGGER'] !== 'false') +) { rootFrame.run(connectLogger) } From 878a1f4e2c82b972a66c5154dbd4787600022934 Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Fri, 29 May 2026 02:37:09 +0300 Subject: [PATCH 4/6] fix: catch AbortErrors in storybook tests and patch @reatom/core Add abort error detection guard to storybook test harness that fails tests when non-route-loader AbortErrors are detected. Route loader aborts (unmatch/concurrent) are expected Reatom routing lifecycle and are filtered. Patch @reatom/core to reduce unnecessary route loader evaluations: - urlAtom init/set loops now only trigger loaders for matching routes - isSomeLoaderPending only checks pending state for matching routes These optimizations prevent isSomeLoaderPending (used by GlobalLoader) from forcing ALL route loaders to evaluate, which was causing every non-matching route to produce an unmatch AbortError on every URL change. Other fixes: - Remove redundant withConnectHook from conversationUnreadCountAtom that caused a concurrent abort (double-fetch on first connection) - Set auth before URL navigation in storybook setup to prevent concurrent loader re-evaluations from auth state changes - Fix timer input accessibility (add aria-label) - Fix mobile master-detail tests to wait for list after goBack - Disable connectLogger during test runs (VITE_CONNECT_LOGGER per-task) - Fix broken import.meta.env.VITEST detection in browser tests --- .config/mise/conf.d/tasks-test.toml | 4 ++- .storybook/abortErrorGuard.ts | 14 +++++++-- .storybook/expectedAbortErrors.ts | 18 +++++++++++ .storybook/preview.tsx | 18 +++++++---- .storybook/setupStorybookUrl.ts | 6 +++- bun.lock | 3 ++ package.json | 3 ++ patches/@reatom%2Fcore@1001.0.0.patch | 30 +++++++++++++++++++ src/app/integration/Articles.stories.tsx | 8 ++++- src/app/integration/Auth.stories.tsx | 8 ++++- src/app/integration/Calculator.stories.tsx | 7 ++++- src/app/integration/Chat.stories.tsx | 7 ++++- src/app/integration/Connections.stories.tsx | 8 ++++- src/app/integration/Dashboard.stories.tsx | 7 ++++- src/app/integration/Items.stories.tsx | 7 ++++- src/app/integration/Pricing.stories.tsx | 7 ++++- src/app/integration/Settings.stories.tsx | 7 ++++- src/app/integration/SidebarFooter.stories.tsx | 7 ++++- src/app/integration/Timeline.stories.tsx | 7 ++++- src/app/integration/Timer.stories.tsx | 7 ++++- src/app/integration/Usage.stories.tsx | 7 ++++- .../conversation/model/unreadCount.ts | 9 ++---- src/pages/timer/testing.ts | 2 +- src/pages/timer/ui/TimerPage.tsx | 1 + src/setup.ts | 12 +++++++- 25 files changed, 182 insertions(+), 32 deletions(-) create mode 100644 .storybook/expectedAbortErrors.ts create mode 100644 patches/@reatom%2Fcore@1001.0.0.patch diff --git a/.config/mise/conf.d/tasks-test.toml b/.config/mise/conf.d/tasks-test.toml index f077bb0..6f99857 100644 --- a/.config/mise/conf.d/tasks-test.toml +++ b/.config/mise/conf.d/tasks-test.toml @@ -11,19 +11,21 @@ COVERAGE_THRESHOLD_LINES = "{{ vars.coverage_threshold_lines }}" COVERAGE_THRESHOLD_FUNCTIONS = "{{ vars.coverage_threshold_functions }}" COVERAGE_THRESHOLD_BRANCHES = "{{ vars.coverage_threshold_branches }}" COVERAGE_THRESHOLD_STATEMENTS = "{{ vars.coverage_threshold_statements }}" -VITE_CONNECT_LOGGER = "false" [tasks.test] 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 index 2c20ed0..b4708d6 100644 --- a/.storybook/abortErrorGuard.ts +++ b/.storybook/abortErrorGuard.ts @@ -6,13 +6,23 @@ interface CollectedAbortError { } const collected: CollectedAbortError[] = [] +let expectedPatterns: RegExp[] = [] export function clearAbortErrors() { collected.length = 0 } -export function drainAbortErrors() { - return collected.splice(0) +export function setExpectedAbortErrors(patterns: RegExp[]) { + expectedPatterns = patterns +} + +export function drainUnexpectedAbortErrors() { + const unexpected = collected.filter( + (e) => !expectedPatterns.some((p) => p.test(e.actionName) || p.test(e.message)), + ) + collected.length = 0 + expectedPatterns = [] + return unexpected } addGlobalExtension((target) => { diff --git a/.storybook/expectedAbortErrors.ts b/.storybook/expectedAbortErrors.ts new file mode 100644 index 0000000..d84f4ec --- /dev/null +++ b/.storybook/expectedAbortErrors.ts @@ -0,0 +1,18 @@ +/** + * Expected abort error patterns for stories that render the full App component. + * + * Route loader AbortErrors ("unmatch" / "concurrent") are normal Reatom routing + * lifecycle events that happen when: + * - A route stops matching the URL (unmatch) + * - A user navigates to new params before the previous fetch settles (concurrent) + * + * Stories that render the full App trigger route matching for all registered + * routes, which causes non-matching route loaders to abort. + * + * @example + * const meta = preview.meta({ + * component: App, + * parameters: { expectedAbortErrors: ROUTE_LOADER_ABORTS }, + * }) + */ +export const ROUTE_LOADER_ABORTS = [/\.loader\.onReject/] as const satisfies readonly RegExp[] diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 6b384ca..521e864 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -7,7 +7,11 @@ import addonA11y from '@storybook/addon-a11y' import { definePreview } from '@storybook/react-vite' import { initialize, mswLoader } from 'msw-storybook-addon' -import { clearAbortErrors, drainAbortErrors } from './abortErrorGuard' +import { + clearAbortErrors, + drainUnexpectedAbortErrors, + setExpectedAbortErrors, +} from './abortErrorGuard' // oxlint-disable-next-line no-restricted-imports import { useMemo, type PropsWithChildren } from 'react' @@ -33,9 +37,9 @@ 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]) return {children} } @@ -63,8 +67,10 @@ const preview = definePreview({ msw: { handlers }, }, // fallow-ignore-next-line complexity - beforeEach: async ({ globals }) => { + beforeEach: async ({ globals, parameters }) => { clearAbortErrors() + const expected = parameters['expectedAbortErrors'] as RegExp[] | undefined + if (expected) setExpectedAbortErrors(expected) if (!(globalThis as Record)['__vitest_worker__']) return const { page } = await import('vite-plus/test/browser') const viewportGlobal = globals['viewport'] as { value?: string } | string | undefined @@ -72,7 +78,7 @@ const preview = definePreview({ const viewport = (viewportName ? getViewportSize(viewportName) : null) ?? FALLBACK_VIEWPORT await page.viewport(viewport.width, viewport.height) return () => { - const errors = drainAbortErrors() + const errors = drainUnexpectedAbortErrors() if (errors.length > 0) { const summary = errors.map((e) => ` - ${e.actionName}: ${e.message}`).join('\n') throw new Error(`Reatom AbortErrors detected during story test:\n${summary}`) 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..829cff8 100644 --- a/bun.lock +++ b/bun.lock @@ -40,6 +40,9 @@ }, }, }, + "patchedDependencies": { + "@reatom/core@1001.0.0": "patches/@reatom%2Fcore@1001.0.0.patch", + }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], diff --git a/package.json b/package.json index 216d11d..807185b 100644 --- a/package.json +++ b/package.json @@ -67,5 +67,8 @@ "workerDirectory": [ "public" ] + }, + "patchedDependencies": { + "@reatom/core@1001.0.0": "patches/@reatom%2Fcore@1001.0.0.patch" } } diff --git a/patches/@reatom%2Fcore@1001.0.0.patch b/patches/@reatom%2Fcore@1001.0.0.patch new file mode 100644 index 0000000..cfb3d44 --- /dev/null +++ b/patches/@reatom%2Fcore@1001.0.0.patch @@ -0,0 +1,30 @@ +diff --git a/dist/index.js b/dist/index.js +index b3110cb9bbe3b771c3b3fa5e9afbd8fd95d4d04b..485372c98b801fd07a9ddfe43d45d7c560dc36fb 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -7126,14 +7126,14 @@ const onEvent = (target, type, cb, options) => { + //#region src/web/url.ts + /** Create the URL atom with the new Reatom API. */ + let urlAtom = atom(null, "urlAtom").extend(withMiddleware(() => (next, ...params) => next(...params) ?? urlAtom.init()), withInitHook(() => { +- for (const [, routeAtom] of Object.entries(urlAtom.routes)) routeAtom.loader(); ++ for (const [, routeAtom] of Object.entries(urlAtom.routes)) if (routeAtom()) routeAtom.loader(); + }, "effect"), withParams((update, replace = false) => { + let url = top().state; + let newUrl = typeof update === "function" ? update(url ?? urlAtom.init()) : update; + if (newUrl.href === url?.href) return url; + if (url !== newUrl) { + _enqueue(() => { +- for (const [, routeAtom] of Object.entries(urlAtom.routes)) routeAtom.loader(); ++ for (const [, routeAtom] of Object.entries(urlAtom.routes)) if (routeAtom()) routeAtom.loader(); + }, "compute"); + if (STACK[STACK.length - 2]?.atom !== urlAtom.syncFromSource) urlAtom.sync()(newUrl, replace); + } +@@ -7572,7 +7572,7 @@ let reatomRoute = createRouteFactory(urlAtom); + * route + */ + const is404 = computed(() => Object.values(urlAtom.routes).every((route) => !route()), "is404"); +-const isSomeLoaderPending = computed(() => Object.values(urlAtom.routes).some((route) => route.loader.pending() > 0), "isSomeLoaderPending"); ++const isSomeLoaderPending = computed(() => Object.values(urlAtom.routes).some((route) => route.match() && route.loader.pending() > 0), "isSomeLoaderPending"); + //#endregion + //#region src/routing/searchParams.ts + const isSubpath = (currentPath, targetPath) => !targetPath || targetPath[targetPath.length - 1] === "*" ? `${currentPath}/`.startsWith(targetPath.slice(0, -1)) : `${currentPath}/` === targetPath; diff --git a/src/app/integration/Articles.stories.tsx b/src/app/integration/Articles.stories.tsx index 54deb86..d446c88 100644 --- a/src/app/integration/Articles.stories.tsx +++ b/src/app/integration/Articles.stories.tsx @@ -1,3 +1,4 @@ +import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { articleDetail, articleList } from '#entities/article/mocks/handlers' @@ -7,7 +8,11 @@ import { heading, link, role, text } from '#shared/test' const meta = preview.meta({ title: 'Integration/Articles', component: App, - parameters: { layout: 'fullscreen', initialPath: 'articles' }, + parameters: { + layout: 'fullscreen', + expectedAbortErrors: ROUTE_LOADER_ABORTS, + initialPath: 'articles', + }, loaders: [(ctx) => I.init(ctx)], }) @@ -133,6 +138,7 @@ DefaultMobile.test('[mobile] can select different articles', async () => { await I.seeArticleDetail('Quarterly report') await I.goBack() + await I.see(role('list', 'Articles').wait()) await I.openArticle(/Hiring plan/i) await I.seeArticleDetail('Hiring plan') diff --git a/src/app/integration/Auth.stories.tsx b/src/app/integration/Auth.stories.tsx index d8d533e..1614ba5 100644 --- a/src/app/integration/Auth.stories.tsx +++ b/src/app/integration/Auth.stories.tsx @@ -1,5 +1,6 @@ import type { Canvas } from '#shared/test/loc' +import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { authHandlers } from '#entities/auth/mocks/handlers' @@ -12,7 +13,12 @@ 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', + expectedAbortErrors: ROUTE_LOADER_ABORTS, + 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..a81d649 100644 --- a/src/app/integration/Calculator.stories.tsx +++ b/src/app/integration/Calculator.stories.tsx @@ -1,3 +1,4 @@ +import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { calculatorActor as I, calculatorLoc as loc } from '#pages/calculator/testing' @@ -5,7 +6,11 @@ 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', + expectedAbortErrors: ROUTE_LOADER_ABORTS, + initialPath: 'calculator', + }, loaders: [(ctx) => I.init(ctx)], }) diff --git a/src/app/integration/Chat.stories.tsx b/src/app/integration/Chat.stories.tsx index 9be5730..a26bb6c 100644 --- a/src/app/integration/Chat.stories.tsx +++ b/src/app/integration/Chat.stories.tsx @@ -1,3 +1,4 @@ +import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { conversationDetail, conversationList } from '#entities/conversation/mocks/handlers' @@ -7,7 +8,11 @@ import { link, role, text } from '#shared/test' const meta = preview.meta({ title: 'Integration/Chat', component: App, - parameters: { layout: 'fullscreen', initialPath: 'chat' }, + parameters: { + layout: 'fullscreen', + expectedAbortErrors: ROUTE_LOADER_ABORTS, + initialPath: 'chat', + }, loaders: [(ctx) => I.init(ctx)], }) diff --git a/src/app/integration/Connections.stories.tsx b/src/app/integration/Connections.stories.tsx index a997b15..dbc5514 100644 --- a/src/app/integration/Connections.stories.tsx +++ b/src/app/integration/Connections.stories.tsx @@ -1,3 +1,4 @@ +import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { connectionDetail, connectionList } from '#entities/connection/mocks/handlers' @@ -7,7 +8,11 @@ import { button, heading, link, role, text } from '#shared/test' const meta = preview.meta({ title: 'Integration/Connections', component: App, - parameters: { layout: 'fullscreen', initialPath: 'connections' }, + parameters: { + layout: 'fullscreen', + expectedAbortErrors: ROUTE_LOADER_ABORTS, + initialPath: 'connections', + }, loaders: [(ctx) => I.init(ctx)], }) @@ -122,6 +127,7 @@ DefaultMobile.test('[mobile] can select different connections', async () => { await I.see(heading('Stripe API')) await I.goBack() + await I.see(role('list', 'Connections').wait()) await I.click(link(/Analytics DB/i)) await I.waitExit(role('status')) diff --git a/src/app/integration/Dashboard.stories.tsx b/src/app/integration/Dashboard.stories.tsx index 307df3e..1996396 100644 --- a/src/app/integration/Dashboard.stories.tsx +++ b/src/app/integration/Dashboard.stories.tsx @@ -1,3 +1,4 @@ +import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { dashboardStats } from '#entities/dashboard/mocks/handlers' @@ -7,7 +8,11 @@ import { button, role, text } from '#shared/test' const meta = preview.meta({ title: 'Integration/Dashboard', component: App, - parameters: { layout: 'fullscreen', initialPath: 'dashboard' }, + parameters: { + layout: 'fullscreen', + expectedAbortErrors: ROUTE_LOADER_ABORTS, + 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..107e4cd 100644 --- a/src/app/integration/Items.stories.tsx +++ b/src/app/integration/Items.stories.tsx @@ -1,5 +1,6 @@ import { expect } from 'storybook/test' +import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { itemDetail, itemList } from '#entities/item/mocks/handlers' @@ -9,7 +10,11 @@ import { role, text } from '#shared/test' const meta = preview.meta({ title: 'Integration/Items', component: App, - parameters: { layout: 'fullscreen', initialPath: 'items' }, + parameters: { + layout: 'fullscreen', + expectedAbortErrors: ROUTE_LOADER_ABORTS, + initialPath: 'items', + }, loaders: [(ctx) => I.init(ctx)], }) diff --git a/src/app/integration/Pricing.stories.tsx b/src/app/integration/Pricing.stories.tsx index 1042767..6f70817 100644 --- a/src/app/integration/Pricing.stories.tsx +++ b/src/app/integration/Pricing.stories.tsx @@ -1,3 +1,4 @@ +import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { pricingActor as I, pricingLoc as loc } from '#pages/pricing/testing' @@ -5,7 +6,11 @@ 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', + expectedAbortErrors: ROUTE_LOADER_ABORTS, + 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..ba2a7d9 100644 --- a/src/app/integration/Settings.stories.tsx +++ b/src/app/integration/Settings.stories.tsx @@ -1,3 +1,4 @@ +import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { settingsActor as I, settingsLoc as loc } from '#pages/settings/testing' @@ -6,7 +7,11 @@ import { button, role, text } from '#shared/test' const meta = preview.meta({ title: 'Integration/Settings', component: App, - parameters: { layout: 'fullscreen', initialPath: 'settings' }, + parameters: { + layout: 'fullscreen', + expectedAbortErrors: ROUTE_LOADER_ABORTS, + 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..4406e93 100644 --- a/src/app/integration/SidebarFooter.stories.tsx +++ b/src/app/integration/SidebarFooter.stories.tsx @@ -1,3 +1,4 @@ +import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { createActor, heading, role, text, link } from '#shared/test' @@ -10,7 +11,11 @@ const I = createActor() const meta = preview.meta({ title: 'Integration/Sidebar Footer', component: App, - parameters: { layout: 'fullscreen', initialPath: 'dashboard' }, + parameters: { + layout: 'fullscreen', + expectedAbortErrors: ROUTE_LOADER_ABORTS, + 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..4b94f8b 100644 --- a/src/app/integration/Timeline.stories.tsx +++ b/src/app/integration/Timeline.stories.tsx @@ -1,3 +1,4 @@ +import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { timelineEventList } from '#entities/timeline-event/mocks/handlers' @@ -7,7 +8,11 @@ import { role, text } from '#shared/test' const meta = preview.meta({ title: 'Integration/Timeline', component: App, - parameters: { layout: 'fullscreen', initialPath: 'timeline' }, + parameters: { + layout: 'fullscreen', + expectedAbortErrors: ROUTE_LOADER_ABORTS, + 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..403bfe0 100644 --- a/src/app/integration/Timer.stories.tsx +++ b/src/app/integration/Timer.stories.tsx @@ -1,3 +1,4 @@ +import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { timerActor as I, timerLoc as loc } from '#pages/timer/testing' @@ -6,7 +7,11 @@ 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', + expectedAbortErrors: ROUTE_LOADER_ABORTS, + 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..82295f3 100644 --- a/src/app/integration/Usage.stories.tsx +++ b/src/app/integration/Usage.stories.tsx @@ -1,3 +1,4 @@ +import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { usageActor as I, usageLoc as loc } from '#pages/usage/testing' @@ -5,7 +6,11 @@ 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', + expectedAbortErrors: ROUTE_LOADER_ABORTS, + 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/timer/testing.ts b/src/pages/timer/testing.ts index c66c923..dcd2ffb 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'), 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(() => { { + // Route loader AbortErrors (unmatch/concurrent) are normal routing + // lifecycle — suppress them to reduce connectLogger noise. + if (name.endsWith('.onReject') && name.includes('.loader.')) { + const error = frame.state?.at(-1)?.payload?.error + if (error?.name === 'AbortError') return false + } + return true + }, + }) } From fac6e93aab0041e6b3905bafa34b357ccf4f5c3b Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Sun, 31 May 2026 01:06:05 +0300 Subject: [PATCH 5/6] test: tighten abort guard and add route loader contract checks --- .storybook/abortErrorGuard.ts | 24 ++++--- .storybook/expectedAbortErrors.ts | 18 ----- .storybook/preview.tsx | 26 ++++--- src/app/integration/Articles.stories.tsx | 2 - src/app/integration/Auth.stories.tsx | 2 - src/app/integration/Calculator.stories.tsx | 2 - src/app/integration/Chat.stories.tsx | 2 - src/app/integration/Connections.stories.tsx | 2 - src/app/integration/Dashboard.stories.tsx | 2 - src/app/integration/Items.stories.tsx | 2 - src/app/integration/Pricing.stories.tsx | 2 - src/app/integration/Settings.stories.tsx | 2 - src/app/integration/SidebarFooter.stories.tsx | 2 - src/app/integration/Timeline.stories.tsx | 2 - src/app/integration/Timer.stories.tsx | 2 - src/app/integration/Usage.stories.tsx | 2 - src/pages/chat/ui/ChatNavItem.tsx | 10 +-- src/setup.ts | 12 +--- src/shared/test/reatomRouteContracts.test.ts | 69 +++++++++++++++++++ vitest.config.ts | 11 +++ 20 files changed, 116 insertions(+), 80 deletions(-) delete mode 100644 .storybook/expectedAbortErrors.ts create mode 100644 src/shared/test/reatomRouteContracts.test.ts diff --git a/.storybook/abortErrorGuard.ts b/.storybook/abortErrorGuard.ts index b4708d6..68621d5 100644 --- a/.storybook/abortErrorGuard.ts +++ b/.storybook/abortErrorGuard.ts @@ -5,24 +5,28 @@ interface CollectedAbortError { message: string } +export interface DrainAbortError extends CollectedAbortError { + count: number +} + const collected: CollectedAbortError[] = [] -let expectedPatterns: RegExp[] = [] export function clearAbortErrors() { collected.length = 0 } -export function setExpectedAbortErrors(patterns: RegExp[]) { - expectedPatterns = patterns -} +export function drainAbortErrors(): DrainAbortError[] { + const grouped = new Map() + + for (const error of collected) { + const key = `${error.actionName}\u0000${error.message}` + const existing = grouped.get(key) + if (existing) existing.count += 1 + else grouped.set(key, { ...error, count: 1 }) + } -export function drainUnexpectedAbortErrors() { - const unexpected = collected.filter( - (e) => !expectedPatterns.some((p) => p.test(e.actionName) || p.test(e.message)), - ) collected.length = 0 - expectedPatterns = [] - return unexpected + return [...grouped.values()] } addGlobalExtension((target) => { diff --git a/.storybook/expectedAbortErrors.ts b/.storybook/expectedAbortErrors.ts deleted file mode 100644 index d84f4ec..0000000 --- a/.storybook/expectedAbortErrors.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Expected abort error patterns for stories that render the full App component. - * - * Route loader AbortErrors ("unmatch" / "concurrent") are normal Reatom routing - * lifecycle events that happen when: - * - A route stops matching the URL (unmatch) - * - A user navigates to new params before the previous fetch settles (concurrent) - * - * Stories that render the full App trigger route matching for all registered - * routes, which causes non-matching route loaders to abort. - * - * @example - * const meta = preview.meta({ - * component: App, - * parameters: { expectedAbortErrors: ROUTE_LOADER_ABORTS }, - * }) - */ -export const ROUTE_LOADER_ABORTS = [/\.loader\.onReject/] as const satisfies readonly RegExp[] diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 521e864..a947022 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -7,13 +7,9 @@ import addonA11y from '@storybook/addon-a11y' import { definePreview } from '@storybook/react-vite' import { initialize, mswLoader } from 'msw-storybook-addon' -import { - clearAbortErrors, - drainUnexpectedAbortErrors, - setExpectedAbortErrors, -} from './abortErrorGuard' +import { clearAbortErrors, drainAbortErrors } 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' @@ -41,6 +37,14 @@ function ReatomDecorator({ setAuthenticatedForTest(authenticated ? authMockSession : null) }) }, [authenticated, initialPath]) + + useEffect(() => { + return () => { + clearAbortErrors() + queueMicrotask(clearAbortErrors) + } + }, [frame]) + return {children} } @@ -67,10 +71,8 @@ const preview = definePreview({ msw: { handlers }, }, // fallow-ignore-next-line complexity - beforeEach: async ({ globals, parameters }) => { + beforeEach: async ({ globals }) => { clearAbortErrors() - const expected = parameters['expectedAbortErrors'] as RegExp[] | undefined - if (expected) setExpectedAbortErrors(expected) if (!(globalThis as Record)['__vitest_worker__']) return const { page } = await import('vite-plus/test/browser') const viewportGlobal = globals['viewport'] as { value?: string } | string | undefined @@ -78,9 +80,11 @@ const preview = definePreview({ const viewport = (viewportName ? getViewportSize(viewportName) : null) ?? FALLBACK_VIEWPORT await page.viewport(viewport.width, viewport.height) return () => { - const errors = drainUnexpectedAbortErrors() + const errors = drainAbortErrors() if (errors.length > 0) { - const summary = errors.map((e) => ` - ${e.actionName}: ${e.message}`).join('\n') + const summary = errors + .map((e) => ` - ${e.actionName}: ${e.message} ×${e.count}`) + .join('\n') throw new Error(`Reatom AbortErrors detected during story test:\n${summary}`) } } diff --git a/src/app/integration/Articles.stories.tsx b/src/app/integration/Articles.stories.tsx index d446c88..92543be 100644 --- a/src/app/integration/Articles.stories.tsx +++ b/src/app/integration/Articles.stories.tsx @@ -1,4 +1,3 @@ -import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { articleDetail, articleList } from '#entities/article/mocks/handlers' @@ -10,7 +9,6 @@ const meta = preview.meta({ component: App, parameters: { layout: 'fullscreen', - expectedAbortErrors: ROUTE_LOADER_ABORTS, initialPath: 'articles', }, loaders: [(ctx) => I.init(ctx)], diff --git a/src/app/integration/Auth.stories.tsx b/src/app/integration/Auth.stories.tsx index 1614ba5..1d11a50 100644 --- a/src/app/integration/Auth.stories.tsx +++ b/src/app/integration/Auth.stories.tsx @@ -1,6 +1,5 @@ import type { Canvas } from '#shared/test/loc' -import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { authHandlers } from '#entities/auth/mocks/handlers' @@ -15,7 +14,6 @@ const meta = preview.meta({ component: App, parameters: { layout: 'fullscreen', - expectedAbortErrors: ROUTE_LOADER_ABORTS, authenticated: false, initialPath: 'login', }, diff --git a/src/app/integration/Calculator.stories.tsx b/src/app/integration/Calculator.stories.tsx index a81d649..c6956fc 100644 --- a/src/app/integration/Calculator.stories.tsx +++ b/src/app/integration/Calculator.stories.tsx @@ -1,4 +1,3 @@ -import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { calculatorActor as I, calculatorLoc as loc } from '#pages/calculator/testing' @@ -8,7 +7,6 @@ const meta = preview.meta({ component: App, parameters: { layout: 'fullscreen', - expectedAbortErrors: ROUTE_LOADER_ABORTS, initialPath: 'calculator', }, loaders: [(ctx) => I.init(ctx)], diff --git a/src/app/integration/Chat.stories.tsx b/src/app/integration/Chat.stories.tsx index a26bb6c..2748d34 100644 --- a/src/app/integration/Chat.stories.tsx +++ b/src/app/integration/Chat.stories.tsx @@ -1,4 +1,3 @@ -import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { conversationDetail, conversationList } from '#entities/conversation/mocks/handlers' @@ -10,7 +9,6 @@ const meta = preview.meta({ component: App, parameters: { layout: 'fullscreen', - expectedAbortErrors: ROUTE_LOADER_ABORTS, initialPath: 'chat', }, loaders: [(ctx) => I.init(ctx)], diff --git a/src/app/integration/Connections.stories.tsx b/src/app/integration/Connections.stories.tsx index dbc5514..66b685d 100644 --- a/src/app/integration/Connections.stories.tsx +++ b/src/app/integration/Connections.stories.tsx @@ -1,4 +1,3 @@ -import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { connectionDetail, connectionList } from '#entities/connection/mocks/handlers' @@ -10,7 +9,6 @@ const meta = preview.meta({ component: App, parameters: { layout: 'fullscreen', - expectedAbortErrors: ROUTE_LOADER_ABORTS, initialPath: 'connections', }, loaders: [(ctx) => I.init(ctx)], diff --git a/src/app/integration/Dashboard.stories.tsx b/src/app/integration/Dashboard.stories.tsx index 1996396..da8657f 100644 --- a/src/app/integration/Dashboard.stories.tsx +++ b/src/app/integration/Dashboard.stories.tsx @@ -1,4 +1,3 @@ -import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { dashboardStats } from '#entities/dashboard/mocks/handlers' @@ -10,7 +9,6 @@ const meta = preview.meta({ component: App, parameters: { layout: 'fullscreen', - expectedAbortErrors: ROUTE_LOADER_ABORTS, initialPath: 'dashboard', }, loaders: [(ctx) => I.init(ctx)], diff --git a/src/app/integration/Items.stories.tsx b/src/app/integration/Items.stories.tsx index 107e4cd..a0024b4 100644 --- a/src/app/integration/Items.stories.tsx +++ b/src/app/integration/Items.stories.tsx @@ -1,6 +1,5 @@ import { expect } from 'storybook/test' -import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { itemDetail, itemList } from '#entities/item/mocks/handlers' @@ -12,7 +11,6 @@ const meta = preview.meta({ component: App, parameters: { layout: 'fullscreen', - expectedAbortErrors: ROUTE_LOADER_ABORTS, initialPath: 'items', }, loaders: [(ctx) => I.init(ctx)], diff --git a/src/app/integration/Pricing.stories.tsx b/src/app/integration/Pricing.stories.tsx index 6f70817..fd79a2e 100644 --- a/src/app/integration/Pricing.stories.tsx +++ b/src/app/integration/Pricing.stories.tsx @@ -1,4 +1,3 @@ -import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { pricingActor as I, pricingLoc as loc } from '#pages/pricing/testing' @@ -8,7 +7,6 @@ const meta = preview.meta({ component: App, parameters: { layout: 'fullscreen', - expectedAbortErrors: ROUTE_LOADER_ABORTS, initialPath: 'pricing', }, loaders: [(ctx) => I.init(ctx)], diff --git a/src/app/integration/Settings.stories.tsx b/src/app/integration/Settings.stories.tsx index ba2a7d9..cbca185 100644 --- a/src/app/integration/Settings.stories.tsx +++ b/src/app/integration/Settings.stories.tsx @@ -1,4 +1,3 @@ -import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { settingsActor as I, settingsLoc as loc } from '#pages/settings/testing' @@ -9,7 +8,6 @@ const meta = preview.meta({ component: App, parameters: { layout: 'fullscreen', - expectedAbortErrors: ROUTE_LOADER_ABORTS, initialPath: 'settings', }, loaders: [(ctx) => I.init(ctx)], diff --git a/src/app/integration/SidebarFooter.stories.tsx b/src/app/integration/SidebarFooter.stories.tsx index 4406e93..234f256 100644 --- a/src/app/integration/SidebarFooter.stories.tsx +++ b/src/app/integration/SidebarFooter.stories.tsx @@ -1,4 +1,3 @@ -import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { createActor, heading, role, text, link } from '#shared/test' @@ -13,7 +12,6 @@ const meta = preview.meta({ component: App, parameters: { layout: 'fullscreen', - expectedAbortErrors: ROUTE_LOADER_ABORTS, initialPath: 'dashboard', }, loaders: [(ctx) => I.init(ctx)], diff --git a/src/app/integration/Timeline.stories.tsx b/src/app/integration/Timeline.stories.tsx index 4b94f8b..77629ae 100644 --- a/src/app/integration/Timeline.stories.tsx +++ b/src/app/integration/Timeline.stories.tsx @@ -1,4 +1,3 @@ -import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { timelineEventList } from '#entities/timeline-event/mocks/handlers' @@ -10,7 +9,6 @@ const meta = preview.meta({ component: App, parameters: { layout: 'fullscreen', - expectedAbortErrors: ROUTE_LOADER_ABORTS, initialPath: 'timeline', }, loaders: [(ctx) => I.init(ctx)], diff --git a/src/app/integration/Timer.stories.tsx b/src/app/integration/Timer.stories.tsx index 403bfe0..9a380f1 100644 --- a/src/app/integration/Timer.stories.tsx +++ b/src/app/integration/Timer.stories.tsx @@ -1,4 +1,3 @@ -import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { timerActor as I, timerLoc as loc } from '#pages/timer/testing' @@ -9,7 +8,6 @@ const meta = preview.meta({ component: App, parameters: { layout: 'fullscreen', - expectedAbortErrors: ROUTE_LOADER_ABORTS, initialPath: 'timer', }, loaders: [(ctx) => I.init(ctx)], diff --git a/src/app/integration/Usage.stories.tsx b/src/app/integration/Usage.stories.tsx index 82295f3..668613f 100644 --- a/src/app/integration/Usage.stories.tsx +++ b/src/app/integration/Usage.stories.tsx @@ -1,4 +1,3 @@ -import { ROUTE_LOADER_ABORTS } from '#.storybook/expectedAbortErrors' import preview from '#.storybook/preview' import { App } from '#app/App' import { usageActor as I, usageLoc as loc } from '#pages/usage/testing' @@ -8,7 +7,6 @@ const meta = preview.meta({ component: App, parameters: { layout: 'fullscreen', - expectedAbortErrors: ROUTE_LOADER_ABORTS, initialPath: 'usage', }, loaders: [(ctx) => I.init(ctx)], 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/setup.ts b/src/setup.ts index 44b3257..194981a 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -9,15 +9,5 @@ if ( import.meta.env['VITE_CONNECT_LOGGER'] === 'true' || (import.meta.env['DEV'] && import.meta.env['VITE_CONNECT_LOGGER'] !== 'false') ) { - rootFrame.run(connectLogger, { - match: (name, frame) => { - // Route loader AbortErrors (unmatch/concurrent) are normal routing - // lifecycle — suppress them to reduce connectLogger noise. - if (name.endsWith('.onReject') && name.includes('.loader.')) { - const error = frame.state?.at(-1)?.payload?.error - if (error?.name === 'AbortError') return false - } - return true - }, - }) + rootFrame.run(connectLogger) } 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: [ From 2170bb9299eeb5ccc27ee6f3b58d6be61c84113d Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Sun, 31 May 2026 13:42:45 +0300 Subject: [PATCH 6/6] test: isolate route-mode stories and tighten abort-error assertions - split Connections, Chat, and Articles integration stories by route mode - keep Storybook AbortError guard strict by default - add explicit helper for expected matched-route loader teardown aborts - restore isolated multi-navigation coverage for Articles - reduce cross-story/test abort attribution noise without hiding real loader misuse --- .storybook/abortErrorGuard.ts | 41 ++ .storybook/preview.tsx | 9 +- .../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 | 336 --------------- 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 | 279 ------------- .../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 | 387 ------------------ 19 files changed, 1235 insertions(+), 1007 deletions(-) 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 diff --git a/.storybook/abortErrorGuard.ts b/.storybook/abortErrorGuard.ts index 68621d5..3c1a66e 100644 --- a/.storybook/abortErrorGuard.ts +++ b/.storybook/abortErrorGuard.ts @@ -9,6 +9,11 @@ export interface DrainAbortError extends CollectedAbortError { count: number } +interface ExpectedAbortError { + actionName?: RegExp | string + message?: RegExp | string +} + const collected: CollectedAbortError[] = [] export function clearAbortErrors() { @@ -29,6 +34,42 @@ export function drainAbortErrors(): DrainAbortError[] { return [...grouped.values()] } +const isMatch = (actual: string, expected: RegExp | string | undefined) => { + if (!expected) return true + return typeof expected === 'string' ? actual === expected : expected.test(actual) +} + +const escapeRegExp = (value: string) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') + +export const formatAbortErrors = (errors: DrainAbortError[]) => + errors.map((e) => ` - ${e.actionName}: ${e.message} ×${e.count}`).join('\n') + +export function assertOnlyExpectedAbortErrors( + expected: ExpectedAbortError, + reason = 'Expected Reatom AbortErrors', +) { + const errors = drainAbortErrors() + const unexpected = errors.filter( + (error) => + !isMatch(error.actionName, expected.actionName) || !isMatch(error.message, expected.message), + ) + if (unexpected.length > 0) { + throw new Error(`${reason} included unexpected AbortErrors:\n${formatAbortErrors(unexpected)}`) + } +} + +export async function assertExpectedRouteLoaderTeardownAbort(routeName: string) { + await Promise.resolve() + await new Promise((resolve) => queueMicrotask(resolve)) + assertOnlyExpectedAbortErrors( + { + actionName: new RegExp(`${escapeRegExp(routeName)}.*\\.loader\\.onReject$`), + message: /unmatch/, + }, + `Expected ${routeName} matched-route teardown AbortErrors`, + ) +} + addGlobalExtension((target) => { if (isAction(target) && target.name.endsWith('.onReject')) { target.extend( diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index a947022..2164d37 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -7,7 +7,7 @@ import addonA11y from '@storybook/addon-a11y' import { definePreview } from '@storybook/react-vite' import { initialize, mswLoader } from 'msw-storybook-addon' -import { clearAbortErrors, drainAbortErrors } from './abortErrorGuard' +import { clearAbortErrors, drainAbortErrors, formatAbortErrors } from './abortErrorGuard' // oxlint-disable-next-line no-restricted-imports import { useEffect, useMemo, type PropsWithChildren } from 'react' @@ -82,10 +82,9 @@ const preview = definePreview({ return () => { const errors = drainAbortErrors() if (errors.length > 0) { - const summary = errors - .map((e) => ` - ${e.actionName}: ${e.message} ×${e.count}`) - .join('\n') - throw new Error(`Reatom AbortErrors detected during story test:\n${summary}`) + throw new Error( + `Reatom AbortErrors detected during story test:\n${formatAbortErrors(errors)}`, + ) } } }, 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..d7e35ce --- /dev/null +++ b/src/app/integration/Articles.list.stories.tsx @@ -0,0 +1,102 @@ +import { assertExpectedRouteLoaderTeardownAbort } 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 assertExpectedRouteLoaderTeardownAbort('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..f7fab85 --- /dev/null +++ b/src/app/integration/Articles.navigation.stories.tsx @@ -0,0 +1,51 @@ +import { assertExpectedRouteLoaderTeardownAbort } 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 assertExpectedRouteLoaderTeardownAbort('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 92543be..0000000 --- a/src/app/integration/Articles.stories.tsx +++ /dev/null @@ -1,336 +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.see(role('list', 'Articles').wait()) - - 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/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..125dbb4 --- /dev/null +++ b/src/app/integration/Chat.list.stories.tsx @@ -0,0 +1,70 @@ +import { assertExpectedRouteLoaderTeardownAbort } 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 assertExpectedRouteLoaderTeardownAbort('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 2748d34..0000000 --- a/src/app/integration/Chat.stories.tsx +++ /dev/null @@ -1,279 +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..84c9a70 --- /dev/null +++ b/src/app/integration/Connections.list.stories.tsx @@ -0,0 +1,116 @@ +import { assertExpectedRouteLoaderTeardownAbort } 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 assertExpectedRouteLoaderTeardownAbort('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..6e7c57e --- /dev/null +++ b/src/app/integration/Connections.navigation.stories.tsx @@ -0,0 +1,55 @@ +import { assertExpectedRouteLoaderTeardownAbort } 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 assertExpectedRouteLoaderTeardownAbort('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 66b685d..0000000 --- a/src/app/integration/Connections.stories.tsx +++ /dev/null @@ -1,387 +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.see(role('list', 'Connections').wait()) - - 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')) - }) -})