Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .config/mise/conf.d/tasks-test.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Binary file added .storybook/abortErrorGuard.ts
Binary file not shown.
29 changes: 24 additions & 5 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 <reatomContext.Provider value={frame}>{children}</reatomContext.Provider>
}

Expand Down Expand Up @@ -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<string, unknown>)['__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)}`,
)
}
}
},
})

Expand Down
6 changes: 5 additions & 1 deletion .storybook/setupStorybookUrl.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Expand Down
8 changes: 4 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
112 changes: 112 additions & 0 deletions src/app/integration/Articles.detail.stories.tsx
Original file line number Diff line number Diff line change
@@ -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)
},
)
49 changes: 49 additions & 0 deletions src/app/integration/Articles.direct-url.stories.tsx
Original file line number Diff line number Diff line change
@@ -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')
})
101 changes: 101 additions & 0 deletions src/app/integration/Articles.list-request.stories.tsx
Original file line number Diff line number Diff line change
@@ -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()
},
)
Loading
Loading