Skip to content

Commit bc7778d

Browse files
feat(maintenance): customer-facing scheduled-maintenance banner + modal (#221)
* feat(maintenance): customer-facing scheduled-maintenance banner + modal The prod cluster (api.instanode.dev) is intentionally paused for scheduled maintenance, but the SPA still loads from GitHub Pages — so visitors see the page render while every API call fails, which reads as a broken product. Add a customer-facing notice that makes the downtime read as intentional: - a sticky top banner on every route (marketing + app + login), and - a one-time dismissible modal on /app* + /login* (where a customer would otherwise hit confusing API errors first), reinforced with a clear icon. Copy is calm + honest ("scheduled maintenance", "temporarily unavailable", "your data is safe", "back shortly") and styled with the existing design tokens (amber warning family + auth-card/modal shapes) so it looks intentional, not like a raw browser alert. The whole thing is gated behind the build-time flag VITE_MAINTENANCE_MODE: unset/'0' renders null (zero markup — the default in CI, tests, and local dev); '1' renders the banner + modal. deploy-pages.yml sets it to '1' on the publish build only (PR builds + the build-and-test/playwright/coverage/ lighthouse jobs never set it, so existing tests are unaffected). The notice is rendered into the prerendered HTML via entry-server.tsx so the banner copy is present on first byte of every static page. To turn OFF on resume: set VITE_MAINTENANCE_MODE to '0' / remove it in deploy-pages.yml and re-run the deploy (or revert this PR). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): keep prod-targeting auth-contract spec out of the mocked PR gate The required `playwright` job (ci.yml) runs the default playwright.config.ts mocked suite with VITE_NO_PROXY=1 — every page.route() mock intercepts, so nothing should touch the network. But the default testMatch glob ('**/*.spec.ts') also swept in e2e/auth-contract.spec.ts, which makes UNCONDITIONAL real fetches to PROD (api.instanode.dev) to assert the AUTH-004 CORS envelope. That made the required gate silently depend on prod being reachable: with the prod cluster paused, the fetch times out and the mocked job fails for a reason unrelated to the PR (it was green before only because prod happened to be up). Add auth-contract.spec.ts to the default config's testIgnore. The spec keeps full coverage via its dedicated playwright.auth-contract.config.ts + auth-contract-e2e.yml workflow, which run it against prod (and are allowed to go red while prod is down — they are not a required check). Mocked suite verified locally: 55 passed / 1 skipped / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8e552e9 commit bc7778d

7 files changed

Lines changed: 540 additions & 1 deletion

File tree

.github/workflows/deploy-pages.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,27 @@ jobs:
4444
# init (fail-open), so PR forks / unconfigured envs still build cleanly.
4545
VITE_NEWRELIC_LICENSE_KEY: ${{ secrets.VITE_NEWRELIC_LICENSE_KEY }}
4646
VITE_NEWRELIC_APP_ID: ${{ secrets.VITE_NEWRELIC_APP_ID }}
47+
# ──────────────────────────────────────────────────────────────
48+
# SCHEDULED-MAINTENANCE TOGGLE (src/components/MaintenanceNotice.tsx).
49+
#
50+
# '1' → the published Pages build renders the customer-facing
51+
# maintenance banner (every route) + a one-time dismissible
52+
# modal on /app* + /login*. This is ON because the prod
53+
# cluster (api.instanode.dev) is intentionally paused, so the
54+
# SPA loads but every API call fails — the banner explains the
55+
# downtime is scheduled and the data is safe.
56+
#
57+
# Gated to PUBLISH events only (push to main / manual dispatch) so a
58+
# PR's `build` check produces a byte-identical, banner-OFF artifact —
59+
# it never publishes (the deploy job below is PR-gated) and runs no
60+
# tests, so this has zero effect on the required CI checks. The
61+
# separate build-and-test / playwright / coverage / lighthouse jobs
62+
# never set this var, so they build with the notice OFF.
63+
#
64+
# ▶ TO TURN THE BANNER OFF ON RESUME: set this to '0' (or delete the
65+
# line) and re-run this deploy — or revert the PR that added it.
66+
# The component renders null when the flag is unset/'0'.
67+
VITE_MAINTENANCE_MODE: ${{ github.event_name != 'pull_request' && '1' || '0' }}
4768
# NOTE: do NOT `cp dist/index.html dist/404.html` here. prerender.mjs
4869
# already writes dist/404.html as the bare SPA shell so every /app/*
4970
# bookmark / shared link / magic-link callback rehydrates the React

playwright.config.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,18 @@ export default defineConfig({
2121
// self-skip anyway, but keeping them out of the default config means the
2222
// per-PR gate never boots a browser for a spec that always skips here, and
2323
// the two suites stay cleanly separated.
24-
testIgnore: ['live-*.spec.ts'],
24+
//
25+
// auth-contract.spec.ts is ALSO excluded here: it makes UNCONDITIONAL real
26+
// fetches to PROD (api.instanode.dev) to assert the AUTH-004 CORS envelope,
27+
// so it does NOT belong in this mocked, same-origin (VITE_NO_PROXY=1) gate —
28+
// it has its own dedicated config (playwright.auth-contract.config.ts) and
29+
// workflow (auth-contract-e2e.yml) that run it against prod. Leaving it in
30+
// the default glob made the required `playwright` job depend on prod being
31+
// reachable: when the prod cluster is down/paused the fetch times out and
32+
// the mocked gate fails for a reason that has nothing to do with the PR.
33+
// The prod-targeting assertion still runs (and is allowed to go red while
34+
// prod is down) in its own non-required workflow.
35+
testIgnore: ['live-*.spec.ts', 'auth-contract.spec.ts'],
2536
fullyParallel: true,
2637
forbidOnly: !!process.env.CI,
2738
retries: process.env.CI ? 2 : 0,

src/App.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation, useParams } from '
99
// sibling of <AppRoutes>. Renders null — no markup contribution.
1010
import { RouteTracker } from './components/RouteTracker'
1111

12+
// MaintenanceNotice — customer-facing scheduled-maintenance notice. Sits
13+
// inside <BrowserRouter> (it reads window.location to decide whether the
14+
// dismissible modal applies on /app* + /login*) and OUTSIDE <AppRoutes> so
15+
// the sticky banner persists across every route change. The whole thing is
16+
// gated behind the build-time VITE_MAINTENANCE_MODE flag: when unset/'0' it
17+
// renders null and contributes zero markup (the default in CI + locally). It
18+
// is only '1' on the published GitHub Pages build (deploy-pages.yml).
19+
import { MaintenanceNotice } from './components/MaintenanceNotice'
20+
1221
// Homepage — eagerly imported. It's the cold-load path and the most-visited
1322
// public surface, so it stays in the main entry chunk.
1423
import { MarketingPage } from './pages/MarketingPage'
@@ -430,6 +439,11 @@ export function App() {
430439
during a lazy-chunk fetch (an unmount would skip the
431440
setPageViewName for that navigation). */}
432441
<RouteTracker />
442+
{/* MaintenanceNotice renders the sticky banner on every route (and a
443+
one-time modal on /app* + /login*) when VITE_MAINTENANCE_MODE='1';
444+
otherwise it returns null and adds no markup. Mounted above
445+
AppRoutes so the banner sits at the top of the document flow. */}
446+
<MaintenanceNotice />
433447
<AppRoutes />
434448
</BrowserRouter>
435449
)
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/* MaintenanceNotice.test.tsx — full coverage for the scheduled-maintenance
2+
* notice. The component is flag-gated (VITE_MAINTENANCE_MODE), so the tests
3+
* drive the ON branch via the `enabled` prop and the per-route modal logic
4+
* via the `pathname` prop — no need to stub import.meta.env or mount a
5+
* router. The env-read helper (isMaintenanceEnabled) is covered separately.
6+
*
7+
* Coverage targets (every line of the new component):
8+
* - enabled=false → renders nothing (the default everywhere except Pages)
9+
* - enabled=true → sticky banner with the headline + body copy
10+
* - modal shows on /app + /app/* + /login* and NOT on marketing routes
11+
* - dismiss via button, overlay click, and Escape key — all close it and
12+
* persist the dismissal in sessionStorage
13+
* - a persisted dismissal keeps the modal closed on remount
14+
* - isMaintenanceEnabled() reads the env flag
15+
* - modalAppliesTo() route predicate
16+
* - sessionStorage-throws fail-open paths
17+
*/
18+
19+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
20+
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
21+
import {
22+
MaintenanceNotice,
23+
isMaintenanceEnabled,
24+
modalAppliesTo,
25+
MAINTENANCE_HEADLINE,
26+
MAINTENANCE_BODY,
27+
} from './MaintenanceNotice'
28+
29+
beforeEach(() => {
30+
window.sessionStorage.clear()
31+
})
32+
33+
afterEach(() => {
34+
cleanup()
35+
vi.restoreAllMocks()
36+
})
37+
38+
describe('MaintenanceNotice — flag gating', () => {
39+
it('renders nothing when disabled', () => {
40+
const { container } = render(<MaintenanceNotice enabled={false} pathname="/app" />)
41+
expect(container.firstChild).toBeNull()
42+
expect(screen.queryByTestId('maintenance-banner')).toBeNull()
43+
expect(screen.queryByTestId('maintenance-modal')).toBeNull()
44+
})
45+
46+
it('renders nothing when called with no props and the env flag is off (default test env)', () => {
47+
// In the unit-test env VITE_MAINTENANCE_MODE is unset, so the default
48+
// (enabled omitted → isMaintenanceEnabled()) is false. This covers the
49+
// env-read default-argument branch.
50+
const { container } = render(<MaintenanceNotice />)
51+
expect(container.firstChild).toBeNull()
52+
})
53+
})
54+
55+
describe('MaintenanceNotice — sticky banner', () => {
56+
it('shows the banner with headline + body when enabled, on any route', () => {
57+
render(<MaintenanceNotice enabled pathname="/" />)
58+
const banner = screen.getByTestId('maintenance-banner')
59+
expect(banner).toBeTruthy()
60+
expect(banner.getAttribute('role')).toBe('status')
61+
expect(banner.textContent).toContain(MAINTENANCE_HEADLINE)
62+
expect(banner.textContent).toContain('Your data is safe')
63+
})
64+
65+
it('does NOT show the modal on a marketing route', () => {
66+
render(<MaintenanceNotice enabled pathname="/pricing" />)
67+
expect(screen.getByTestId('maintenance-banner')).toBeTruthy()
68+
expect(screen.queryByTestId('maintenance-modal')).toBeNull()
69+
})
70+
71+
it('resolves the current path from window.location when pathname is omitted', () => {
72+
// No `pathname` prop → resolvePathname() reads window.location.pathname.
73+
// jsdom defaults to "/", a marketing path, so the banner shows but the
74+
// modal does not. This covers the resolvePathname window branch.
75+
render(<MaintenanceNotice enabled />)
76+
expect(screen.getByTestId('maintenance-banner')).toBeTruthy()
77+
expect(screen.queryByTestId('maintenance-modal')).toBeNull()
78+
})
79+
})
80+
81+
describe('MaintenanceNotice — modal on /app + /login', () => {
82+
it('shows the modal on /app', () => {
83+
render(<MaintenanceNotice enabled pathname="/app" />)
84+
const modal = screen.getByTestId('maintenance-modal')
85+
expect(modal).toBeTruthy()
86+
expect(modal.getAttribute('aria-modal')).toBe('true')
87+
expect(modal.textContent).toContain(MAINTENANCE_HEADLINE)
88+
})
89+
90+
it('shows the modal on a nested /app/* route', () => {
91+
render(<MaintenanceNotice enabled pathname="/app/resources" />)
92+
expect(screen.getByTestId('maintenance-modal')).toBeTruthy()
93+
})
94+
95+
it('shows the modal on /login', () => {
96+
render(<MaintenanceNotice enabled pathname="/login?next=%2Fapp" />)
97+
expect(screen.getByTestId('maintenance-modal')).toBeTruthy()
98+
})
99+
100+
it('closes the modal and persists the dismissal when the button is clicked', () => {
101+
render(<MaintenanceNotice enabled pathname="/app" />)
102+
expect(screen.getByTestId('maintenance-modal')).toBeTruthy()
103+
fireEvent.click(screen.getByTestId('maintenance-modal-dismiss'))
104+
expect(screen.queryByTestId('maintenance-modal')).toBeNull()
105+
expect(window.sessionStorage.getItem('instanode.maintenanceModalDismissed')).toBe('1')
106+
// Banner persists after dismiss.
107+
expect(screen.getByTestId('maintenance-banner')).toBeTruthy()
108+
})
109+
110+
it('closes the modal on overlay (backdrop) click', () => {
111+
render(<MaintenanceNotice enabled pathname="/app" />)
112+
const overlay = screen.getByTestId('maintenance-modal')
113+
fireEvent.click(overlay)
114+
expect(screen.queryByTestId('maintenance-modal')).toBeNull()
115+
})
116+
117+
it('does NOT close when clicking inside the dialog card (event target is a child)', () => {
118+
render(<MaintenanceNotice enabled pathname="/app" />)
119+
// Clicking the headline inside the card must not bubble-dismiss.
120+
fireEvent.click(screen.getByText(MAINTENANCE_HEADLINE, { selector: 'h2' }))
121+
expect(screen.getByTestId('maintenance-modal')).toBeTruthy()
122+
})
123+
124+
it('closes the modal on Escape and ignores other keys', () => {
125+
render(<MaintenanceNotice enabled pathname="/app" />)
126+
// A non-Escape key is a no-op (covers the `if (e.key === 'Escape')` false branch).
127+
fireEvent.keyDown(document, { key: 'a' })
128+
expect(screen.getByTestId('maintenance-modal')).toBeTruthy()
129+
fireEvent.keyDown(document, { key: 'Escape' })
130+
expect(screen.queryByTestId('maintenance-modal')).toBeNull()
131+
expect(window.sessionStorage.getItem('instanode.maintenanceModalDismissed')).toBe('1')
132+
})
133+
134+
it('keeps the modal closed on a fresh mount once dismissed this session', () => {
135+
window.sessionStorage.setItem('instanode.maintenanceModalDismissed', '1')
136+
render(<MaintenanceNotice enabled pathname="/app" />)
137+
expect(screen.queryByTestId('maintenance-modal')).toBeNull()
138+
// Banner still shows.
139+
expect(screen.getByTestId('maintenance-banner')).toBeTruthy()
140+
})
141+
})
142+
143+
describe('MaintenanceNotice — sessionStorage failure is non-fatal', () => {
144+
// jsdom's sessionStorage is an exotic Storage object whose methods can't be
145+
// replaced via vi.spyOn (the spy is silently ignored). To exercise the
146+
// fail-open catch branches we swap the whole window.sessionStorage for a
147+
// throwing stub via Object.defineProperty, then restore it after.
148+
function withThrowingSessionStorage(stub: Partial<Storage>, fn: () => void) {
149+
const original = Object.getOwnPropertyDescriptor(window, 'sessionStorage')
150+
Object.defineProperty(window, 'sessionStorage', {
151+
value: stub,
152+
configurable: true,
153+
writable: true,
154+
})
155+
try {
156+
fn()
157+
} finally {
158+
if (original) Object.defineProperty(window, 'sessionStorage', original)
159+
}
160+
}
161+
162+
it('shows the modal when sessionStorage.getItem throws (fail-open on read)', () => {
163+
withThrowingSessionStorage(
164+
{
165+
getItem() {
166+
throw new Error('blocked')
167+
},
168+
setItem() {},
169+
},
170+
() => {
171+
render(<MaintenanceNotice enabled pathname="/app" />)
172+
expect(screen.getByTestId('maintenance-modal')).toBeTruthy()
173+
},
174+
)
175+
})
176+
177+
it('still closes the modal when sessionStorage.setItem throws (fail-open on write)', () => {
178+
withThrowingSessionStorage(
179+
{
180+
getItem() {
181+
return null
182+
},
183+
setItem() {
184+
throw new Error('blocked')
185+
},
186+
},
187+
() => {
188+
render(<MaintenanceNotice enabled pathname="/app" />)
189+
fireEvent.click(screen.getByTestId('maintenance-modal-dismiss'))
190+
expect(screen.queryByTestId('maintenance-modal')).toBeNull()
191+
},
192+
)
193+
})
194+
})
195+
196+
describe('MaintenanceNotice — helpers', () => {
197+
it('isMaintenanceEnabled reflects the (off) env flag in the test env', () => {
198+
expect(isMaintenanceEnabled()).toBe(false)
199+
})
200+
201+
it('modalAppliesTo matches only /app* and /login*', () => {
202+
expect(modalAppliesTo('/app')).toBe(true)
203+
expect(modalAppliesTo('/app/billing')).toBe(true)
204+
expect(modalAppliesTo('/login')).toBe(true)
205+
expect(modalAppliesTo('/login/callback')).toBe(true)
206+
expect(modalAppliesTo('/')).toBe(false)
207+
expect(modalAppliesTo('/pricing')).toBe(false)
208+
expect(modalAppliesTo('/docs')).toBe(false)
209+
})
210+
211+
it('exposes stable copy constants used by both surfaces', () => {
212+
expect(MAINTENANCE_HEADLINE).toBe('Scheduled maintenance')
213+
expect(MAINTENANCE_BODY).toContain('temporarily unavailable')
214+
expect(MAINTENANCE_BODY).toContain('back shortly')
215+
})
216+
})

0 commit comments

Comments
 (0)