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', () => {