diff --git a/.config/mise/conf.d/tasks-test.toml b/.config/mise/conf.d/tasks-test.toml
index f077bb0..de72f09 100644
--- a/.config/mise/conf.d/tasks-test.toml
+++ b/.config/mise/conf.d/tasks-test.toml
@@ -17,13 +17,16 @@ VITE_CONNECT_LOGGER = "false"
description = "Run tests in watch mode"
alias = "t"
run = "vp test"
+env = { VITE_CONNECT_LOGGER = "false" }
[tasks."test:run"]
description = "Run tests once"
alias = "tr"
run = "vp test run"
+env = { VITE_CONNECT_LOGGER = "false" }
[tasks."test:coverage"]
description = "Run tests with coverage"
alias = "tcov"
run = "vp test run --coverage"
+env = { VITE_CONNECT_LOGGER = "false" }
diff --git a/.storybook/abortErrorGuard.ts b/.storybook/abortErrorGuard.ts
new file mode 100644
index 0000000..6975663
Binary files /dev/null and b/.storybook/abortErrorGuard.ts differ
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index 7549a2a..2164d37 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -6,8 +6,10 @@ import { reatomContext } from '@reatom/react'
import addonA11y from '@storybook/addon-a11y'
import { definePreview } from '@storybook/react-vite'
import { initialize, mswLoader } from 'msw-storybook-addon'
+
+import { clearAbortErrors, drainAbortErrors, formatAbortErrors } from './abortErrorGuard'
// oxlint-disable-next-line no-restricted-imports
-import { useMemo, type PropsWithChildren } from 'react'
+import { useEffect, useMemo, type PropsWithChildren } from 'react'
import { handlers } from '#app/mocks/handlers'
import { setAuthenticatedForTest } from '#entities/auth'
@@ -31,10 +33,18 @@ function ReatomDecorator({
authenticated = true,
}: PropsWithChildren<{ authenticated?: boolean; initialPath?: string }>) {
const frame = useMemo(() => {
- const nextFrame = setupStorybookUrl(initialPath)
- nextFrame.run(() => setAuthenticatedForTest(authenticated ? authMockSession : null))
- return nextFrame
+ return setupStorybookUrl(initialPath, () => {
+ setAuthenticatedForTest(authenticated ? authMockSession : null)
+ })
}, [authenticated, initialPath])
+
+ useEffect(() => {
+ return () => {
+ clearAbortErrors()
+ queueMicrotask(clearAbortErrors)
+ }
+ }, [frame])
+
return {children}
}
@@ -62,12 +72,21 @@ const preview = definePreview({
},
// fallow-ignore-next-line complexity
beforeEach: async ({ globals }) => {
- if (!import.meta.env['VITEST']) return
+ clearAbortErrors()
+ if (!(globalThis as Record)['__vitest_worker__']) return
const { page } = await import('vite-plus/test/browser')
const viewportGlobal = globals['viewport'] as { value?: string } | string | undefined
const viewportName = typeof viewportGlobal === 'string' ? viewportGlobal : viewportGlobal?.value
const viewport = (viewportName ? getViewportSize(viewportName) : null) ?? FALLBACK_VIEWPORT
await page.viewport(viewport.width, viewport.height)
+ return () => {
+ const errors = drainAbortErrors()
+ if (errors.length > 0) {
+ throw new Error(
+ `Reatom AbortErrors detected during story test:\n${formatAbortErrors(errors)}`,
+ )
+ }
+ }
},
})
diff --git a/.storybook/setupStorybookUrl.ts b/.storybook/setupStorybookUrl.ts
index 744b3d8..d921d92 100644
--- a/.storybook/setupStorybookUrl.ts
+++ b/.storybook/setupStorybookUrl.ts
@@ -1,13 +1,17 @@
import { context, noop, urlAtom, withChangeHook } from '@reatom/core'
const originalHref = window.location.href
-export const setupStorybookUrl = (initialPath = '') => {
+export const setupStorybookUrl = (initialPath = '', beforeNavigate?: () => void) => {
const frame = context.start()
frame.run(() => {
// Configure urlAtom for Storybook: routing state works internally but
// the iframe URL stays fixed so Storybook remains happy.
urlAtom.sync.set(() => noop)
urlAtom.extend(withChangeHook(() => void window.history.replaceState({}, '', originalHref)))
+ // Run pre-navigation setup (e.g. auth) BEFORE urlAtom.go so that
+ // route matching and loader evaluation happen only once with the
+ // correct state, avoiding concurrent loader abort errors.
+ beforeNavigate?.()
const base = import.meta.env.BASE_URL ?? ''
urlAtom.go(base + initialPath)
})
diff --git a/bun.lock b/bun.lock
index 6bee967..1eb83d4 100644
--- a/bun.lock
+++ b/bun.lock
@@ -7,8 +7,8 @@
"dependencies": {
"@ark-ui/react": "^5.34.0",
"@icons-pack/react-simple-icons": "^13.13.0",
- "@reatom/core": "^1001.0.0",
- "@reatom/react": "^1001.0.0",
+ "@reatom/core": "https://pkg.pr.new/reatom/reatom/@reatom/core@1294",
+ "@reatom/react": "https://pkg.pr.new/reatom/reatom/@reatom/react@1294",
"lucide-react": "^1.14.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -523,9 +523,9 @@
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
- "@reatom/core": ["@reatom/core@1001.0.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0" }, "optionalDependencies": { "idb-keyval": "*" } }, "sha512-zcHVumpWczzzYzwcSPbFyxX9N46dyVeS4F8vtK2qpNmewTKMv0eFjFDYkSu8xV+ISRhGtMz+Xr+GTS99dTTvbw=="],
+ "@reatom/core": ["@reatom/core@https://pkg.pr.new/reatom/reatom/@reatom/core@1294", { "dependencies": { "@standard-schema/spec": "^1.0.0" }, "optionalDependencies": { "idb-keyval": "*" } }, "sha512-fvCGYti2VczUwFmmEFaQt+Q0cFM4ghynp51scX0HMzR3iygOqVMxZh0/zHlgz153Dv7Y70R4uWMnTrsG3v0Upw=="],
- "@reatom/react": ["@reatom/react@1001.0.0", "", { "peerDependencies": { "@reatom/core": "^1001.0.0", "react": ">=18.2.0", "react-dom": ">=18.2.0" } }, "sha512-7UmxyJnv9KSmOnf7uHJrtTcG0/RtHjUIOY0is+R1oNeEfEl5dKAcuza6zOobm9ABTbZVvaEgsWkguoAYK8YJtQ=="],
+ "@reatom/react": ["@reatom/react@https://pkg.pr.new/reatom/reatom/@reatom/react@1294", { "peerDependencies": { "@reatom/core": "^1001.1.0", "react": ">=18.2.0", "react-dom": ">=18.2.0" } }, "sha512-JIdqF48kbZLKVOPBkdL1k8SHrRtjcDLZMAhT+XIl6+NeblJSC05+TUcVdu1fMKI1yvz1GygJxM7hEH26GufrIg=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ=="],
diff --git a/package.json b/package.json
index 216d11d..70a33ef 100644
--- a/package.json
+++ b/package.json
@@ -31,8 +31,8 @@
"dependencies": {
"@ark-ui/react": "^5.34.0",
"@icons-pack/react-simple-icons": "^13.13.0",
- "@reatom/core": "^1001.0.0",
- "@reatom/react": "^1001.0.0",
+ "@reatom/core": "https://pkg.pr.new/reatom/reatom/@reatom/core@1294",
+ "@reatom/react": "https://pkg.pr.new/reatom/reatom/@reatom/react@1294",
"lucide-react": "^1.14.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
diff --git a/src/app/integration/Articles.detail.stories.tsx b/src/app/integration/Articles.detail.stories.tsx
new file mode 100644
index 0000000..88106d3
--- /dev/null
+++ b/src/app/integration/Articles.detail.stories.tsx
@@ -0,0 +1,112 @@
+import preview from '#.storybook/preview'
+import { App } from '#app/App'
+import { articleDetail } from '#entities/article/mocks/handlers'
+import { articlesActor as I } from '#pages/articles/testing'
+import { heading, role } from '#shared/test'
+
+const meta = preview.meta({
+ title: 'Integration/Articles/Detail',
+ component: App,
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'articles/1',
+ },
+ loaders: [(ctx) => I.init(ctx)],
+})
+
+export default meta
+
+export const HandlesArticleDetailServerError = meta.story({
+ name: 'Article Detail Server Error',
+ play: () => I.waitExit(role('status')),
+ parameters: {
+ msw: {
+ handlers: { articleDetail: articleDetail.error },
+ },
+ },
+})
+
+HandlesArticleDetailServerError.test(
+ 'shows error state when article detail request fails',
+ async () => {
+ await I.scope(role('main'), async () => {
+ await I.seeDetailError()
+ })
+ },
+)
+
+HandlesArticleDetailServerError.test('keeps detail error state when retry also fails', async () => {
+ await I.scope(role('main'), async () => {
+ await I.seeDetailError()
+ await I.retry()
+ await I.waitExit(role('status'))
+ await I.seeDetailError()
+ })
+})
+
+export const RecoversAfterArticleDetailRetry = meta.story({
+ name: 'Article Detail Retry Success',
+ play: () => I.waitExit(role('status')),
+ parameters: {
+ msw: {
+ handlers: { articleDetail: articleDetail.retrySucceeds() },
+ },
+ },
+})
+
+RecoversAfterArticleDetailRetry.test('loads article detail after retry succeeds', async () => {
+ await I.scope(role('main'), async () => {
+ await I.seeDetailError()
+ await I.retry()
+ await I.waitExit(role('status'))
+ await I.see(heading('Quarterly report').wait())
+ await I.seeArticleDetail('Quarterly report')
+ })
+})
+
+export const HandlesArticleDetailServerErrorMobile = meta.story({
+ name: 'Article Detail Server Error (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ parameters: HandlesArticleDetailServerError.input.parameters,
+ play: () => I.waitExit(role('status')),
+})
+
+HandlesArticleDetailServerErrorMobile.test(
+ '[mobile] shows error state when article detail request fails',
+ async () => {
+ await I.scope(role('main'), async () => {
+ await I.seeDetailError()
+ })
+ },
+)
+
+export const KeepsLoadingWhenArticleDetailNeverResolves = meta.story({
+ name: 'Article Detail Loading State',
+ parameters: {
+ msw: {
+ handlers: { articleDetail: articleDetail.loading },
+ },
+ },
+})
+
+KeepsLoadingWhenArticleDetailNeverResolves.test(
+ 'shows detail loading state while article detail is pending',
+ async () => {
+ const detail = await I.see(role('main'))
+ await I.seeDetailLoading(detail)
+ },
+)
+
+export const KeepsLoadingWhenArticleDetailNeverResolvesMobile = meta.story({
+ name: 'Article Detail Loading State (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ parameters: KeepsLoadingWhenArticleDetailNeverResolves.input.parameters,
+})
+
+KeepsLoadingWhenArticleDetailNeverResolvesMobile.test(
+ '[mobile] shows detail loading state while article detail is pending',
+ async () => {
+ const detail = await I.see(role('main'))
+ await I.seeDetailLoading(detail)
+ },
+)
diff --git a/src/app/integration/Articles.direct-url.stories.tsx b/src/app/integration/Articles.direct-url.stories.tsx
new file mode 100644
index 0000000..467ddaa
--- /dev/null
+++ b/src/app/integration/Articles.direct-url.stories.tsx
@@ -0,0 +1,49 @@
+import preview from '#.storybook/preview'
+import { App } from '#app/App'
+import { articlesActor as I } from '#pages/articles/testing'
+import { role } from '#shared/test'
+
+const meta = preview.meta({
+ title: 'Integration/Articles/Direct URL',
+ component: App,
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'articles/1',
+ },
+ loaders: [(ctx) => I.init(ctx)],
+})
+
+export default meta
+
+export const DirectUrlNavigation = meta.story({
+ name: 'Direct URL to Article',
+ play: () => I.waitExit(role('status')),
+})
+
+DirectUrlNavigation.test('loads article detail directly from URL', async () => {
+ await I.seeArticleDetail('Quarterly report')
+ await I.seeArticleDetailContent()
+})
+
+export const DirectUrlNotFound = meta.story({
+ name: 'Direct URL to Missing Article',
+ parameters: { initialPath: 'articles/missing-42' },
+ play: () => I.waitExit(role('status')),
+})
+
+DirectUrlNotFound.test('shows not-found state for missing article URL', async () => {
+ await I.scope(role('main'), async () => {
+ await I.seeArticleNotFound('missing-42')
+ })
+})
+
+export const DirectUrlNavigationMobile = meta.story({
+ name: 'Direct URL to Article (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ parameters: { initialPath: 'articles/1' },
+ play: () => I.waitExit(role('status')),
+})
+
+DirectUrlNavigationMobile.test('[mobile] loads article detail directly from URL', async () => {
+ await I.seeArticleDetail('Quarterly report')
+})
diff --git a/src/app/integration/Articles.list-request.stories.tsx b/src/app/integration/Articles.list-request.stories.tsx
new file mode 100644
index 0000000..5b09851
--- /dev/null
+++ b/src/app/integration/Articles.list-request.stories.tsx
@@ -0,0 +1,101 @@
+import preview from '#.storybook/preview'
+import { App } from '#app/App'
+import { articleList } from '#entities/article/mocks/handlers'
+import { articlesActor as I } from '#pages/articles/testing'
+import { role, text } from '#shared/test'
+
+const meta = preview.meta({
+ title: 'Integration/Articles/List Request',
+ component: App,
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'articles',
+ },
+ loaders: [(ctx) => I.init(ctx)],
+})
+
+export default meta
+
+export const HandlesArticlesLoadServerError = meta.story({
+ name: 'Articles Load Server Error',
+ parameters: {
+ msw: {
+ handlers: { articleList: articleList.error },
+ },
+ },
+ play: () => I.waitExit(role('status')),
+})
+
+HandlesArticlesLoadServerError.test('shows error state when articles request fails', async () => {
+ await I.seeError()
+ await I.see(text("We couldn't load the article list. Try again in a moment."))
+})
+
+HandlesArticlesLoadServerError.test('keeps error state when retry also fails', async () => {
+ await I.seeError()
+ await I.retry()
+ await I.waitExit(role('status'))
+ await I.seeError()
+})
+
+export const RecoversAfterArticlesLoadRetry = meta.story({
+ name: 'Articles Load Retry Success',
+ play: () => I.waitExit(role('status')),
+ parameters: {
+ msw: {
+ handlers: { articleList: articleList.retrySucceeds() },
+ },
+ },
+})
+
+RecoversAfterArticlesLoadRetry.test('loads article list after retry succeeds', async () => {
+ await I.seeError()
+ await I.retry()
+ await I.waitExit(role('status'))
+ await I.see(role('list', 'Articles').wait())
+ await I.seeArticleList()
+})
+
+export const HandlesArticlesLoadServerErrorMobile = meta.story({
+ name: 'Articles Load Server Error (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ parameters: HandlesArticlesLoadServerError.input.parameters,
+ play: () => I.waitExit(role('status')),
+})
+
+HandlesArticlesLoadServerErrorMobile.test(
+ '[mobile] shows error state when articles request fails',
+ async () => {
+ await I.seeError()
+ await I.see(text("We couldn't load the article list. Try again in a moment."))
+ },
+)
+
+export const KeepsLoadingWhenArticlesRequestNeverResolves = meta.story({
+ name: 'Articles Request Loading State',
+ parameters: {
+ msw: {
+ handlers: { articleList: articleList.loading },
+ },
+ },
+})
+
+KeepsLoadingWhenArticlesRequestNeverResolves.test(
+ 'keeps loading state for pending articles request',
+ async () => {
+ await I.seeLoading()
+ },
+)
+
+export const KeepsLoadingWhenArticlesRequestNeverResolvesMobile = meta.story({
+ name: 'Articles Request Loading State (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ parameters: KeepsLoadingWhenArticlesRequestNeverResolves.input.parameters,
+})
+
+KeepsLoadingWhenArticlesRequestNeverResolvesMobile.test(
+ '[mobile] keeps loading state for pending articles request',
+ async () => {
+ await I.seeLoading()
+ },
+)
diff --git a/src/app/integration/Articles.list.stories.tsx b/src/app/integration/Articles.list.stories.tsx
new file mode 100644
index 0000000..b2bf1ba
--- /dev/null
+++ b/src/app/integration/Articles.list.stories.tsx
@@ -0,0 +1,102 @@
+import { assertNoRouteLoaderAbortErrors } from '#.storybook/abortErrorGuard'
+import preview from '#.storybook/preview'
+import { App } from '#app/App'
+import { articlesActor as I } from '#pages/articles/testing'
+import { role } from '#shared/test'
+
+const meta = preview.meta({
+ title: 'Integration/Articles/List',
+ component: App,
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'articles',
+ },
+ loaders: [(ctx) => I.init(ctx)],
+})
+
+export default meta
+
+const assertExpectedDetailTeardown = async () => {
+ await assertNoRouteLoaderAbortErrors('articleDetail')
+}
+
+export const Default = meta.story({
+ name: 'Default',
+ play: () => I.waitExit(role('status')),
+})
+
+Default.test('renders article list with no selection message', async () => {
+ await I.seeNoSelection()
+ await I.seeArticleList()
+ await I.seeStatusBadges()
+})
+
+Default.test('shows search toolbar with new article button', async () => {
+ await I.seeSearchToolbar()
+})
+
+Default.test('shows article descriptions in list items', async () => {
+ await I.seeArticleDescription(/Revenue overview and growth metrics/)
+ await I.seeArticleDescription(/Engineering headcount proposal/)
+})
+
+Default.test('shows article detail when article is clicked', async () => {
+ await I.openArticle(/Quarterly report/i)
+ await I.seeArticleDetail('Quarterly report')
+})
+
+Default.test('shows all content paragraphs in article detail', async () => {
+ await I.openArticle(/Quarterly report/i)
+ await I.seeArticleDetail('Quarterly report')
+ await I.seeArticleDetailContent()
+})
+
+Default.test('shows edit button and status badge in article detail', async () => {
+ await I.openArticle(/Quarterly report/i)
+ await I.seeArticleDetail('Quarterly report')
+ await I.seeArticleDetailStatus('Done')
+})
+
+Default.test('shows article description in detail view', async () => {
+ await I.openArticle(/Quarterly report/i)
+ await I.seeArticleDetailDescription(
+ 'Revenue overview and growth metrics for Q3 across all regions.',
+ )
+})
+
+export const DefaultMobile = meta.story({
+ name: 'Default (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ play: () => I.waitExit(role('status')),
+})
+
+DefaultMobile.test('[mobile] shows article list when no article is selected', async () => {
+ await I.seeArticleList()
+})
+
+DefaultMobile.test('[mobile] shows search toolbar with new article button', async () => {
+ await I.seeSearchToolbar()
+})
+
+DefaultMobile.test('[mobile] shows article detail when article is clicked', async () => {
+ await I.openArticle(/Quarterly report/i)
+ await I.seeArticleDetail('Quarterly report')
+})
+
+DefaultMobile.test('[mobile] shows all content paragraphs in article detail', async () => {
+ await I.openArticle(/Quarterly report/i)
+ await I.seeArticleDetail('Quarterly report')
+ await I.seeArticleDetailContent()
+})
+
+DefaultMobile.test('[mobile] displays correct status badges for different statuses', async () => {
+ await I.seeStatusBadges()
+})
+
+DefaultMobile.test('[mobile] can navigate back to article list', async () => {
+ await I.openArticle(/Quarterly report/i)
+ await I.seeArticleDetail('Quarterly report')
+ await I.goBack()
+ await I.see(role('list', 'Articles').wait())
+ await assertExpectedDetailTeardown()
+})
diff --git a/src/app/integration/Articles.navigation.stories.tsx b/src/app/integration/Articles.navigation.stories.tsx
new file mode 100644
index 0000000..46bb2f8
--- /dev/null
+++ b/src/app/integration/Articles.navigation.stories.tsx
@@ -0,0 +1,51 @@
+import { assertNoRouteLoaderAbortErrors } from '#.storybook/abortErrorGuard'
+import preview from '#.storybook/preview'
+import { App } from '#app/App'
+import { articlesActor as I } from '#pages/articles/testing'
+import { role } from '#shared/test'
+
+const meta = preview.meta({
+ title: 'Integration/Articles/Navigation',
+ component: App,
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'articles',
+ },
+ loaders: [(ctx) => I.init(ctx)],
+})
+
+export default meta
+
+export const SwitchBetweenArticles = meta.story({
+ name: 'Switch Between Articles',
+ play: () => I.waitExit(role('status')),
+})
+
+SwitchBetweenArticles.test('can switch from one article detail to another', async () => {
+ await I.openArticle(/Quarterly report/i)
+ await I.seeArticleDetail('Quarterly report')
+
+ await I.openArticle(/Hiring plan/i)
+ await I.seeArticleDetail('Hiring plan')
+})
+
+export const SwitchBetweenArticlesMobile = meta.story({
+ name: 'Switch Between Articles (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ play: () => I.waitExit(role('status')),
+})
+
+SwitchBetweenArticlesMobile.test(
+ '[mobile] can switch to another article after navigating back',
+ async () => {
+ await I.openArticle(/Quarterly report/i)
+ await I.seeArticleDetail('Quarterly report')
+
+ await I.goBack()
+ await I.see(role('list', 'Articles').wait())
+ await assertNoRouteLoaderAbortErrors('articleDetail')
+
+ await I.openArticle(/Hiring plan/i)
+ await I.seeArticleDetail('Hiring plan')
+ },
+)
diff --git a/src/app/integration/Articles.stories.tsx b/src/app/integration/Articles.stories.tsx
deleted file mode 100644
index 54deb86..0000000
--- a/src/app/integration/Articles.stories.tsx
+++ /dev/null
@@ -1,332 +0,0 @@
-import preview from '#.storybook/preview'
-import { App } from '#app/App'
-import { articleDetail, articleList } from '#entities/article/mocks/handlers'
-import { articlesActor as I } from '#pages/articles/testing'
-import { heading, link, role, text } from '#shared/test'
-
-const meta = preview.meta({
- title: 'Integration/Articles',
- component: App,
- parameters: { layout: 'fullscreen', initialPath: 'articles' },
- loaders: [(ctx) => I.init(ctx)],
-})
-
-export default meta
-
-export const Default = meta.story({
- name: 'Default',
- play: () => I.waitExit(role('status')),
-})
-
-Default.test('renders article list with no selection message', async () => {
- await I.seeNoSelection()
- await I.seeArticleList()
- await I.seeStatusBadges()
-})
-
-Default.test('shows search toolbar with new article button', async () => {
- await I.seeSearchToolbar()
-})
-
-Default.test('shows article descriptions in list items', async () => {
- await I.seeArticleDescription(/Revenue overview and growth metrics/)
- await I.seeArticleDescription(/Engineering headcount proposal/)
-})
-
-Default.test('shows article detail when article is clicked', async () => {
- await I.openArticle(/Quarterly report/i)
- await I.seeArticleDetail('Quarterly report')
-})
-
-Default.test('shows all content paragraphs in article detail', async () => {
- await I.openArticle(/Quarterly report/i)
- await I.seeArticleDetail('Quarterly report')
- await I.seeArticleDetailContent()
-})
-
-Default.test('shows edit button and status badge in article detail', async () => {
- await I.openArticle(/Quarterly report/i)
- await I.seeArticleDetail('Quarterly report')
- await I.seeArticleDetailStatus('Done')
-})
-
-Default.test('shows article description in detail view', async () => {
- await I.openArticle(/Quarterly report/i)
- await I.seeArticleDetailDescription(
- 'Revenue overview and growth metrics for Q3 across all regions.',
- )
-})
-
-Default.test('can select different articles', async () => {
- await I.openArticle(/Quarterly report/i)
- await I.seeArticleDetail('Quarterly report')
-
- await I.openArticle(/Hiring plan/i)
- await I.seeArticleDetail('Hiring plan')
-})
-
-export const DirectUrlNavigation = meta.story({
- name: 'Direct URL to Article',
- parameters: { initialPath: 'articles/1' },
- play: () => I.waitExit(role('status')),
-})
-
-DirectUrlNavigation.test('loads article detail directly from URL', async () => {
- await I.seeArticleDetail('Quarterly report')
- await I.seeArticleDetailContent()
-})
-
-export const DirectUrlNotFound = meta.story({
- name: 'Direct URL to Missing Article',
- parameters: { initialPath: 'articles/missing-42' },
- play: () => I.waitExit(role('status')),
-})
-
-DirectUrlNotFound.test('shows not-found state for missing article URL', async () => {
- await I.scope(role('main'), async () => {
- await I.seeArticleNotFound('missing-42')
- })
-})
-
-export const DirectUrlNavigationMobile = meta.story({
- name: 'Direct URL to Article (Mobile)',
- globals: { viewport: { value: 'sm', isRotated: false } },
- parameters: { initialPath: 'articles/1' },
- play: () => I.waitExit(role('status')),
-})
-
-DirectUrlNavigationMobile.test('[mobile] loads article detail directly from URL', async () => {
- await I.seeArticleDetail('Quarterly report')
-})
-
-export const DefaultMobile = meta.story({
- name: 'Default (Mobile)',
- globals: { viewport: { value: 'sm', isRotated: false } },
- play: () => I.waitExit(role('status')),
-})
-
-DefaultMobile.test('[mobile] shows article list when no article is selected', async () => {
- await I.seeArticleList()
-})
-
-DefaultMobile.test('[mobile] shows search toolbar with new article button', async () => {
- await I.seeSearchToolbar()
-})
-
-DefaultMobile.test('[mobile] shows article detail when article is clicked', async () => {
- await I.openArticle(/Quarterly report/i)
- await I.seeArticleDetail('Quarterly report')
-})
-
-DefaultMobile.test('[mobile] shows all content paragraphs in article detail', async () => {
- await I.openArticle(/Quarterly report/i)
- await I.seeArticleDetail('Quarterly report')
- await I.seeArticleDetailContent()
-})
-
-DefaultMobile.test('[mobile] displays correct status badges for different statuses', async () => {
- await I.seeStatusBadges()
-})
-
-DefaultMobile.test('[mobile] can select different articles', async () => {
- await I.openArticle(/Quarterly report/i)
- await I.seeArticleDetail('Quarterly report')
-
- await I.goBack()
-
- await I.openArticle(/Hiring plan/i)
- await I.seeArticleDetail('Hiring plan')
-})
-
-export const HandlesArticlesLoadServerError = meta.story({
- name: 'Articles Load Server Error',
- parameters: {
- msw: {
- handlers: { articleList: articleList.error },
- },
- },
- play: () => I.waitExit(role('status')),
-})
-
-HandlesArticlesLoadServerError.test('shows error state when articles request fails', async () => {
- await I.seeError()
- await I.see(text("We couldn't load the article list. Try again in a moment."))
-})
-
-HandlesArticlesLoadServerError.test('keeps error state when retry also fails', async () => {
- await I.seeError()
- await I.retry()
- await I.waitExit(role('status'))
- await I.seeError()
-})
-
-export const RecoversAfterArticlesLoadRetry = meta.story({
- name: 'Articles Load Retry Success',
- play: () => I.waitExit(role('status')),
- parameters: {
- msw: {
- handlers: { articleList: articleList.retrySucceeds() },
- },
- },
-})
-
-RecoversAfterArticlesLoadRetry.test('loads article list after retry succeeds', async () => {
- await I.seeError()
- await I.retry()
- await I.waitExit(role('status'))
- await I.see(role('list', 'Articles').wait())
- await I.seeArticleList()
-})
-
-export const HandlesArticlesLoadServerErrorMobile = meta.story({
- name: 'Articles Load Server Error (Mobile)',
- globals: { viewport: { value: 'sm', isRotated: false } },
- parameters: HandlesArticlesLoadServerError.input.parameters,
- play: () => I.waitExit(role('status')),
-})
-
-HandlesArticlesLoadServerErrorMobile.test(
- '[mobile] shows error state when articles request fails',
- async () => {
- await I.seeError()
- await I.see(text("We couldn't load the article list. Try again in a moment."))
- },
-)
-
-export const KeepsLoadingWhenArticlesRequestNeverResolves = meta.story({
- name: 'Articles Request Loading State',
- parameters: {
- msw: {
- handlers: { articleList: articleList.loading },
- },
- },
-})
-
-KeepsLoadingWhenArticlesRequestNeverResolves.test(
- 'keeps loading state for pending articles request',
- async () => {
- await I.seeLoading()
- },
-)
-
-export const KeepsLoadingWhenArticlesRequestNeverResolvesMobile = meta.story({
- name: 'Articles Request Loading State (Mobile)',
- globals: { viewport: { value: 'sm', isRotated: false } },
- parameters: KeepsLoadingWhenArticlesRequestNeverResolves.input.parameters,
-})
-
-KeepsLoadingWhenArticlesRequestNeverResolvesMobile.test(
- '[mobile] keeps loading state for pending articles request',
- async () => {
- await I.seeLoading()
- },
-)
-
-export const HandlesArticleDetailServerError = meta.story({
- name: 'Article Detail Server Error',
- play: () => I.waitExit(role('status')),
- parameters: {
- msw: {
- handlers: { articleDetail: articleDetail.error },
- },
- },
-})
-
-HandlesArticleDetailServerError.test(
- 'shows error state when article detail request fails',
- async () => {
- await I.openArticle(/Quarterly report/i)
-
- await I.scope(role('main'), async () => {
- await I.seeDetailError()
- })
- },
-)
-
-HandlesArticleDetailServerError.test('keeps detail error state when retry also fails', async () => {
- await I.openArticle(/Quarterly report/i)
-
- await I.scope(role('main'), async () => {
- await I.seeDetailError()
- await I.retry()
- await I.waitExit(role('status'))
- await I.seeDetailError()
- })
-})
-
-export const RecoversAfterArticleDetailRetry = meta.story({
- name: 'Article Detail Retry Success',
- play: () => I.waitExit(role('status')),
- parameters: {
- msw: {
- handlers: { articleDetail: articleDetail.retrySucceeds() },
- },
- },
-})
-
-RecoversAfterArticleDetailRetry.test('loads article detail after retry succeeds', async () => {
- await I.openArticle(/Quarterly report/i)
-
- await I.scope(role('main'), async () => {
- await I.seeDetailError()
- await I.retry()
- await I.waitExit(role('status'))
- await I.see(heading('Quarterly report').wait())
- await I.seeArticleDetail('Quarterly report')
- })
-})
-
-export const HandlesArticleDetailServerErrorMobile = meta.story({
- name: 'Article Detail Server Error (Mobile)',
- globals: { viewport: { value: 'sm', isRotated: false } },
- parameters: HandlesArticleDetailServerError.input.parameters,
- play: () => I.waitExit(role('status')),
-})
-
-HandlesArticleDetailServerErrorMobile.test(
- '[mobile] shows error state when article detail request fails',
- async () => {
- await I.openArticle(/Quarterly report/i)
-
- await I.scope(role('main'), async () => {
- await I.seeDetailError()
- })
- },
-)
-
-export const KeepsLoadingWhenArticleDetailNeverResolves = meta.story({
- name: 'Article Detail Loading State',
- play: () => I.waitExit(role('status')),
- parameters: {
- msw: {
- handlers: { articleDetail: articleDetail.loading },
- },
- },
-})
-
-KeepsLoadingWhenArticleDetailNeverResolves.test(
- 'shows detail loading state while article detail is pending',
- async () => {
- await I.click(link(/Quarterly report/i))
-
- const detail = await I.see(role('main'))
- await I.seeDetailLoading(detail)
- },
-)
-
-export const KeepsLoadingWhenArticleDetailNeverResolvesMobile = meta.story({
- name: 'Article Detail Loading State (Mobile)',
- globals: { viewport: { value: 'sm', isRotated: false } },
- parameters: KeepsLoadingWhenArticleDetailNeverResolves.input.parameters,
- play: () => I.waitExit(role('status')),
-})
-
-KeepsLoadingWhenArticleDetailNeverResolvesMobile.test(
- '[mobile] shows detail loading state while article detail is pending',
- async () => {
- await I.click(link(/Quarterly report/i))
-
- const detail = await I.see(role('main'))
- await I.seeDetailLoading(detail)
- },
-)
diff --git a/src/app/integration/Auth.stories.tsx b/src/app/integration/Auth.stories.tsx
index d8d533e..1d11a50 100644
--- a/src/app/integration/Auth.stories.tsx
+++ b/src/app/integration/Auth.stories.tsx
@@ -12,7 +12,11 @@ const field = (name: string) => (canvas: Canvas) => canvas.getByLabelText(name)
const meta = preview.meta({
title: 'Integration/Auth',
component: App,
- parameters: { layout: 'fullscreen', authenticated: false, initialPath: 'login' },
+ parameters: {
+ layout: 'fullscreen',
+ authenticated: false,
+ initialPath: 'login',
+ },
loaders: [(ctx) => I.init(ctx)],
})
diff --git a/src/app/integration/Calculator.stories.tsx b/src/app/integration/Calculator.stories.tsx
index 9f5d7e5..c6956fc 100644
--- a/src/app/integration/Calculator.stories.tsx
+++ b/src/app/integration/Calculator.stories.tsx
@@ -5,7 +5,10 @@ import { calculatorActor as I, calculatorLoc as loc } from '#pages/calculator/te
const meta = preview.meta({
title: 'Integration/Calculator',
component: App,
- parameters: { layout: 'fullscreen', initialPath: 'calculator' },
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'calculator',
+ },
loaders: [(ctx) => I.init(ctx)],
})
diff --git a/src/app/integration/Chat.detail.stories.tsx b/src/app/integration/Chat.detail.stories.tsx
new file mode 100644
index 0000000..fe68683
--- /dev/null
+++ b/src/app/integration/Chat.detail.stories.tsx
@@ -0,0 +1,117 @@
+import preview from '#.storybook/preview'
+import { App } from '#app/App'
+import { conversationDetail } from '#entities/conversation/mocks/handlers'
+import { chatActor as I, chatLoc as loc } from '#pages/chat/testing'
+import { role, text } from '#shared/test'
+
+const meta = preview.meta({
+ title: 'Integration/Chat/Detail',
+ component: App,
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'chat/1',
+ },
+ loaders: [(ctx) => I.init(ctx)],
+})
+
+export default meta
+
+export const HandlesConversationDetailServerError = meta.story({
+ name: 'Conversation Detail Server Error',
+ play: () => I.waitExit(role('status')),
+ parameters: {
+ msw: {
+ handlers: { conversationDetail: conversationDetail.error },
+ },
+ },
+})
+
+HandlesConversationDetailServerError.test(
+ 'shows error state when conversation detail request fails',
+ async () => {
+ await I.scope(role('main'), async () => {
+ await I.seeDetailError()
+ })
+ },
+)
+
+HandlesConversationDetailServerError.test(
+ 'keeps detail error state when retry also fails',
+ async () => {
+ await I.scope(role('main'), async () => {
+ await I.seeDetailError()
+ await I.retry()
+ await I.waitExit(role('status'))
+ await I.seeDetailError()
+ })
+ },
+)
+
+export const RecoversAfterConversationDetailRetry = meta.story({
+ name: 'Conversation Detail Retry Success',
+ play: () => I.waitExit(role('status')),
+ parameters: {
+ msw: {
+ handlers: { conversationDetail: conversationDetail.retrySucceeds() },
+ },
+ },
+})
+
+RecoversAfterConversationDetailRetry.test(
+ 'loads conversation detail after retry succeeds',
+ async () => {
+ await I.scope(role('main'), async () => {
+ await I.seeDetailError()
+ await I.retry()
+ await I.waitExit(role('status'))
+ await I.see(text('Has anyone looked at the failing CI on main?').wait())
+ })
+ },
+)
+
+export const HandlesConversationDetailServerErrorMobile = meta.story({
+ name: 'Conversation Detail Server Error (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ parameters: HandlesConversationDetailServerError.input.parameters,
+ play: () => I.waitExit(role('status')),
+})
+
+HandlesConversationDetailServerErrorMobile.test(
+ '[mobile] shows error state when conversation detail request fails',
+ async () => {
+ await I.scope(role('main'), async () => {
+ await I.seeDetailError()
+ })
+ },
+)
+
+export const KeepsLoadingWhenConversationDetailNeverResolves = meta.story({
+ name: 'Conversation Detail Loading State',
+ parameters: {
+ msw: {
+ handlers: { conversationDetail: conversationDetail.loading },
+ },
+ },
+})
+
+KeepsLoadingWhenConversationDetailNeverResolves.test(
+ 'shows message thread loading state while conversation detail is pending',
+ async () => {
+ await I.see(loc.messageThreadLoading)
+ await I.dontSee(loc.conversationNotFoundHeading)
+ },
+)
+
+export const KeepsLoadingWhenConversationDetailNeverResolvesMobile = meta.story({
+ name: 'Conversation Detail Loading State (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ parameters: KeepsLoadingWhenConversationDetailNeverResolves.input.parameters,
+})
+
+KeepsLoadingWhenConversationDetailNeverResolvesMobile.test(
+ '[mobile] shows message thread loading state while conversation detail is pending',
+ async () => {
+ await I.see(loc.messageThreadLoading)
+ await I.dontSee(loc.conversationNotFoundHeading)
+ },
+)
diff --git a/src/app/integration/Chat.direct-url.stories.tsx b/src/app/integration/Chat.direct-url.stories.tsx
new file mode 100644
index 0000000..67059b5
--- /dev/null
+++ b/src/app/integration/Chat.direct-url.stories.tsx
@@ -0,0 +1,27 @@
+import preview from '#.storybook/preview'
+import { App } from '#app/App'
+import { chatActor as I } from '#pages/chat/testing'
+import { role } from '#shared/test'
+
+const meta = preview.meta({
+ title: 'Integration/Chat/Direct URL',
+ component: App,
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'chat/missing-42',
+ },
+ loaders: [(ctx) => I.init(ctx)],
+})
+
+export default meta
+
+export const DirectUrlNotFound = meta.story({
+ name: 'Direct URL to Missing Conversation',
+ play: () => I.waitExit(role('status')),
+})
+
+DirectUrlNotFound.test('shows not-found state for missing conversation URL', async () => {
+ await I.scope(role('main'), async () => {
+ await I.seeConversationNotFound('missing-42')
+ })
+})
diff --git a/src/app/integration/Chat.list-request.stories.tsx b/src/app/integration/Chat.list-request.stories.tsx
new file mode 100644
index 0000000..2ffca65
--- /dev/null
+++ b/src/app/integration/Chat.list-request.stories.tsx
@@ -0,0 +1,99 @@
+import preview from '#.storybook/preview'
+import { App } from '#app/App'
+import { conversationList } from '#entities/conversation/mocks/handlers'
+import { chatActor as I } from '#pages/chat/testing'
+import { role } from '#shared/test'
+
+const meta = preview.meta({
+ title: 'Integration/Chat/List Request',
+ component: App,
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'chat',
+ },
+ loaders: [(ctx) => I.init(ctx)],
+})
+
+export default meta
+
+export const HandlesChatLoadServerError = meta.story({
+ name: 'Conversations Load Server Error',
+ play: () => I.waitExit(role('status')),
+ parameters: {
+ msw: {
+ handlers: { conversationList: conversationList.error },
+ },
+ },
+})
+
+HandlesChatLoadServerError.test('shows error state when conversations request fails', async () => {
+ await I.seeError()
+})
+
+HandlesChatLoadServerError.test('keeps error state when retry also fails', async () => {
+ await I.seeError()
+ await I.retry()
+ await I.waitExit(role('status'))
+ await I.seeError()
+})
+
+export const RecoversAfterChatLoadRetry = meta.story({
+ name: 'Conversations Load Retry Success',
+ play: () => I.waitExit(role('status')),
+ parameters: {
+ msw: {
+ handlers: { conversationList: conversationList.retrySucceeds() },
+ },
+ },
+})
+
+RecoversAfterChatLoadRetry.test('loads conversations after retry succeeds', async () => {
+ await I.seeError()
+ await I.retry()
+ await I.waitExit(role('status'))
+ await I.see(role('list', 'Chat').wait())
+ await I.seeConversationList()
+})
+
+export const HandlesChatLoadServerErrorMobile = meta.story({
+ name: 'Conversations Load Server Error (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ parameters: HandlesChatLoadServerError.input.parameters,
+ play: () => I.waitExit(role('status')),
+})
+
+HandlesChatLoadServerErrorMobile.test(
+ '[mobile] shows error state when conversations request fails',
+ async () => {
+ await I.seeError()
+ },
+)
+
+export const KeepsLoadingWhenChatRequestNeverResolves = meta.story({
+ name: 'Conversations Request Loading State',
+ parameters: {
+ msw: {
+ handlers: { conversationList: conversationList.loading },
+ },
+ },
+})
+
+KeepsLoadingWhenChatRequestNeverResolves.test(
+ 'keeps loading state for pending conversations request',
+ async () => {
+ await I.seeLoading()
+ },
+)
+
+export const KeepsLoadingWhenChatRequestNeverResolvesMobile = meta.story({
+ name: 'Conversations Request Loading State (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ parameters: KeepsLoadingWhenChatRequestNeverResolves.input.parameters,
+})
+
+KeepsLoadingWhenChatRequestNeverResolvesMobile.test(
+ '[mobile] keeps loading state for pending conversations request',
+ async () => {
+ await I.seeLoading()
+ },
+)
diff --git a/src/app/integration/Chat.list.stories.tsx b/src/app/integration/Chat.list.stories.tsx
new file mode 100644
index 0000000..9a5ef48
--- /dev/null
+++ b/src/app/integration/Chat.list.stories.tsx
@@ -0,0 +1,70 @@
+import { assertNoRouteLoaderAbortErrors } from '#.storybook/abortErrorGuard'
+import preview from '#.storybook/preview'
+import { App } from '#app/App'
+import { chatActor as I } from '#pages/chat/testing'
+import { link, role, text } from '#shared/test'
+
+const meta = preview.meta({
+ title: 'Integration/Chat/List',
+ component: App,
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'chat',
+ },
+ loaders: [(ctx) => I.init(ctx)],
+})
+
+export default meta
+
+const assertExpectedDetailTeardown = async () => {
+ await assertNoRouteLoaderAbortErrors('chatConversation')
+}
+
+export const Default = meta.story({
+ name: 'Default',
+ play: () => I.waitExit(role('status')),
+})
+
+Default.test('renders conversation list', async () => {
+ await I.seeConversationList()
+})
+
+Default.test('shows no-selection message when no conversation selected', async () => {
+ await I.see(text('No conversation selected'))
+})
+
+Default.test('shows message thread when conversation is clicked', async () => {
+ await I.click(link(/Engineering/))
+ await I.waitExit(role('status'))
+
+ await I.scope(role('main'), async () => {
+ await I.see(text('Has anyone looked at the failing CI on main?'))
+ })
+})
+
+export const DefaultMobile = meta.story({
+ name: 'Default (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ play: () => I.waitExit(role('status')),
+})
+
+DefaultMobile.test('[mobile] renders conversation list', async () => {
+ await I.seeConversationList()
+})
+
+DefaultMobile.test('[mobile] shows message thread when conversation is clicked', async () => {
+ await I.click(link(/Engineering/))
+ await I.waitExit(role('status'))
+
+ await I.scope(role('main'), async () => {
+ await I.see(text('Has anyone looked at the failing CI on main?'))
+ })
+})
+
+DefaultMobile.test('[mobile] can navigate back to conversation list', async () => {
+ await I.click(link(/Engineering/))
+ await I.waitExit(role('status'))
+ await I.goBack()
+ await I.see(link(/Engineering/))
+ await assertExpectedDetailTeardown()
+})
diff --git a/src/app/integration/Chat.stories.tsx b/src/app/integration/Chat.stories.tsx
deleted file mode 100644
index 9be5730..0000000
--- a/src/app/integration/Chat.stories.tsx
+++ /dev/null
@@ -1,276 +0,0 @@
-import preview from '#.storybook/preview'
-import { App } from '#app/App'
-import { conversationDetail, conversationList } from '#entities/conversation/mocks/handlers'
-import { chatActor as I, chatLoc as loc } from '#pages/chat/testing'
-import { link, role, text } from '#shared/test'
-
-const meta = preview.meta({
- title: 'Integration/Chat',
- component: App,
- parameters: { layout: 'fullscreen', initialPath: 'chat' },
- loaders: [(ctx) => I.init(ctx)],
-})
-
-export default meta
-
-export const Default = meta.story({
- name: 'Default',
- play: () => I.waitExit(role('status')),
-})
-
-Default.test('renders conversation list', async () => {
- await I.seeConversationList()
-})
-
-Default.test('shows no-selection message when no conversation selected', async () => {
- await I.see(text('No conversation selected'))
-})
-
-Default.test('shows message thread when conversation is clicked', async () => {
- await I.click(link(/Engineering/))
- await I.waitExit(role('status'))
-
- await I.scope(role('main'), async () => {
- await I.see(text('Has anyone looked at the failing CI on main?'))
- })
-})
-
-export const DirectUrlNotFound = meta.story({
- name: 'Direct URL to Missing Conversation',
- parameters: { initialPath: 'chat/missing-42' },
- play: () => I.waitExit(role('status')),
-})
-
-DirectUrlNotFound.test('shows not-found state for missing conversation URL', async () => {
- await I.scope(role('main'), async () => {
- await I.seeConversationNotFound('missing-42')
- })
-})
-
-export const DefaultMobile = meta.story({
- name: 'Default (Mobile)',
- globals: { viewport: { value: 'sm', isRotated: false } },
- play: () => I.waitExit(role('status')),
-})
-
-DefaultMobile.test('[mobile] renders conversation list', async () => {
- await I.seeConversationList()
-})
-
-DefaultMobile.test('[mobile] shows message thread when conversation is clicked', async () => {
- await I.click(link(/Engineering/))
- await I.waitExit(role('status'))
-
- await I.scope(role('main'), async () => {
- await I.see(text('Has anyone looked at the failing CI on main?'))
- })
-})
-
-DefaultMobile.test('[mobile] can navigate back to conversation list', async () => {
- await I.click(link(/Engineering/))
- await I.waitExit(role('status'))
- await I.goBack()
- await I.see(link(/Engineering/))
-})
-
-export const HandlesChatLoadServerError = meta.story({
- name: 'Conversations Load Server Error',
- play: () => I.waitExit(role('status')),
- parameters: {
- msw: {
- handlers: { conversationList: conversationList.error },
- },
- },
-})
-
-HandlesChatLoadServerError.test('shows error state when conversations request fails', async () => {
- await I.seeError()
-})
-
-HandlesChatLoadServerError.test('keeps error state when retry also fails', async () => {
- await I.seeError()
- await I.retry()
- await I.waitExit(role('status'))
- await I.seeError()
-})
-
-export const RecoversAfterChatLoadRetry = meta.story({
- name: 'Conversations Load Retry Success',
- play: () => I.waitExit(role('status')),
- parameters: {
- msw: {
- handlers: { conversationList: conversationList.retrySucceeds() },
- },
- },
-})
-
-RecoversAfterChatLoadRetry.test('loads conversations after retry succeeds', async () => {
- await I.seeError()
- await I.retry()
- await I.waitExit(role('status'))
- await I.see(role('list', 'Chat').wait())
- await I.seeConversationList()
-})
-
-export const HandlesChatLoadServerErrorMobile = meta.story({
- name: 'Conversations Load Server Error (Mobile)',
- globals: { viewport: { value: 'sm', isRotated: false } },
- parameters: HandlesChatLoadServerError.input.parameters,
- play: () => I.waitExit(role('status')),
-})
-
-HandlesChatLoadServerErrorMobile.test(
- '[mobile] shows error state when conversations request fails',
- async () => {
- await I.seeError()
- },
-)
-
-export const KeepsLoadingWhenChatRequestNeverResolves = meta.story({
- name: 'Conversations Request Loading State',
- parameters: {
- msw: {
- handlers: { conversationList: conversationList.loading },
- },
- },
-})
-
-KeepsLoadingWhenChatRequestNeverResolves.test(
- 'keeps loading state for pending conversations request',
- async () => {
- await I.seeLoading()
- },
-)
-
-export const KeepsLoadingWhenChatRequestNeverResolvesMobile = meta.story({
- name: 'Conversations Request Loading State (Mobile)',
- globals: { viewport: { value: 'sm', isRotated: false } },
- parameters: KeepsLoadingWhenChatRequestNeverResolves.input.parameters,
-})
-
-KeepsLoadingWhenChatRequestNeverResolvesMobile.test(
- '[mobile] keeps loading state for pending conversations request',
- async () => {
- await I.seeLoading()
- },
-)
-
-export const HandlesConversationDetailServerError = meta.story({
- name: 'Conversation Detail Server Error',
- play: () => I.waitExit(role('status')),
- parameters: {
- msw: {
- handlers: { conversationDetail: conversationDetail.error },
- },
- },
-})
-
-HandlesConversationDetailServerError.test(
- 'shows error state when conversation detail request fails',
- async () => {
- await I.click(link(/Engineering/))
- await I.waitExit(role('status'))
-
- await I.scope(role('main'), async () => {
- await I.seeDetailError()
- })
- },
-)
-
-HandlesConversationDetailServerError.test(
- 'keeps detail error state when retry also fails',
- async () => {
- await I.click(link(/Engineering/))
- await I.waitExit(role('status'))
-
- await I.scope(role('main'), async () => {
- await I.seeDetailError()
- await I.retry()
- await I.waitExit(role('status'))
- await I.seeDetailError()
- })
- },
-)
-
-export const RecoversAfterConversationDetailRetry = meta.story({
- name: 'Conversation Detail Retry Success',
- play: () => I.waitExit(role('status')),
- parameters: {
- msw: {
- handlers: { conversationDetail: conversationDetail.retrySucceeds() },
- },
- },
-})
-
-RecoversAfterConversationDetailRetry.test(
- 'loads conversation detail after retry succeeds',
- async () => {
- await I.click(link(/Engineering/))
- await I.waitExit(role('status'))
-
- await I.scope(role('main'), async () => {
- await I.seeDetailError()
- await I.retry()
- await I.waitExit(role('status'))
- await I.see(text('Has anyone looked at the failing CI on main?').wait())
- })
- },
-)
-
-export const HandlesConversationDetailServerErrorMobile = meta.story({
- name: 'Conversation Detail Server Error (Mobile)',
- globals: { viewport: { value: 'sm', isRotated: false } },
- parameters: HandlesConversationDetailServerError.input.parameters,
- play: () => I.waitExit(role('status')),
-})
-
-HandlesConversationDetailServerErrorMobile.test(
- '[mobile] shows error state when conversation detail request fails',
- async () => {
- await I.click(link(/Engineering/))
- await I.waitExit(role('status'))
-
- await I.scope(role('main'), async () => {
- await I.seeDetailError()
- })
- },
-)
-
-export const KeepsLoadingWhenConversationDetailNeverResolves = meta.story({
- name: 'Conversation Detail Loading State',
- play: () => I.waitExit(role('status')),
- parameters: {
- msw: {
- handlers: { conversationDetail: conversationDetail.loading },
- },
- },
-})
-
-KeepsLoadingWhenConversationDetailNeverResolves.test(
- 'shows message thread loading state while conversation detail is pending',
- async () => {
- await I.click(link(/Engineering/))
-
- const detail = await I.see(role('main'))
- await I.see(loc.messageThreadLoading.within(detail))
- await I.dontSee(loc.conversationNotFoundHeading.within(detail))
- },
-)
-
-export const KeepsLoadingWhenConversationDetailNeverResolvesMobile = meta.story({
- name: 'Conversation Detail Loading State (Mobile)',
- globals: { viewport: { value: 'sm', isRotated: false } },
- parameters: KeepsLoadingWhenConversationDetailNeverResolves.input.parameters,
- play: () => I.waitExit(role('status')),
-})
-
-KeepsLoadingWhenConversationDetailNeverResolvesMobile.test(
- '[mobile] shows message thread loading state while conversation detail is pending',
- async () => {
- await I.click(link(/Engineering/))
-
- const detail = await I.see(role('main'))
- await I.see(loc.messageThreadLoading.within(detail))
- await I.dontSee(loc.conversationNotFoundHeading.within(detail))
- },
-)
diff --git a/src/app/integration/Connections.detail.stories.tsx b/src/app/integration/Connections.detail.stories.tsx
new file mode 100644
index 0000000..44f37b8
--- /dev/null
+++ b/src/app/integration/Connections.detail.stories.tsx
@@ -0,0 +1,160 @@
+import preview from '#.storybook/preview'
+import { App } from '#app/App'
+import { connectionDetail } from '#entities/connection/mocks/handlers'
+import { connectionsActor as I, connectionsLoc as loc } from '#pages/connections/testing'
+import { button, heading, role, text } from '#shared/test'
+
+const meta = preview.meta({
+ title: 'Integration/Connections/Detail',
+ component: App,
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'connections/1',
+ },
+ loaders: [(ctx) => I.init(ctx)],
+})
+
+export default meta
+
+export const HandlesConnectionDetailServerError = meta.story({
+ name: 'Connection Detail Server Error',
+ play: () => I.waitExit(role('status')),
+ parameters: {
+ msw: {
+ handlers: { connectionDetail: connectionDetail.error },
+ },
+ },
+})
+
+HandlesConnectionDetailServerError.test(
+ 'shows error state when connection detail request fails',
+ async () => {
+ await I.scope(role('main'), async () => {
+ await I.seeDetailError()
+ })
+ },
+)
+
+HandlesConnectionDetailServerError.test(
+ 'keeps detail error state when retry also fails',
+ async () => {
+ await I.scope(role('main'), async () => {
+ await I.seeDetailError()
+ await I.retry()
+ await I.waitExit(role('status'))
+ await I.seeDetailError()
+ })
+ },
+)
+
+export const RecoversAfterConnectionDetailRetry = meta.story({
+ name: 'Connection Detail Retry Success',
+ play: () => I.waitExit(role('status')),
+ parameters: {
+ msw: {
+ handlers: { connectionDetail: connectionDetail.retrySucceeds() },
+ },
+ },
+})
+
+RecoversAfterConnectionDetailRetry.test(
+ 'loads connection detail after retry succeeds',
+ async () => {
+ await I.scope(role('main'), async () => {
+ await I.seeDetailError()
+ await I.retry()
+ await I.waitExit(role('status'))
+ await I.see(heading('Stripe API').wait())
+ })
+ },
+)
+
+export const HandlesConnectionDetailServerErrorMobile = meta.story({
+ name: 'Connection Detail Server Error (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ parameters: HandlesConnectionDetailServerError.input.parameters,
+ play: () => I.waitExit(role('status')),
+})
+
+HandlesConnectionDetailServerErrorMobile.test(
+ '[mobile] shows error state when connection detail request fails',
+ async () => {
+ await I.scope(role('main'), async () => {
+ await I.seeDetailError()
+ })
+ },
+)
+
+export const KeepsLoadingWhenConnectionDetailNeverResolves = meta.story({
+ name: 'Connection Detail Loading State',
+ parameters: {
+ msw: {
+ handlers: { connectionDetail: connectionDetail.loading },
+ },
+ },
+})
+
+KeepsLoadingWhenConnectionDetailNeverResolves.test(
+ 'shows detail loading state while connection detail is pending',
+ async () => {
+ const detail = await I.see(role('main'))
+ await I.see(loc.detailLoading.within(detail))
+ await I.dontSee(heading('Stripe API').within(detail))
+ await I.dontSee(text('Connection not found').within(detail))
+ },
+)
+
+export const KeepsLoadingWhenConnectionDetailNeverResolvesMobile = meta.story({
+ name: 'Connection Detail Loading State (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ parameters: KeepsLoadingWhenConnectionDetailNeverResolves.input.parameters,
+})
+
+KeepsLoadingWhenConnectionDetailNeverResolvesMobile.test(
+ '[mobile] shows detail loading state while connection detail is pending',
+ async () => {
+ const detail = await I.see(role('main'))
+ await I.see(loc.detailLoading.within(detail))
+ await I.dontSee(heading('Stripe API').within(detail))
+ await I.dontSee(text('Connection not found').within(detail))
+ },
+)
+
+export const TestConnectionButton = meta.story({
+ name: 'Test Connection Button',
+ play: () => I.waitExit(role('status')),
+})
+
+TestConnectionButton.test('clicking Test Connection shows success toast', async () => {
+ await I.see(button('Test connection'))
+ await I.click(button('Test connection'))
+ await I.seeTestConnectionToast()
+})
+
+export const ReconnectErrorConnection = meta.story({
+ name: 'Reconnect Error Status Connection',
+ parameters: { initialPath: 'connections/4' },
+ play: () => I.waitExit(role('status')),
+})
+
+ReconnectErrorConnection.test('error-status connection shows Reconnect button', async () => {
+ await I.scope(role('main'), async () => {
+ await I.see(button('Reconnect'))
+ })
+})
+
+ReconnectErrorConnection.test('clicking Reconnect shows success toast', async () => {
+ await I.click(button('Reconnect'))
+ await I.seeReconnectToast()
+})
+
+ReconnectErrorConnection.test('active connection does not show Reconnect button', async () => {
+ await I.goBack()
+ await I.see(role('list', 'Connections').wait())
+ await I.click(role('link', /Stripe API/))
+ await I.waitExit(role('status'))
+
+ await I.scope(role('main'), async () => {
+ await I.dontSee(button('Reconnect'))
+ })
+})
diff --git a/src/app/integration/Connections.direct-url.stories.tsx b/src/app/integration/Connections.direct-url.stories.tsx
new file mode 100644
index 0000000..08a105d
--- /dev/null
+++ b/src/app/integration/Connections.direct-url.stories.tsx
@@ -0,0 +1,27 @@
+import preview from '#.storybook/preview'
+import { App } from '#app/App'
+import { connectionsActor as I } from '#pages/connections/testing'
+import { role } from '#shared/test'
+
+const meta = preview.meta({
+ title: 'Integration/Connections/Direct URL',
+ component: App,
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'connections/missing-42',
+ },
+ loaders: [(ctx) => I.init(ctx)],
+})
+
+export default meta
+
+export const MissingConnection = meta.story({
+ name: 'Missing Connection',
+ play: () => I.waitExit(role('status')),
+})
+
+MissingConnection.test('shows not-found state for missing connection URL', async () => {
+ await I.scope(role('main'), async () => {
+ await I.seeConnectionNotFound('missing-42')
+ })
+})
diff --git a/src/app/integration/Connections.list-request.stories.tsx b/src/app/integration/Connections.list-request.stories.tsx
new file mode 100644
index 0000000..2f1d924
--- /dev/null
+++ b/src/app/integration/Connections.list-request.stories.tsx
@@ -0,0 +1,104 @@
+import preview from '#.storybook/preview'
+import { App } from '#app/App'
+import { connectionList } from '#entities/connection/mocks/handlers'
+import { connectionsActor as I } from '#pages/connections/testing'
+import { role, text } from '#shared/test'
+
+const meta = preview.meta({
+ title: 'Integration/Connections/List Request',
+ component: App,
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'connections',
+ },
+ loaders: [(ctx) => I.init(ctx)],
+})
+
+export default meta
+
+export const HandlesConnectionsLoadServerError = meta.story({
+ name: 'Connections Load Server Error',
+ play: () => I.waitExit(role('status')),
+ parameters: {
+ msw: {
+ handlers: { connectionList: connectionList.error },
+ },
+ },
+})
+
+HandlesConnectionsLoadServerError.test(
+ 'shows error state when connections request fails',
+ async () => {
+ await I.seeError()
+ await I.see(text("We couldn't load the connection list. Try again in a moment."))
+ },
+)
+
+HandlesConnectionsLoadServerError.test('keeps error state when retry also fails', async () => {
+ await I.seeError()
+ await I.retry()
+ await I.waitExit(role('status'))
+ await I.seeError()
+})
+
+export const RecoversAfterConnectionsLoadRetry = meta.story({
+ name: 'Connections Load Retry Success',
+ play: () => I.waitExit(role('status')),
+ parameters: {
+ msw: {
+ handlers: { connectionList: connectionList.retrySucceeds() },
+ },
+ },
+})
+
+RecoversAfterConnectionsLoadRetry.test('loads connection list after retry succeeds', async () => {
+ await I.seeError()
+ await I.retry()
+ await I.waitExit(role('status'))
+ await I.see(role('list', 'Connections').wait())
+ await I.seeConnectionList()
+})
+
+export const HandlesConnectionsLoadServerErrorMobile = meta.story({
+ name: 'Connections Load Server Error (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ parameters: HandlesConnectionsLoadServerError.input.parameters,
+ play: () => I.waitExit(role('status')),
+})
+
+HandlesConnectionsLoadServerErrorMobile.test(
+ '[mobile] shows error state when connections request fails',
+ async () => {
+ await I.seeError()
+ await I.see(text("We couldn't load the connection list. Try again in a moment."))
+ },
+)
+
+export const KeepsLoadingWhenConnectionsRequestNeverResolves = meta.story({
+ name: 'Connections Request Loading State',
+ parameters: {
+ msw: {
+ handlers: { connectionList: connectionList.loading },
+ },
+ },
+})
+
+KeepsLoadingWhenConnectionsRequestNeverResolves.test(
+ 'shows loading state while connections request is pending',
+ async () => {
+ await I.seeLoading()
+ },
+)
+
+export const KeepsLoadingWhenConnectionsRequestNeverResolvesMobile = meta.story({
+ name: 'Connections Request Loading State (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ parameters: KeepsLoadingWhenConnectionsRequestNeverResolves.input.parameters,
+})
+
+KeepsLoadingWhenConnectionsRequestNeverResolvesMobile.test(
+ '[mobile] shows loading state while connections request is pending',
+ async () => {
+ await I.seeLoading()
+ },
+)
diff --git a/src/app/integration/Connections.list.stories.tsx b/src/app/integration/Connections.list.stories.tsx
new file mode 100644
index 0000000..2026115
--- /dev/null
+++ b/src/app/integration/Connections.list.stories.tsx
@@ -0,0 +1,116 @@
+import { assertNoRouteLoaderAbortErrors } from '#.storybook/abortErrorGuard'
+import preview from '#.storybook/preview'
+import { App } from '#app/App'
+import { connectionsActor as I } from '#pages/connections/testing'
+import { heading, link, role, text } from '#shared/test'
+
+const meta = preview.meta({
+ title: 'Integration/Connections/List',
+ component: App,
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'connections',
+ },
+ loaders: [(ctx) => I.init(ctx)],
+})
+
+export default meta
+
+const assertExpectedDetailTeardown = async () => {
+ await assertNoRouteLoaderAbortErrors('connectionDetail')
+}
+
+export const Default = meta.story({
+ name: 'Default',
+ play: () => I.waitExit(role('status')),
+})
+
+Default.test('renders connection list with all connections', async () => {
+ await I.seeConnectionList()
+})
+
+Default.test('shows no-selection message when no connection selected', async () => {
+ await I.see(text('No connection selected'))
+})
+
+Default.test('shows connection detail when connection is clicked', async () => {
+ await I.click(link(/Stripe API/i))
+ await I.waitExit(role('status'))
+ await I.see(heading('Stripe API'))
+ await I.goBack()
+ await I.see(role('list', 'Connections').wait())
+ await assertExpectedDetailTeardown()
+})
+
+Default.test('shows all detail paragraphs in connection detail', async () => {
+ await I.click(link(/Stripe API/i))
+ await I.waitExit(role('status'))
+
+ await I.scope(role('main'), async () => {
+ await I.see(heading(/Stripe API/i))
+ await I.see(text(/Connected to Stripe API v2023-10-16/))
+ await I.see(text(/Webhook endpoint configured/))
+ await I.see(text(/Average response latency/))
+ await I.see(text(/Rate limit headroom/))
+ })
+
+ await I.goBack()
+ await I.see(role('list', 'Connections').wait())
+ await assertExpectedDetailTeardown()
+})
+
+Default.test('displays correct status badges for all statuses', async () => {
+ await I.seeStatusBadges()
+})
+
+Default.test('displays correct type badges for all types', async () => {
+ await I.seeTypeBadges()
+})
+
+export const DefaultMobile = meta.story({
+ name: 'Default (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ play: () => I.waitExit(role('status')),
+})
+
+DefaultMobile.test('[mobile] renders connection list with all connections', async () => {
+ await I.seeConnectionList()
+})
+
+DefaultMobile.test('[mobile] shows connection list when no connection is selected', async () => {
+ await I.seeConnectionList()
+})
+
+DefaultMobile.test('[mobile] shows connection detail when connection is clicked', async () => {
+ await I.click(link(/Stripe API/i))
+ await I.waitExit(role('status'))
+ await I.see(heading('Stripe API'))
+ await I.goBack()
+ await I.see(role('list', 'Connections').wait())
+ await assertExpectedDetailTeardown()
+})
+
+DefaultMobile.test('[mobile] shows all detail paragraphs in connection detail', async () => {
+ await I.click(link(/Stripe API/i))
+ await I.waitExit(role('status'))
+
+ await I.scope(role('main'), async () => {
+ await I.see(heading(/Stripe API/i))
+ await I.see(text(/Connected to Stripe API v2023-10-16/))
+ await I.see(text(/Webhook endpoint configured/))
+ await I.see(text(/Average response latency/))
+ await I.see(text(/Rate limit headroom/))
+ })
+
+ await I.goBack()
+ await I.see(role('list', 'Connections').wait())
+ await assertExpectedDetailTeardown()
+})
+
+DefaultMobile.test('[mobile] displays correct status badges for all statuses', async () => {
+ await I.seeStatusBadges()
+})
+
+DefaultMobile.test('[mobile] displays correct type badges for all types', async () => {
+ await I.seeTypeBadges()
+})
diff --git a/src/app/integration/Connections.navigation.stories.tsx b/src/app/integration/Connections.navigation.stories.tsx
new file mode 100644
index 0000000..ac570e5
--- /dev/null
+++ b/src/app/integration/Connections.navigation.stories.tsx
@@ -0,0 +1,55 @@
+import { assertNoRouteLoaderAbortErrors } from '#.storybook/abortErrorGuard'
+import preview from '#.storybook/preview'
+import { App } from '#app/App'
+import { connectionsActor as I } from '#pages/connections/testing'
+import { heading, link, role } from '#shared/test'
+
+const meta = preview.meta({
+ title: 'Integration/Connections/Navigation',
+ component: App,
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'connections',
+ },
+ loaders: [(ctx) => I.init(ctx)],
+})
+
+export default meta
+
+export const SwitchBetweenConnections = meta.story({
+ name: 'Switch Between Connections',
+ play: () => I.waitExit(role('status')),
+})
+
+SwitchBetweenConnections.test('can switch from one connection detail to another', async () => {
+ await I.click(link(/Stripe API/i))
+ await I.waitExit(role('status'))
+ await I.see(heading('Stripe API'))
+
+ await I.click(link(/Analytics DB/i))
+ await I.waitExit(role('status'))
+ await I.see(heading('Analytics DB'))
+})
+
+export const SwitchBetweenConnectionsMobile = meta.story({
+ name: 'Switch Between Connections (Mobile)',
+ globals: { viewport: { value: 'sm', isRotated: false } },
+ play: () => I.waitExit(role('status')),
+})
+
+SwitchBetweenConnectionsMobile.test(
+ '[mobile] can switch to another connection after navigating back',
+ async () => {
+ await I.click(link(/Stripe API/i))
+ await I.waitExit(role('status'))
+ await I.see(heading('Stripe API'))
+
+ await I.goBack()
+ await I.see(role('list', 'Connections').wait())
+ await assertNoRouteLoaderAbortErrors('connectionDetail')
+
+ await I.click(link(/Analytics DB/i))
+ await I.waitExit(role('status'))
+ await I.see(heading('Analytics DB'))
+ },
+)
diff --git a/src/app/integration/Connections.stories.tsx b/src/app/integration/Connections.stories.tsx
deleted file mode 100644
index a997b15..0000000
--- a/src/app/integration/Connections.stories.tsx
+++ /dev/null
@@ -1,383 +0,0 @@
-import preview from '#.storybook/preview'
-import { App } from '#app/App'
-import { connectionDetail, connectionList } from '#entities/connection/mocks/handlers'
-import { connectionsActor as I, connectionsLoc as loc } from '#pages/connections/testing'
-import { button, heading, link, role, text } from '#shared/test'
-
-const meta = preview.meta({
- title: 'Integration/Connections',
- component: App,
- parameters: { layout: 'fullscreen', initialPath: 'connections' },
- loaders: [(ctx) => I.init(ctx)],
-})
-
-export default meta
-
-export const Default = meta.story({
- name: 'Default',
- play: () => I.waitExit(role('status')),
-})
-
-Default.test('renders connection list with all connections', async () => {
- await I.seeConnectionList()
-})
-
-Default.test('shows no-selection message when no connection selected', async () => {
- await I.see(text('No connection selected'))
-})
-
-Default.test('shows connection detail when connection is clicked', async () => {
- await I.click(link(/Stripe API/i))
- await I.waitExit(role('status'))
- await I.see(heading('Stripe API'))
-})
-
-Default.test('shows all detail paragraphs in connection detail', async () => {
- await I.click(link(/Stripe API/i))
- await I.waitExit(role('status'))
-
- await I.scope(role('main'), async () => {
- await I.see(heading(/Stripe API/i))
- await I.see(text(/Connected to Stripe API v2023-10-16/))
- await I.see(text(/Webhook endpoint configured/))
- await I.see(text(/Average response latency/))
- await I.see(text(/Rate limit headroom/))
- })
-})
-
-Default.test('displays correct status badges for all statuses', async () => {
- await I.seeStatusBadges()
-})
-
-Default.test('displays correct type badges for all types', async () => {
- await I.seeTypeBadges()
-})
-
-Default.test('can select different connections', async () => {
- await I.click(link(/Stripe API/i))
- await I.waitExit(role('status'))
- await I.see(heading('Stripe API'))
-
- await I.click(link(/Analytics DB/i))
- await I.waitExit(role('status'))
- await I.see(heading('Analytics DB'))
-})
-
-export const DirectUrlNotFound = meta.story({
- name: 'Direct URL to Missing Connection',
- parameters: { initialPath: 'connections/missing-42' },
- play: () => I.waitExit(role('status')),
-})
-
-DirectUrlNotFound.test('shows not-found state for missing connection URL', async () => {
- await I.scope(role('main'), async () => {
- await I.seeConnectionNotFound('missing-42')
- })
-})
-
-export const DefaultMobile = meta.story({
- name: 'Default (Mobile)',
- globals: { viewport: { value: 'sm', isRotated: false } },
- play: () => I.waitExit(role('status')),
-})
-
-DefaultMobile.test('[mobile] renders connection list with all connections', async () => {
- await I.seeConnectionList()
-})
-
-DefaultMobile.test('[mobile] shows connection list when no connection is selected', async () => {
- await I.seeConnectionList()
-})
-
-DefaultMobile.test('[mobile] shows connection detail when connection is clicked', async () => {
- await I.click(link(/Stripe API/i))
- await I.waitExit(role('status'))
- await I.see(heading('Stripe API'))
-})
-
-DefaultMobile.test('[mobile] shows all detail paragraphs in connection detail', async () => {
- await I.click(link(/Stripe API/i))
- await I.waitExit(role('status'))
-
- await I.scope(role('main'), async () => {
- await I.see(heading(/Stripe API/i))
- await I.see(text(/Connected to Stripe API v2023-10-16/))
- await I.see(text(/Webhook endpoint configured/))
- await I.see(text(/Average response latency/))
- await I.see(text(/Rate limit headroom/))
- })
-})
-
-DefaultMobile.test('[mobile] displays correct status badges for all statuses', async () => {
- await I.seeStatusBadges()
-})
-
-DefaultMobile.test('[mobile] displays correct type badges for all types', async () => {
- await I.seeTypeBadges()
-})
-
-DefaultMobile.test('[mobile] can select different connections', async () => {
- await I.click(link(/Stripe API/i))
- await I.waitExit(role('status'))
- await I.see(heading('Stripe API'))
-
- await I.goBack()
-
- await I.click(link(/Analytics DB/i))
- await I.waitExit(role('status'))
- await I.see(heading('Analytics DB'))
-})
-
-export const HandlesConnectionsLoadServerError = meta.story({
- name: 'Connections Load Server Error',
- play: () => I.waitExit(role('status')),
- parameters: {
- msw: {
- handlers: { connectionList: connectionList.error },
- },
- },
-})
-
-HandlesConnectionsLoadServerError.test(
- 'shows error state when connections request fails',
- async () => {
- await I.seeError()
- await I.see(text("We couldn't load the connection list. Try again in a moment."))
- },
-)
-
-HandlesConnectionsLoadServerError.test('keeps error state when retry also fails', async () => {
- await I.seeError()
- await I.retry()
- await I.waitExit(role('status'))
- await I.seeError()
-})
-
-export const RecoversAfterConnectionsLoadRetry = meta.story({
- name: 'Connections Load Retry Success',
- play: () => I.waitExit(role('status')),
- parameters: {
- msw: {
- handlers: { connectionList: connectionList.retrySucceeds() },
- },
- },
-})
-
-RecoversAfterConnectionsLoadRetry.test('loads connection list after retry succeeds', async () => {
- await I.seeError()
- await I.retry()
- await I.waitExit(role('status'))
- await I.see(role('list', 'Connections').wait())
- await I.seeConnectionList()
-})
-
-export const HandlesConnectionsLoadServerErrorMobile = meta.story({
- name: 'Connections Load Server Error (Mobile)',
- globals: { viewport: { value: 'sm', isRotated: false } },
- parameters: HandlesConnectionsLoadServerError.input.parameters,
- play: () => I.waitExit(role('status')),
-})
-
-HandlesConnectionsLoadServerErrorMobile.test(
- '[mobile] shows error state when connections request fails',
- async () => {
- await I.seeError()
- await I.see(text("We couldn't load the connection list. Try again in a moment."))
- },
-)
-
-export const KeepsLoadingWhenConnectionsRequestNeverResolves = meta.story({
- name: 'Connections Request Loading State',
- parameters: {
- msw: {
- handlers: { connectionList: connectionList.loading },
- },
- },
-})
-
-KeepsLoadingWhenConnectionsRequestNeverResolves.test(
- 'shows loading state while connections request is pending',
- async () => {
- await I.seeLoading()
- },
-)
-
-export const KeepsLoadingWhenConnectionsRequestNeverResolvesMobile = meta.story({
- name: 'Connections Request Loading State (Mobile)',
- globals: { viewport: { value: 'sm', isRotated: false } },
- parameters: KeepsLoadingWhenConnectionsRequestNeverResolves.input.parameters,
-})
-
-KeepsLoadingWhenConnectionsRequestNeverResolvesMobile.test(
- '[mobile] shows loading state while connections request is pending',
- async () => {
- await I.seeLoading()
- },
-)
-
-export const HandlesConnectionDetailServerError = meta.story({
- name: 'Connection Detail Server Error',
- play: () => I.waitExit(role('status')),
- parameters: {
- msw: {
- handlers: { connectionDetail: connectionDetail.error },
- },
- },
-})
-
-HandlesConnectionDetailServerError.test(
- 'shows error state when connection detail request fails',
- async () => {
- await I.click(link(/Stripe API/i))
- await I.waitExit(role('status'))
-
- await I.scope(role('main'), async () => {
- await I.seeDetailError()
- })
- },
-)
-
-HandlesConnectionDetailServerError.test(
- 'keeps detail error state when retry also fails',
- async () => {
- await I.click(link(/Stripe API/i))
- await I.waitExit(role('status'))
-
- await I.scope(role('main'), async () => {
- await I.seeDetailError()
- await I.retry()
- await I.waitExit(role('status'))
- await I.seeDetailError()
- })
- },
-)
-
-export const RecoversAfterConnectionDetailRetry = meta.story({
- name: 'Connection Detail Retry Success',
- play: () => I.waitExit(role('status')),
- parameters: {
- msw: {
- handlers: { connectionDetail: connectionDetail.retrySucceeds() },
- },
- },
-})
-
-RecoversAfterConnectionDetailRetry.test(
- 'loads connection detail after retry succeeds',
- async () => {
- await I.click(link(/Stripe API/i))
- await I.waitExit(role('status'))
-
- await I.scope(role('main'), async () => {
- await I.seeDetailError()
- await I.retry()
- await I.waitExit(role('status'))
- await I.see(heading('Stripe API').wait())
- })
- },
-)
-
-export const HandlesConnectionDetailServerErrorMobile = meta.story({
- name: 'Connection Detail Server Error (Mobile)',
- globals: { viewport: { value: 'sm', isRotated: false } },
- parameters: HandlesConnectionDetailServerError.input.parameters,
- play: () => I.waitExit(role('status')),
-})
-
-HandlesConnectionDetailServerErrorMobile.test(
- '[mobile] shows error state when connection detail request fails',
- async () => {
- await I.click(link(/Stripe API/i))
- await I.waitExit(role('status'))
-
- await I.scope(role('main'), async () => {
- await I.seeDetailError()
- })
- },
-)
-
-export const KeepsLoadingWhenConnectionDetailNeverResolves = meta.story({
- name: 'Connection Detail Loading State',
- play: () => I.waitExit(role('status')),
- parameters: {
- msw: {
- handlers: { connectionDetail: connectionDetail.loading },
- },
- },
-})
-
-KeepsLoadingWhenConnectionDetailNeverResolves.test(
- 'shows detail loading state while connection detail is pending',
- async () => {
- await I.click(link(/Stripe API/i))
-
- const detail = await I.see(role('main'))
- await I.see(loc.detailLoading.within(detail))
- await I.dontSee(heading('Stripe API').within(detail))
- await I.dontSee(text('Connection not found').within(detail))
- },
-)
-
-export const KeepsLoadingWhenConnectionDetailNeverResolvesMobile = meta.story({
- name: 'Connection Detail Loading State (Mobile)',
- globals: { viewport: { value: 'sm', isRotated: false } },
- parameters: KeepsLoadingWhenConnectionDetailNeverResolves.input.parameters,
- play: () => I.waitExit(role('status')),
-})
-
-KeepsLoadingWhenConnectionDetailNeverResolvesMobile.test(
- '[mobile] shows detail loading state while connection detail is pending',
- async () => {
- await I.click(link(/Stripe API/i))
-
- const detail = await I.see(role('main'))
- await I.see(loc.detailLoading.within(detail))
- await I.dontSee(heading('Stripe API').within(detail))
- await I.dontSee(text('Connection not found').within(detail))
- },
-)
-
-export const TestConnectionButton = meta.story({
- name: 'Test Connection Button',
- play: () => I.waitExit(role('status')),
-})
-
-TestConnectionButton.test('clicking Test Connection shows success toast', async () => {
- await I.click(link(/Stripe API/i))
- await I.waitExit(role('status'))
-
- await I.see(button('Test connection'))
- await I.click(button('Test connection'))
- await I.seeTestConnectionToast()
-})
-
-export const ReconnectErrorConnection = meta.story({
- name: 'Reconnect Error Status Connection',
- play: () => I.waitExit(role('status')),
-})
-
-ReconnectErrorConnection.test('error-status connection shows Reconnect button', async () => {
- await I.click(link(/Auth0 SSO/i))
- await I.waitExit(role('status'))
-
- await I.scope(role('main'), async () => {
- await I.see(button('Reconnect'))
- })
-})
-
-ReconnectErrorConnection.test('clicking Reconnect shows success toast', async () => {
- await I.click(link(/Auth0 SSO/i))
- await I.waitExit(role('status'))
-
- await I.click(button('Reconnect'))
- await I.seeReconnectToast()
-})
-
-ReconnectErrorConnection.test('active connection does not show Reconnect button', async () => {
- await I.click(link(/Stripe API/i))
- await I.waitExit(role('status'))
-
- await I.scope(role('main'), async () => {
- await I.dontSee(button('Reconnect'))
- })
-})
diff --git a/src/app/integration/Dashboard.stories.tsx b/src/app/integration/Dashboard.stories.tsx
index 307df3e..da8657f 100644
--- a/src/app/integration/Dashboard.stories.tsx
+++ b/src/app/integration/Dashboard.stories.tsx
@@ -7,7 +7,10 @@ import { button, role, text } from '#shared/test'
const meta = preview.meta({
title: 'Integration/Dashboard',
component: App,
- parameters: { layout: 'fullscreen', initialPath: 'dashboard' },
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'dashboard',
+ },
loaders: [(ctx) => I.init(ctx)],
})
diff --git a/src/app/integration/Items.stories.tsx b/src/app/integration/Items.stories.tsx
index c9a6a0d..7149494 100644
--- a/src/app/integration/Items.stories.tsx
+++ b/src/app/integration/Items.stories.tsx
@@ -9,7 +9,10 @@ import { role, text } from '#shared/test'
const meta = preview.meta({
title: 'Integration/Items',
component: App,
- parameters: { layout: 'fullscreen', initialPath: 'items' },
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'items',
+ },
loaders: [(ctx) => I.init(ctx)],
})
@@ -278,7 +281,7 @@ FilteredToEmpty.test('updates visible items across filter states', async () => {
await I.applyCategoryFilter('Electronics')
await I.applyStockFilter('Out of Stock')
- expect(await I.hopeThat(() => I.seeItem('Wireless Headphones'))).toBe(false)
+ expect(await I.tryTo(() => I.seeItem('Wireless Headphones'))).toBe(false)
await I.see(text('No items match the current filters.'))
})
diff --git a/src/app/integration/Pricing.stories.tsx b/src/app/integration/Pricing.stories.tsx
index 1042767..fd79a2e 100644
--- a/src/app/integration/Pricing.stories.tsx
+++ b/src/app/integration/Pricing.stories.tsx
@@ -5,7 +5,10 @@ import { pricingActor as I, pricingLoc as loc } from '#pages/pricing/testing'
const meta = preview.meta({
title: 'Integration/Pricing',
component: App,
- parameters: { layout: 'fullscreen', initialPath: 'pricing' },
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'pricing',
+ },
loaders: [(ctx) => I.init(ctx)],
})
diff --git a/src/app/integration/Settings.stories.tsx b/src/app/integration/Settings.stories.tsx
index 0155572..cbca185 100644
--- a/src/app/integration/Settings.stories.tsx
+++ b/src/app/integration/Settings.stories.tsx
@@ -6,7 +6,10 @@ import { button, role, text } from '#shared/test'
const meta = preview.meta({
title: 'Integration/Settings',
component: App,
- parameters: { layout: 'fullscreen', initialPath: 'settings' },
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'settings',
+ },
loaders: [(ctx) => I.init(ctx)],
})
diff --git a/src/app/integration/SidebarFooter.stories.tsx b/src/app/integration/SidebarFooter.stories.tsx
index 00c954b..234f256 100644
--- a/src/app/integration/SidebarFooter.stories.tsx
+++ b/src/app/integration/SidebarFooter.stories.tsx
@@ -10,7 +10,10 @@ const I = createActor()
const meta = preview.meta({
title: 'Integration/Sidebar Footer',
component: App,
- parameters: { layout: 'fullscreen', initialPath: 'dashboard' },
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'dashboard',
+ },
loaders: [(ctx) => I.init(ctx)],
})
diff --git a/src/app/integration/Timeline.stories.tsx b/src/app/integration/Timeline.stories.tsx
index 64ffef1..77629ae 100644
--- a/src/app/integration/Timeline.stories.tsx
+++ b/src/app/integration/Timeline.stories.tsx
@@ -7,7 +7,10 @@ import { role, text } from '#shared/test'
const meta = preview.meta({
title: 'Integration/Timeline',
component: App,
- parameters: { layout: 'fullscreen', initialPath: 'timeline' },
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'timeline',
+ },
loaders: [(ctx) => I.init(ctx)],
})
diff --git a/src/app/integration/Timer.stories.tsx b/src/app/integration/Timer.stories.tsx
index 142b2f5..9a380f1 100644
--- a/src/app/integration/Timer.stories.tsx
+++ b/src/app/integration/Timer.stories.tsx
@@ -6,7 +6,10 @@ import { button, link, role, text } from '#shared/test'
const meta = preview.meta({
title: 'Integration/Timer',
component: App,
- parameters: { layout: 'fullscreen', initialPath: 'timer' },
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'timer',
+ },
loaders: [(ctx) => I.init(ctx)],
})
diff --git a/src/app/integration/Usage.stories.tsx b/src/app/integration/Usage.stories.tsx
index 1771b58..668613f 100644
--- a/src/app/integration/Usage.stories.tsx
+++ b/src/app/integration/Usage.stories.tsx
@@ -5,7 +5,10 @@ import { usageActor as I, usageLoc as loc } from '#pages/usage/testing'
const meta = preview.meta({
title: 'Integration/Usage',
component: App,
- parameters: { layout: 'fullscreen', initialPath: 'usage' },
+ parameters: {
+ layout: 'fullscreen',
+ initialPath: 'usage',
+ },
loaders: [(ctx) => I.init(ctx)],
})
diff --git a/src/entities/conversation/model/unreadCount.ts b/src/entities/conversation/model/unreadCount.ts
index f59342e..0e32f3b 100644
--- a/src/entities/conversation/model/unreadCount.ts
+++ b/src/entities/conversation/model/unreadCount.ts
@@ -1,13 +1,8 @@
-import { computed, withAsyncData, withConnectHook } from '@reatom/core'
+import { computed, withAsyncData } from '@reatom/core'
import { fetchConversationsUnreadCount } from '#entities/conversation/api/conversationsApi'
export const conversationUnreadCountAtom = computed(
() => fetchConversationsUnreadCount(),
'conversationUnreadCount',
-).extend(
- withAsyncData(),
- withConnectHook((target) => {
- if (!target.ready()) target.retry()
- }),
-)
+).extend(withAsyncData())
diff --git a/src/pages/chat/ui/ChatNavItem.tsx b/src/pages/chat/ui/ChatNavItem.tsx
index 264a00b..cf62313 100644
--- a/src/pages/chat/ui/ChatNavItem.tsx
+++ b/src/pages/chat/ui/ChatNavItem.tsx
@@ -9,10 +9,12 @@ import { SideNavButton, SideNavItemContent } from '#widgets/side-nav'
import { chatRoute } from '../model/routes'
export const ChatNavItem = reatomComponent(() => {
- const routeUnreadCount =
- chatRoute.loader
- .data()
- ?.reduce((totalUnread, conversation) => totalUnread + conversation.unread, 0) ?? null
+ const isChatMatched = chatRoute.match()
+ const routeUnreadCount = isChatMatched
+ ? (chatRoute.loader
+ .data()
+ ?.reduce((totalUnread, conversation) => totalUnread + conversation.unread, 0) ?? null)
+ : null
const unreadCount = routeUnreadCount ?? conversationUnreadCountAtom.data() ?? 0
const unreadBadge =
unreadCount > 0 ? (
diff --git a/src/pages/timer/testing.ts b/src/pages/timer/testing.ts
index c66c923..20150ae 100644
--- a/src/pages/timer/testing.ts
+++ b/src/pages/timer/testing.ts
@@ -3,7 +3,7 @@ import { button, createActor, heading, role, text } from '#shared/test'
export const timerLoc = {
heading: heading('Timer'),
display: (value: string | RegExp) => text(value),
- customInput: role('textbox'),
+ customInput: role('textbox', 'Custom duration'),
startButton: button('Start'),
pauseButton: button('Pause'),
resetButton: button('Reset'),
@@ -46,7 +46,6 @@ export const timerActor = createActor().extend((I) => ({
},
enterCustomDurationByBlur: async (value: string) => {
await I.fill(timerLoc.customInput, value)
- await I.blur(timerLoc.customInput)
},
enterCustomDurationByEnter: async (value: string) => {
await I.clear(timerLoc.customInput)
diff --git a/src/pages/timer/ui/TimerPage.tsx b/src/pages/timer/ui/TimerPage.tsx
index 94ca8d3..6b79dd4 100644
--- a/src/pages/timer/ui/TimerPage.tsx
+++ b/src/pages/timer/ui/TimerPage.tsx
@@ -63,6 +63,7 @@ export const TimerPage = reatomComponent(() => {
StoryContext): BaseActor {
await userEvent.clear(el)
})
},
- blur: async (locator: DefiniteLocator) => {
- const element = await elementFrom(locator)
- element.blur()
- },
press: async (key: string) => {
await ctx().userEvent.keyboard(key)
},
@@ -310,7 +306,6 @@ export interface BaseActor {
fill(locator: DefiniteLocator, value: string): Promise
selectOption(locator: DefiniteLocator, value: string | RegExp): Promise
clear(locator: DefiniteLocator): Promise
- blur(locator: DefiniteLocator): Promise
press(key: string): Promise
scope(locator: DefiniteLocator, callback: () => MaybePromise): Promise
within(locator: DefiniteLocator, callback: () => MaybePromise): Promise
diff --git a/src/shared/test/reatomRouteContracts.test.ts b/src/shared/test/reatomRouteContracts.test.ts
new file mode 100644
index 0000000..ff4e045
--- /dev/null
+++ b/src/shared/test/reatomRouteContracts.test.ts
@@ -0,0 +1,69 @@
+import {
+ context,
+ isSomeLoaderPending,
+ noop,
+ reatomRoute,
+ sleep,
+ urlAtom,
+ withCallHook,
+ wrap,
+} from '@reatom/core'
+import { expect, test } from 'vite-plus/test'
+
+const resetRuntime = () => {
+ context.reset()
+ urlAtom.routes = {}
+ urlAtom.sync.set(() => noop)
+ urlAtom.set(new URL('https://example.test/'))
+}
+
+const createTrackedRoute = (path: string, name: string, events: string[]) => {
+ const route = reatomRoute(
+ {
+ path,
+ async loader() {
+ events.push(`run:${name}`)
+ await wrap(sleep(1))
+ return name
+ },
+ },
+ name,
+ )
+
+ route.loader.onReject.extend(
+ withCallHook(({ error }) => {
+ if (error?.name === 'AbortError') {
+ events.push(`reject:${name}:${error.message}`)
+ }
+ }),
+ )
+
+ return route
+}
+
+test('urlAtom.go does not touch non-matching route loaders', async () => {
+ resetRuntime()
+ const events: string[] = []
+
+ createTrackedRoute('a', 'a', events)
+ createTrackedRoute('b', 'b', events)
+
+ urlAtom.go('/a')
+ await wrap(sleep(5))
+
+ expect(events).toEqual(['run:a'])
+})
+
+test('isSomeLoaderPending does not evaluate non-matching route loaders', async () => {
+ resetRuntime()
+ const events: string[] = []
+
+ createTrackedRoute('a', 'a', events)
+ createTrackedRoute('b', 'b', events)
+
+ const unsubscribe = isSomeLoaderPending.subscribe()
+ await wrap(sleep(5))
+ unsubscribe()
+
+ expect(events).toEqual([])
+})
diff --git a/vitest.config.ts b/vitest.config.ts
index 3df609d..3d9a896 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -32,6 +32,17 @@ export default defineConfig({
thresholds: coverageThresholds,
},
projects: [
+ {
+ extends: true,
+ test: {
+ name: 'unit',
+ include: ['src/**/*.test.{ts,tsx}'],
+ exclude: ['src/**/*.stories.tsx'],
+ testTimeout: testTimeout,
+ hookTimeout: testTimeout,
+ environment: 'node',
+ },
+ },
{
extends: true,
plugins: [