From 13bdd52ec2497f25a6664a9449b501a9059544c6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 29 Aug 2026 14:37:03 -0700 Subject: [PATCH] test(website): give the suite an nx target and repair what rotted in the dark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/website had 36 spec files and no `test` target, so nothing ever ran them — not locally by convention, not in CI. Three files had drifted red and nobody could have known. Wiring: - `test` target on apps/website (@nx/vitest:test, existing vite.config.mts) - `npx nx test website` in the Website CI job, renamed to 'Website — lint / test / build' along with its require_scoped label so the aggregation keeps matching Repairs: - PostCard: asserted the raw ISO date, but the card renders through formatCardDate ('May 17'). Fixed by deriving from that same formatter — a literal would rot again on 1 Jan, since the year is omitted only for same-year posts. - Differentiator: a row was renamed 'MIT + self-hosted' -> 'Open adapters + self-hosted'. Added a length assertion so deleting a row from both the component and the list can't stay green. - ThanksPage: every assertion ran against `
`. It is an async Server Component taking `searchParams: Promise<...>`, so rendering it as sync JSX yields nothing — this never worked, it isn't drift. Now awaits the component. Also fixed a stale link ('Installation docs' -> 'Installation & licensing', /docs/licensing) and added coverage for the Stripe session-id guard that keeps an arbitrary query value out of the portal URL. Verified: 347/347 across 36 files. The nx target exits 1 on a failing test and 0 when green (checked directly — a target that always passes would be worse than none). Both repaired assertions mutation-tested: removing the date from PostCard and loosening the session-id regex each fail. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 5 +-- apps/website/project.json | 6 ++++ apps/website/src/app/thanks/page.spec.tsx | 35 ++++++++++++++----- .../src/components/blog/PostCard.spec.tsx | 7 ++-- .../landing/Differentiator.spec.tsx | 5 ++- 5 files changed, 45 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc25ca9fc..6a2b1c3d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,7 @@ jobs: run: node scripts/check-dx-coverage.mjs website: - name: Website — lint / build + name: Website — lint / test / build needs: ci-scope if: github.event_name == 'push' || needs.ci-scope.outputs.website == 'true' runs-on: ubuntu-latest @@ -114,6 +114,7 @@ jobs: cache: npm - run: npm ci - run: npx nx lint website + - run: npx nx test website - run: npm run generate-api-docs - name: Commit generated API docs to same-repo PR if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository @@ -543,7 +544,7 @@ jobs: require_always "CI scope" "$RESULT_CI_SCOPE" require_scoped "library" "Library — lint / test / build" "$RESULT_LIBRARY" "$SCOPE_LIBRARY" - require_scoped "website" "Website — lint / build" "$RESULT_WEBSITE" "$SCOPE_WEBSITE" + require_scoped "website" "Website — lint / test / build" "$RESULT_WEBSITE" "$SCOPE_WEBSITE" require_scoped "cockpit" "Cockpit — build / test" "$RESULT_COCKPIT" "$SCOPE_COCKPIT" require_scoped "cockpit_examples" "Cockpit — build all examples" "$RESULT_COCKPIT_EXAMPLES" "$SCOPE_COCKPIT_EXAMPLES" require_scoped "cockpit_smoke" "Cockpit — representative capability smoke" "$RESULT_COCKPIT_SMOKE" "$SCOPE_COCKPIT_SMOKE" diff --git a/apps/website/project.json b/apps/website/project.json index 5f2e49997..2ca57e579 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -63,6 +63,12 @@ "{options.outputFile}" ] }, + "test": { + "executor": "@nx/vitest:test", + "options": { + "configFile": "apps/website/vite.config.mts" + } + }, "e2e": { "executor": "nx:run-commands", "options": { diff --git a/apps/website/src/app/thanks/page.spec.tsx b/apps/website/src/app/thanks/page.spec.tsx index 466f94a9e..4b350dae4 100644 --- a/apps/website/src/app/thanks/page.spec.tsx +++ b/apps/website/src/app/thanks/page.spec.tsx @@ -20,21 +20,40 @@ vi.mock('../../components/ui/Button', () => ({ })); describe('ThanksPage', () => { - it('renders the payment-received heading', () => { - render(); + // ThanksPage is an async Server Component taking `searchParams: Promise<...>`. + // Rendering it as JSX synchronously yields an empty DOM — which is why every + // assertion in this file failed against `
`. Await the + // component and render the element it resolves to. + const renderPage = async (searchParams: { session_id?: string } = {}) => + render(await ThanksPage({ searchParams: Promise.resolve(searchParams) })); + + it('renders the payment-received heading', async () => { + await renderPage(); expect(screen.getByRole('heading', { level: 1, name: 'Thanks for your purchase.' })).toBeTruthy(); }); - it('mentions provideChat() activation', () => { - render(); + it('mentions provideChat() activation', async () => { + await renderPage(); expect(screen.getByText(/provideChat\(\)/)).toBeTruthy(); }); - it('links to installation docs and contact', () => { - render(); - expect(screen.getByRole('link', { name: 'Installation docs' }).getAttribute('href')) - .toBe('/docs/chat/getting-started/installation'); + it('links to licensing docs and contact', async () => { + await renderPage(); + expect(screen.getByRole('link', { name: 'Installation & licensing' }).getAttribute('href')) + .toBe('/docs/licensing'); expect(screen.getByRole('link', { name: 'Contact support' }).getAttribute('href')) .toBe('/contact'); }); + + it('offers the billing portal only for a well-formed Stripe session id', async () => { + const { unmount } = await renderPage({ session_id: 'cs_test_abc123' }); + expect(screen.getByRole('link', { name: 'Manage subscription' }).getAttribute('href')) + .toBe('/api/portal/session?session_id=cs_test_abc123'); + unmount(); + + // A malformed id must not produce a portal link — this is the guard that + // keeps an arbitrary query value out of the portal URL. + await renderPage({ session_id: 'not-a-session' }); + expect(screen.queryByRole('link', { name: 'Manage subscription' })).toBeNull(); + }); }); diff --git a/apps/website/src/components/blog/PostCard.spec.tsx b/apps/website/src/components/blog/PostCard.spec.tsx index a7429c080..7bc76341f 100644 --- a/apps/website/src/components/blog/PostCard.spec.tsx +++ b/apps/website/src/components/blog/PostCard.spec.tsx @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render, screen } from '@testing-library/react'; import { PostCard } from './PostCard'; -import type { Post } from '../../lib/blog'; +import { formatCardDate, type Post } from '../../lib/blog'; const post: Post = { slug: 'streaming-chat', @@ -22,7 +22,10 @@ describe('PostCard', () => { it('renders title, date, and tag chips', () => { render(); expect(screen.getByText('Build a streaming chat UI')).toBeTruthy(); - expect(screen.getByText('2026-05-17')).toBeTruthy(); + // Derive through the same formatter the card uses. `formatCardDate` omits + // the year for same-year posts, so a literal ('May 17') would silently rot + // the moment the calendar year rolls over. + expect(screen.getByText(new RegExp(formatCardDate(post.frontmatter.date)))).toBeTruthy(); expect(screen.getByText('tutorial')).toBeTruthy(); expect(screen.getByText('streaming')).toBeTruthy(); }); diff --git a/apps/website/src/components/landing/Differentiator.spec.tsx b/apps/website/src/components/landing/Differentiator.spec.tsx index cf11d27bb..1c1e9dbc9 100644 --- a/apps/website/src/components/landing/Differentiator.spec.tsx +++ b/apps/website/src/components/landing/Differentiator.spec.tsx @@ -32,7 +32,7 @@ const EXPECTED_NEEDS = [ 'Backend portability', 'Angular-native', 'Observability hooks', - 'MIT + self-hosted', + 'Open adapters + self-hosted', ]; describe('Differentiator', () => { @@ -55,6 +55,9 @@ describe('Differentiator', () => { for (const need of EXPECTED_NEEDS) { expect(screen.getByText(need)).toBeTruthy(); } + // Guards silent row loss: without this, deleting a row from the component + // AND its line here would leave the suite green on a shrunken table. + expect(EXPECTED_NEEDS).toHaveLength(10); }); it('renders the @threadplane/render primitive for the generative UI row', () => {