This commit is contained in:
153
apps/www/__tests__/BrandPersonasCard.test.tsx
Normal file
153
apps/www/__tests__/BrandPersonasCard.test.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
import BrandPersonasCard from '../app/_components/BrandPersonasCard';
|
||||
import { personas, type PersonaSlug } from '../app/_data/personas';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('BrandPersonasCard — landing variant', () => {
|
||||
it('renders 13 persona tiles + 3 filler tiles by default', () => {
|
||||
render(<BrandPersonasCard />);
|
||||
|
||||
const tiles = screen.getAllByTestId(/^persona-tile-/);
|
||||
expect(tiles).toHaveLength(13);
|
||||
|
||||
const fillers = screen.getAllByTestId('brand-personas-filler');
|
||||
expect(fillers).toHaveLength(3);
|
||||
|
||||
// Fillers must be marked aria-hidden to stay out of AT navigation.
|
||||
for (const filler of fillers) {
|
||||
expect(filler).toHaveAttribute('aria-hidden', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
it('renders correct title + role copy for all 13 personas (verbatim from locked decision)', () => {
|
||||
render(<BrandPersonasCard />);
|
||||
|
||||
for (const persona of personas) {
|
||||
const tile = screen.getByTestId(`persona-tile-${persona.slug}`);
|
||||
const scope = within(tile);
|
||||
expect(scope.getByText(persona.title)).toBeInTheDocument();
|
||||
expect(scope.getByText(persona.role)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('fires onPersonaClick with the correct slug when a tile is clicked', () => {
|
||||
const handler = vi.fn<(slug: PersonaSlug) => void>();
|
||||
render(<BrandPersonasCard onPersonaClick={handler} />);
|
||||
|
||||
const hunterTile = screen.getByTestId('persona-tile-hunter');
|
||||
const button = within(hunterTile).getByRole('button', {
|
||||
name: /Waggle The Hunter bee mascot/i,
|
||||
});
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith('hunter');
|
||||
});
|
||||
|
||||
it('fires onTileHover with the correct slug on mouseenter', () => {
|
||||
const handler = vi.fn<(slug: PersonaSlug) => void>();
|
||||
render(<BrandPersonasCard onTileHover={handler} />);
|
||||
|
||||
const connectorTile = screen.getByTestId('persona-tile-connector');
|
||||
fireEvent.mouseEnter(connectorTile);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith('connector');
|
||||
});
|
||||
|
||||
it('renders the cta slot below the grid', () => {
|
||||
render(
|
||||
<BrandPersonasCard
|
||||
cta={<a href="https://example.test/cta">Meet the hive</a>}
|
||||
/>,
|
||||
);
|
||||
|
||||
const ctaSlot = screen.getByTestId('brand-personas-cta');
|
||||
expect(ctaSlot).toBeInTheDocument();
|
||||
expect(
|
||||
within(ctaSlot).getByRole('link', { name: /Meet the hive/i }),
|
||||
).toHaveAttribute('href', 'https://example.test/cta');
|
||||
});
|
||||
|
||||
it('hides filler tiles when showFillerTiles={false}', () => {
|
||||
render(<BrandPersonasCard showFillerTiles={false} />);
|
||||
|
||||
expect(screen.queryAllByTestId('brand-personas-filler')).toHaveLength(0);
|
||||
// Persona tiles remain intact — feature is filler-scoped.
|
||||
expect(screen.getAllByTestId(/^persona-tile-/)).toHaveLength(13);
|
||||
});
|
||||
|
||||
it('flips to a placeholder when a persona asset fails to load', () => {
|
||||
render(<BrandPersonasCard />);
|
||||
|
||||
const writerTile = screen.getByTestId('persona-tile-writer');
|
||||
const img = within(writerTile).getByRole('img', {
|
||||
name: /Waggle The Writer bee mascot/i,
|
||||
});
|
||||
|
||||
// Simulate the 404/onerror path that fires when an asset is missing
|
||||
// (e.g., mid-regen Task #24 state before the new PNG lands).
|
||||
fireEvent.error(img);
|
||||
|
||||
expect(writerTile).toHaveAttribute('data-placeholder', 'true');
|
||||
expect(
|
||||
screen.getByTestId('persona-placeholder-writer'),
|
||||
).toBeInTheDocument();
|
||||
// Role copy must stay readable even while the image degrades.
|
||||
expect(
|
||||
within(writerTile).getByText(
|
||||
'Shapes the story the memory wants to tell.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports keyboard activation (Enter + Space) when onPersonaClick is provided', () => {
|
||||
const handler = vi.fn<(slug: PersonaSlug) => void>();
|
||||
render(<BrandPersonasCard onPersonaClick={handler} />);
|
||||
|
||||
const teamTile = screen.getByTestId('persona-tile-team');
|
||||
const button = within(teamTile).getByRole('button');
|
||||
|
||||
fireEvent.keyDown(button, { key: 'Enter' });
|
||||
fireEvent.keyDown(button, { key: ' ' });
|
||||
// A non-activation key must not fire the handler.
|
||||
fireEvent.keyDown(button, { key: 'Tab' });
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
expect(handler).toHaveBeenNthCalledWith(1, 'team');
|
||||
expect(handler).toHaveBeenNthCalledWith(2, 'team');
|
||||
});
|
||||
|
||||
it('honours custom heading + subtitle overrides', () => {
|
||||
render(
|
||||
<BrandPersonasCard
|
||||
heading="Internal reference"
|
||||
subtitle="Brand canon for the team."
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Internal reference' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Brand canon for the team.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BrandPersonasCard — compact variant', () => {
|
||||
it('renders the compact stub without errors and exposes the data-variant hook', () => {
|
||||
render(<BrandPersonasCard variant="compact" />);
|
||||
|
||||
const stub = screen.getByTestId('brand-personas-card-compact');
|
||||
expect(stub).toBeInTheDocument();
|
||||
expect(stub).toHaveAttribute('data-variant', 'compact');
|
||||
// Landing DOM must not leak into compact path.
|
||||
expect(
|
||||
screen.queryByTestId('brand-personas-card'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
51
apps/www/__tests__/Pricing.test.tsx
Normal file
51
apps/www/__tests__/Pricing.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import Pricing from '../app/_components/Pricing';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.pushState({}, '', '/');
|
||||
});
|
||||
|
||||
describe('Pricing', () => {
|
||||
it('uses the canonical GET checkout URL for Team checkout', () => {
|
||||
render(<Pricing />);
|
||||
|
||||
const monthlyCta = screen.getByRole('link', {
|
||||
name: 'landing.pricing.tiers.teams.cta',
|
||||
});
|
||||
expect(monthlyCta).toHaveAttribute(
|
||||
'href',
|
||||
'/api/stripe/checkout?tier=teams&billing=monthly',
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /landing\.pricing\.toggle\.annual/ }),
|
||||
);
|
||||
|
||||
const annualCta = screen.getByRole('link', {
|
||||
name: 'landing.pricing.tiers.teams.cta',
|
||||
});
|
||||
expect(annualCta).toHaveAttribute(
|
||||
'href',
|
||||
'/api/stripe/checkout?tier=teams&billing=annual',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a retry path after a cancelled checkout', async () => {
|
||||
window.history.pushState({}, '', '/?checkout=cancelled#pricing');
|
||||
|
||||
render(<Pricing />);
|
||||
|
||||
const notice = await screen.findByRole('status');
|
||||
expect(notice).toHaveTextContent('landing.pricing.notices.cancelled');
|
||||
expect(
|
||||
screen.getByRole('link', {
|
||||
name: 'landing.pricing.notices.retry',
|
||||
}),
|
||||
).toHaveAttribute(
|
||||
'href',
|
||||
'/api/stripe/checkout?tier=teams&billing=monthly',
|
||||
);
|
||||
});
|
||||
});
|
||||
25
apps/www/__tests__/deployment-workflow.test.ts
Normal file
25
apps/www/__tests__/deployment-workflow.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const workflow = () =>
|
||||
readFileSync(
|
||||
join(process.cwd(), '..', '..', '.github', 'workflows', 'deploy-www.yml'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('public-site deployment workflow', () => {
|
||||
it('deploys the dynamic Next app with Vercel instead of GitHub Pages static artifacts', () => {
|
||||
const source = workflow();
|
||||
|
||||
expect(source).toContain('vercel pull');
|
||||
expect(source).toContain('vercel build');
|
||||
expect(source).toContain('vercel deploy --prebuilt --prod');
|
||||
expect(source).toContain('VERCEL_TOKEN');
|
||||
expect(source).toContain('VERCEL_ORG_ID');
|
||||
expect(source).toContain('VERCEL_PROJECT_ID');
|
||||
expect(source).not.toContain('upload-pages-artifact');
|
||||
expect(source).not.toContain('deploy-pages');
|
||||
expect(source).not.toContain('apps/www/dist');
|
||||
});
|
||||
});
|
||||
58
apps/www/__tests__/download-path.test.tsx
Normal file
58
apps/www/__tests__/download-path.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import DownloadCTA from '../app/_components/DownloadCTA';
|
||||
import DownloadPage from '../app/download/page';
|
||||
import { detectOSFromUserAgent } from '../app/_lib/os-detection';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('download path', () => {
|
||||
it('routes public download CTAs to the controlled download page', () => {
|
||||
render(<DownloadCTA section="hero" />);
|
||||
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', '/download');
|
||||
expect(screen.getByRole('link')).not.toHaveAttribute('target');
|
||||
});
|
||||
|
||||
it('does not send visitors directly to an empty GitHub Releases page', () => {
|
||||
render(<DownloadPage />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Download Waggle' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Windows and macOS installers are being prepared for the signed public release.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'View source on GitHub' }),
|
||||
).toHaveAttribute('href', 'https://github.com/marolinik/waggle-os');
|
||||
expect(
|
||||
screen.queryByRole('link', { name: /releases/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not label mobile visitors as desktop operating systems', () => {
|
||||
expect(
|
||||
detectOSFromUserAgent(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
detectOSFromUserAgent(
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/126.0.0.0 Mobile Safari/537.36',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
detectOSFromUserAgent(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 Safari/605.1.15',
|
||||
),
|
||||
).toBe('macOS');
|
||||
expect(
|
||||
detectOSFromUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36',
|
||||
),
|
||||
).toBe('Windows');
|
||||
});
|
||||
});
|
||||
39
apps/www/__tests__/layout.test.tsx
Normal file
39
apps/www/__tests__/layout.test.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
|
||||
vi.mock('next/font/google', () => ({
|
||||
Hanken_Grotesk: () => ({ variable: '__font_hanken' }),
|
||||
JetBrains_Mono: () => ({ variable: '__font_mono' }),
|
||||
}));
|
||||
|
||||
vi.mock('next-intl/server', () => ({
|
||||
getLocale: vi.fn(async () => 'en'),
|
||||
getMessages: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('@clerk/nextjs', () => ({
|
||||
ClerkProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
vi.mock('@clerk/themes', () => ({
|
||||
dark: {},
|
||||
}));
|
||||
|
||||
import RootLayout from '../app/layout';
|
||||
|
||||
describe('RootLayout', () => {
|
||||
it('keeps the progressive-enhancement js class as an intentional hydration mismatch', async () => {
|
||||
const tree = (await RootLayout({
|
||||
children: <main>content</main>,
|
||||
})) as ReactElement<{
|
||||
className: string;
|
||||
suppressHydrationWarning?: boolean;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
expect(tree.type).toBe('html');
|
||||
expect(tree.props.className).toBe('scroll-smooth __font_hanken __font_mono');
|
||||
expect(tree.props.className).not.toContain(' js');
|
||||
expect(tree.props.suppressHydrationWarning).toBe(true);
|
||||
});
|
||||
});
|
||||
35
apps/www/__tests__/legal-copy.test.ts
Normal file
35
apps/www/__tests__/legal-copy.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const legalFiles = [
|
||||
'terms/page.tsx',
|
||||
'privacy/page.tsx',
|
||||
'cookies/page.tsx',
|
||||
'eu-ai-act/page.tsx',
|
||||
] as const;
|
||||
|
||||
const launchBlockingCopy = [
|
||||
/Day-0 placeholder text/i,
|
||||
/\[Day-0 launch date\]/i,
|
||||
/to be filled before public launch/i,
|
||||
/Pro or Teams/i,
|
||||
/\[to be designated/i,
|
||||
] as const;
|
||||
|
||||
describe('legal pages', () => {
|
||||
it('do not expose launch placeholders or retired tier copy', () => {
|
||||
for (const file of legalFiles) {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), 'app', '(legal)', file),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
for (const pattern of launchBlockingCopy) {
|
||||
expect(source, `${file} should not match ${pattern}`).not.toMatch(
|
||||
pattern,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
13
apps/www/__tests__/middleware.test.ts
Normal file
13
apps/www/__tests__/middleware.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { config } from '../middleware';
|
||||
|
||||
describe('www Clerk middleware boundary', () => {
|
||||
it('protects only identity and server-owned flows', () => {
|
||||
expect(config.matcher).toEqual([
|
||||
'/account(.*)',
|
||||
'/sign-in(.*)',
|
||||
'/sign-up(.*)',
|
||||
'/(api|trpc)(.*)',
|
||||
]);
|
||||
});
|
||||
});
|
||||
45
apps/www/__tests__/setup.ts
Normal file
45
apps/www/__tests__/setup.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* jsdom does not implement `matchMedia`; our component queries
|
||||
* `prefers-reduced-motion` via plain CSS, but consumer code using matchMedia
|
||||
* (existing `Pricing` component paths, etc.) still needs a shim during tests.
|
||||
*/
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock next-intl for unit tests. The `t()` function returns the namespaced
|
||||
* key (with ICU `{var}` placeholders interpolated), so tests can assert on
|
||||
* deterministic strings without needing a real `messages/en.json` round-trip.
|
||||
*
|
||||
* BrandPersonasCard tests check persona-data text (from `_data/personas.ts`,
|
||||
* not i18n) and passed-in prop overrides — never the default heading/
|
||||
* subtitle from i18n — so this mock is safe.
|
||||
*/
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (namespace?: string) => {
|
||||
return (key: string, params?: Record<string, string | number>) => {
|
||||
const fullKey = namespace ? `${namespace}.${key}` : key;
|
||||
if (!params) return fullKey;
|
||||
return Object.entries(params).reduce(
|
||||
(acc, [k, v]) => acc.replace(`{${k}}`, String(v)),
|
||||
fullKey,
|
||||
);
|
||||
};
|
||||
},
|
||||
NextIntlClientProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
93
apps/www/__tests__/stripe-checkout-route.test.ts
Normal file
93
apps/www/__tests__/stripe-checkout-route.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const clerkMocks = vi.hoisted(() => ({
|
||||
auth: vi.fn(),
|
||||
getUser: vi.fn(),
|
||||
updateUserMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
const stripeMocks = vi.hoisted(() => ({
|
||||
checkoutSessionsCreate: vi.fn(),
|
||||
customersCreate: vi.fn(),
|
||||
pricesList: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@clerk/nextjs/server', () => ({
|
||||
auth: clerkMocks.auth,
|
||||
clerkClient: vi.fn(async () => ({
|
||||
users: {
|
||||
getUser: clerkMocks.getUser,
|
||||
updateUserMetadata: clerkMocks.updateUserMetadata,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('stripe', () => ({
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
checkout: {
|
||||
sessions: {
|
||||
create: stripeMocks.checkoutSessionsCreate,
|
||||
},
|
||||
},
|
||||
customers: {
|
||||
create: stripeMocks.customersCreate,
|
||||
},
|
||||
prices: {
|
||||
list: stripeMocks.pricesList,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from '../app/api/stripe/checkout/route';
|
||||
|
||||
describe('/api/stripe/checkout', () => {
|
||||
beforeEach(() => {
|
||||
process.env.STRIPE_SECRET_KEY = 'sk_test_checkout';
|
||||
process.env.STRIPE_PRICE_TEAMS_ANNUAL = 'price_team_annual';
|
||||
clerkMocks.auth.mockResolvedValue({ userId: 'user_123' });
|
||||
clerkMocks.getUser.mockResolvedValue({
|
||||
publicMetadata: { stripeCustomerId: 'cus_existing' },
|
||||
primaryEmailAddress: { emailAddress: 'team@example.test' },
|
||||
});
|
||||
stripeMocks.checkoutSessionsCreate.mockResolvedValue({
|
||||
url: 'https://checkout.stripe.test/session',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.STRIPE_SECRET_KEY;
|
||||
delete process.env.STRIPE_PRICE_TEAMS_ANNUAL;
|
||||
});
|
||||
|
||||
it('sends cancelled checkouts back to the homepage pricing section', async () => {
|
||||
const res = await GET(
|
||||
new Request(
|
||||
'https://waggle.example/api/stripe/checkout?tier=teams&billing=annual',
|
||||
),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(303);
|
||||
expect(stripeMocks.checkoutSessionsCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cancel_url: 'https://waggle.example/?checkout=cancelled#pricing',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends signed-out users to sign-in and preserves the checkout target', async () => {
|
||||
clerkMocks.auth.mockResolvedValueOnce({ userId: null });
|
||||
|
||||
const res = await GET(
|
||||
new Request(
|
||||
'https://waggle.example/api/stripe/checkout?tier=teams&billing=monthly',
|
||||
),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(303);
|
||||
expect(res.headers.get('location')).toBe(
|
||||
'https://waggle.example/sign-in?redirect_url=%2Fapi%2Fstripe%2Fcheckout%3Ftier%3Dteams%26billing%3Dmonthly',
|
||||
);
|
||||
expect(stripeMocks.checkoutSessionsCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user