Skip to content

Commit 5d343d4

Browse files
fix(dashboard): /app SSG shell + IncidentsPage + Stat series prop + ResourceDetailPage gap placeholder (#54)
Closes P3 persona-walkthrough bugs: 1. /app 404 — prerender now emits dist/app/index.html so GH Pages serves the auth-gated entry path with HTTP 200; React Router takes over in-browser 2. Hardcoded sparkline data in Stat — replaced synth series with required series?: number[] prop; all 6 callers pass undefined pending real wiring 3. /incidents dead link — new IncidentsPage with empty-state by default; fetches /api/v1/incidents and treats 404 as [] 4. ResourceDetailPage status="gap" placeholder — removed; replaced with Coming-in-Pro mailto CTA 422 passed | 3 skipped (+5 new tests). 21 Playwright tests pass (+3 new). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8c62867 commit 5d343d4

9 files changed

Lines changed: 621 additions & 17 deletions

e2e/persona-bugs.spec.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/* persona-bugs.spec.ts — coverage for the four P3 (Pro founder) persona
2+
* regressions found on 2026-05-13.
3+
*
4+
* Bug 1 — instanode.dev/app returned 404. The SPA shell now ships at
5+
* dist/app/index.html so GH Pages serves the entry path with
6+
* HTTP 200 and the React Router takes over. We verify the route
7+
* loads cleanly in dev (no 404, response is 200, AuthGate is
8+
* reachable).
9+
*
10+
* Bug 3 — /incidents link from /status was dead. The new IncidentsPage
11+
* renders an empty-state ("No active incidents") when the
12+
* /api/v1/incidents endpoint is missing.
13+
*/
14+
15+
import { expect, test, type Route } from '@playwright/test'
16+
17+
test.describe('P3 persona bug fixes', () => {
18+
// Bug 1: /app must not 404.
19+
test('GET /app responds 200 and mounts the SPA (no 404)', async ({ page }) => {
20+
// The Vite dev server returns the SPA shell for any route via the
21+
// middleware mode. A pre-fix regression would either 404 here (in
22+
// production this surfaced via GH Pages) or render an empty page
23+
// with no <div id="root"> mount.
24+
const response = await page.goto('/app')
25+
expect(response).not.toBeNull()
26+
// dev server may return 200 directly; in any case the SPA must mount.
27+
expect(response!.status()).toBeLessThan(400)
28+
29+
// SPA root mount node must be present (the build pipeline now writes
30+
// this template to dist/app/index.html, so GH Pages serves it too).
31+
await expect(page.locator('#root')).toBeAttached()
32+
33+
// With no auth token, AuthGate redirects to /login — that's fine,
34+
// it means the SPA booted and reacted to the route. The opposite
35+
// failure mode (regression) is a static 404 page with no router.
36+
await page.waitForURL(/\/(login|app).*$/)
37+
})
38+
39+
// Bug 3: /incidents must render (empty-state by default).
40+
test('GET /incidents renders "No active incidents" empty state', async ({ page }) => {
41+
// The page calls fetchIncidents() → GET /api/v1/incidents on mount.
42+
// That endpoint doesn't exist on the agent API yet; in MOCKED mode
43+
// (default in this repo) page.route lets us stub it. We return 404
44+
// because that's what the live API does today, and the page should
45+
// tolerate it and render the empty state.
46+
await page.route('**/api/v1/incidents', (route: Route) =>
47+
route.fulfill({
48+
status: 404,
49+
contentType: 'application/json',
50+
body: JSON.stringify({ ok: false, error: 'not_found' }),
51+
}),
52+
)
53+
54+
const response = await page.goto('/incidents')
55+
expect(response).not.toBeNull()
56+
expect(response!.status()).toBeLessThan(400)
57+
58+
// The page renders the empty-state — the literal "No active incidents"
59+
// headline plus a mailto for reporting.
60+
await expect(page.getByTestId('incidents-empty')).toBeVisible()
61+
await expect(
62+
page.getByTestId('incidents-empty').getByText(/no active incidents/i),
63+
).toBeVisible()
64+
// Report-an-incident link in the empty state.
65+
await expect(
66+
page.getByRole('link', { name: /report an incident/i }).first(),
67+
).toBeVisible()
68+
})
69+
70+
// Sanity check: /status footer link still points at /incidents.
71+
test('GET /status footer "Incident log" link targets /incidents', async ({ page }) => {
72+
await page.goto('/status')
73+
const link = page.getByRole('link', { name: /incident log/i })
74+
await expect(link).toBeVisible()
75+
expect(await link.getAttribute('href')).toBe('/incidents')
76+
})
77+
})

scripts/prerender.mjs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ async function loadRoutes() {
6565
'/pricing',
6666
'/for-agents',
6767
'/status',
68+
'/incidents',
6869
'/docs',
6970
'/blog',
7071
'/use-cases',
@@ -133,6 +134,33 @@ async function main() {
133134
written++
134135
}
135136

137+
// Step 4.5: emit empty SPA shells for every authenticated /app/* entry.
138+
//
139+
// P3 founder persona caught instanode.dev/app returning 404 on 2026-05-13.
140+
// Cause: the GH Pages SPA pattern (cp dist/index.html dist/404.html)
141+
// serves 404.html with HTTP status 404 for any path that doesn't have a
142+
// matching file under dist/. For SEO routes (/pricing, /blog/...) we
143+
// emit dist/<route>/index.html via SSG above, which fixes the status to
144+
// 200 for crawlers. The /app/* tree is intentionally NOT SSG'd (it needs
145+
// localStorage, would crash in renderToString, and has no SEO value
146+
// anyway because it's auth-gated). But the entry path /app must still
147+
// return 200 so a customer who types instanode.dev/app into a browser
148+
// sees the SPA boot, not a 404 page.
149+
//
150+
// Fix: write the unrendered SPA template (the same one Vite emits as
151+
// dist/index.html before our prerender step overwrites it for the
152+
// homepage) to dist/app/index.html. The file has an empty
153+
// <div id="root"></div>; React Router mounts in the browser, sees URL
154+
// /app, runs through AuthGate, and either renders the overview or
155+
// redirects to /login. Status 200 either way.
156+
//
157+
// The dist/index.html template variable above still has the unrendered
158+
// SPA shell because we read it BEFORE the homepage was overwritten.
159+
const appShellPath = resolve(DIST, 'app', 'index.html')
160+
await mkdir(dirname(appShellPath), { recursive: true })
161+
await writeFile(appShellPath, template, 'utf-8')
162+
console.log('prerender: wrote dist/app/index.html SPA shell (P3 fix)')
163+
136164
// Step 5: copy /llms.txt from the content repo to dist root. The
137165
// llms.txt convention (https://llmstxt.org) expects the file at the
138166
// domain root.

src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ const ForAgentsPage = lazy(() =>
3737
const StatusPage = lazy(() =>
3838
import('./pages/StatusPage').then((m) => ({ default: m.StatusPage })),
3939
)
40+
const IncidentsPage = lazy(() =>
41+
import('./pages/IncidentsPage').then((m) => ({ default: m.IncidentsPage })),
42+
)
4043
const BlogPage = lazy(() =>
4144
import('./pages/BlogPage').then((m) => ({ default: m.BlogPage })),
4245
)
@@ -187,6 +190,7 @@ export function AppRoutes() {
187190
<Route path="/pricing" element={<PricingPage />} />
188191
<Route path="/for-agents" element={<ForAgentsPage />} />
189192
<Route path="/status" element={<StatusPage />} />
193+
<Route path="/incidents" element={<IncidentsPage />} />
190194
<Route path="/blog" element={<BlogPage />} />
191195
<Route path="/blog/:slug" element={<BlogPostPage />} />
192196
<Route path="/docs" element={<DocsPage />} />

src/entry-server.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { MarketingPage } from './pages/MarketingPage'
3737
import { PricingPage } from './pages/PricingPage'
3838
import { ForAgentsPage } from './pages/ForAgentsPage'
3939
import { StatusPage } from './pages/StatusPage'
40+
import { IncidentsPage } from './pages/IncidentsPage'
4041
import { BlogPage } from './pages/BlogPage'
4142
import { BlogPostPage } from './pages/BlogPostPage'
4243
import { DocsPage } from './pages/DocsPage'
@@ -54,6 +55,7 @@ function SSRRoutes() {
5455
<Route path="/pricing" element={<PricingPage />} />
5556
<Route path="/for-agents" element={<ForAgentsPage />} />
5657
<Route path="/status" element={<StatusPage />} />
58+
<Route path="/incidents" element={<IncidentsPage />} />
5759
<Route path="/blog" element={<BlogPage />} />
5860
<Route path="/blog/:slug" element={<BlogPostPage />} />
5961
<Route path="/docs" element={<DocsPage />} />

0 commit comments

Comments
 (0)