diff --git a/src/lib/sseStream.test.ts b/src/lib/sseStream.test.ts new file mode 100644 index 0000000..5776c9c --- /dev/null +++ b/src/lib/sseStream.test.ts @@ -0,0 +1,200 @@ +/* sseStream.test.ts — coverage for the SSE-over-fetch consumer. + * + * Tests the parser by stubbing global.fetch with a ReadableStream of + * encoded UTF-8 chunks, then asserting each onLine + onError + onClose + * branch in turn. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { streamSSE, SSEStreamError } from './sseStream' +import { setToken, clearToken } from '../api' + +function makeStream(chunks: string[]): ReadableStream { + const enc = new TextEncoder() + let i = 0 + return new ReadableStream({ + pull(controller) { + if (i >= chunks.length) { + controller.close() + return + } + controller.enqueue(enc.encode(chunks[i++])) + }, + }) +} + +function flush(): Promise { + return new Promise((r) => setTimeout(r, 0)) +} + +describe('SSEStreamError', () => { + it('carries the HTTP status', () => { + const e = new SSEStreamError(404) + expect(e.status).toBe(404) + expect(e.name).toBe('SSEStreamError') + expect(e.message).toBe('HTTP 404') + }) +}) + +describe('streamSSE', () => { + beforeEach(() => { + clearToken() + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('emits onLine for each `data: ` SSE line and calls onClose at end', async () => { + const body = makeStream(['data: hello\n', 'data: world\n']) + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, body }) + ;(globalThis as any).fetch = fetchMock + + const lines: string[] = [] + const closed = new Promise((resolve) => { + streamSSE('/test', { + onLine: (l) => lines.push(l), + onClose: resolve, + }) + }) + await closed + expect(lines).toEqual(['hello', 'world']) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('strips only ONE space after `data:` (spec-compliant)', async () => { + const body = makeStream(['data:no-space\n', 'data: two-spaces\n']) + ;(globalThis as any).fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, body }) + + const lines: string[] = [] + await new Promise((resolve) => { + streamSSE('/x', { onLine: (l) => lines.push(l), onClose: resolve }) + }) + expect(lines).toEqual(['no-space', ' two-spaces']) + }) + + it('ignores non-data lines (event:, id:, blank)', async () => { + const body = makeStream([ + 'event: ping\n', + ': comment\n', + '\n', + 'data: keeper\n', + ]) + ;(globalThis as any).fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, body }) + + const lines: string[] = [] + await new Promise((resolve) => { + streamSSE('/x', { onLine: (l) => lines.push(l), onClose: resolve }) + }) + expect(lines).toEqual(['keeper']) + }) + + it('on non-OK response, calls onError with SSEStreamError + onClose', async () => { + ;(globalThis as any).fetch = vi.fn().mockResolvedValue({ ok: false, status: 503, body: null }) + + let err: unknown = null + let closed = false + await new Promise((resolve) => { + streamSSE('/bad', { + onLine: () => {}, + onError: (e) => { err = e }, + onClose: () => { closed = true; resolve() }, + }) + }) + expect(err).toBeInstanceOf(SSEStreamError) + expect((err as SSEStreamError).status).toBe(503) + expect(closed).toBe(true) + }) + + it('on 401 mid-open, still surfaces SSEStreamError(401)', async () => { + ;(globalThis as any).fetch = vi.fn().mockResolvedValue({ ok: false, status: 401, body: null }) + + let err: unknown = null + await new Promise((resolve) => { + streamSSE('/auth', { + onLine: () => {}, + onError: (e) => { err = e }, + onClose: resolve, + }) + }) + expect((err as SSEStreamError).status).toBe(401) + }) + + it('on thrown fetch (e.g. network error), calls onError + onClose', async () => { + ;(globalThis as any).fetch = vi.fn().mockRejectedValue(new Error('network down')) + + let err: unknown = null + let closed = false + await new Promise((resolve) => { + streamSSE('/x', { + onLine: () => {}, + onError: (e) => { err = e }, + onClose: () => { closed = true; resolve() }, + }) + }) + expect((err as Error).message).toBe('network down') + expect(closed).toBe(true) + }) + + it('suppresses AbortError so the caller does not see spurious errors', async () => { + const ab = new Error('abort') + ab.name = 'AbortError' + ;(globalThis as any).fetch = vi.fn().mockRejectedValue(ab) + + const errors: unknown[] = [] + let closed = false + await new Promise((resolve) => { + streamSSE('/x', { + onLine: () => {}, + onError: (e) => errors.push(e), + onClose: () => { closed = true; resolve() }, + }) + }) + expect(errors).toEqual([]) + expect(closed).toBe(true) + }) + + it('attaches Authorization header when a token is set', async () => { + setToken('test-bearer-123') + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, body: makeStream([]) }) + ;(globalThis as any).fetch = fetchMock + + await new Promise((resolve) => { + streamSSE('/x', { onLine: () => {}, onClose: resolve }) + }) + const opts = fetchMock.mock.calls[0][1] + expect(opts.headers.Authorization).toBe('Bearer test-bearer-123') + expect(opts.headers.Accept).toBe('text/event-stream') + clearToken() + }) + + it('does NOT attach Authorization header when no token is set', async () => { + clearToken() + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, body: makeStream([]) }) + ;(globalThis as any).fetch = fetchMock + + await new Promise((resolve) => { + streamSSE('/x', { onLine: () => {}, onClose: resolve }) + }) + const opts = fetchMock.mock.calls[0][1] + expect(opts.headers.Authorization).toBeUndefined() + }) + + it('returns a cleanup function that aborts the AbortController', async () => { + // No need to await — just verify the callable contract. + ;(globalThis as any).fetch = vi.fn(() => new Promise(() => {})) + const cleanup = streamSSE('/x', { onLine: () => {} }) + expect(typeof cleanup).toBe('function') + cleanup() // does not throw + await flush() + }) + + it('honors an external AbortSignal — aborting it aborts the compound', async () => { + ;(globalThis as any).fetch = vi.fn(() => new Promise(() => {})) + const ctl = new AbortController() + const cleanup = streamSSE('/x', { onLine: () => {} }, ctl.signal) + expect(typeof cleanup).toBe('function') + ctl.abort() + // Just verify no throw + cleanup still callable. + cleanup() + await flush() + }) +}) diff --git a/src/pages/BlogPage.test.tsx b/src/pages/BlogPage.test.tsx new file mode 100644 index 0000000..ee4420b --- /dev/null +++ b/src/pages/BlogPage.test.tsx @@ -0,0 +1,63 @@ +/* BlogPage.test.tsx — coverage for the public blog index. */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { render, cleanup } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { BlogPage } from './BlogPage' +import { POSTS } from '../content/posts' + +let originalTitle = '' +beforeEach(() => { + originalTitle = document.title + document.title = 'instanode · Real infrastructure for AI agents' +}) +afterEach(() => { + document.title = originalTitle + cleanup() +}) + +describe('BlogPage', () => { + it('sets document.title to "Blog · instanode" on mount', () => { + render( + + + , + ) + expect(document.title).toBe('Blog · instanode') + }) + + it('restores the previous document.title on unmount', () => { + const { unmount } = render( + + + , + ) + unmount() + expect(document.title).toBe('instanode · Real infrastructure for AI agents') + }) + + it('renders a card for every post, with link to /blog/', () => { + if (POSTS.length === 0) return + render( + + + , + ) + for (const p of POSTS) { + const link = document.querySelector(`a[href="/blog/${p.slug}"]`) + expect(link, `missing link for ${p.slug}`).toBeTruthy() + } + }) + + it('renders posts in reverse-chronological order', () => { + if (POSTS.length < 2) return + render( + + + , + ) + const cards = Array.from(document.querySelectorAll('a.blog-card-link')) + const slugs = cards.map((c) => (c.getAttribute('href') || '').replace('/blog/', '')) + const sorted = [...POSTS].sort((a, b) => b.date.localeCompare(a.date)).map((p) => p.slug) + expect(slugs).toEqual(sorted) + }) +}) diff --git a/src/pages/NotFoundPage.test.tsx b/src/pages/NotFoundPage.test.tsx new file mode 100644 index 0000000..eb17315 --- /dev/null +++ b/src/pages/NotFoundPage.test.tsx @@ -0,0 +1,56 @@ +/* NotFoundPage.test.tsx — coverage tests for the SPA 404 catch-all. */ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { NotFoundPage } from './NotFoundPage' + +describe('NotFoundPage', () => { + beforeEach(() => { + // jsdom defaults pathname to '/' — for one test we override. + }) + + it('renders 404 eyebrow and headline', () => { + render( + + + , + ) + expect(screen.getByText(/404/i)).toBeTruthy() + expect(screen.getByText(/that page is not provisioned/i)).toBeTruthy() + }) + + it('shows the current URL path in a ', () => { + render( + + + , + ) + // jsdom default location.pathname is '/' so the rendered code is '/'. + const code = document.querySelector('code.nf-url') + expect(code).toBeTruthy() + expect(code!.textContent).toBe(window.location.pathname || '/') + }) + + it('renders both primary and secondary CTAs', () => { + render( + + + , + ) + const home = document.querySelector('a.nf-cta-primary') + const docs = document.querySelector('a.nf-cta-secondary') + expect(home?.getAttribute('href')).toBe('/') + expect(docs?.getAttribute('href')).toBe('/docs') + }) + + it('lists the standard help links (pricing, use-cases, blog, changelog)', () => { + render( + + + , + ) + for (const href of ['/pricing', '/use-cases', '/blog', '/changelog']) { + expect(document.querySelector(`a[href="${href}"]`)).toBeTruthy() + } + }) +}) diff --git a/src/pages/PrivacyPage.test.tsx b/src/pages/PrivacyPage.test.tsx new file mode 100644 index 0000000..74e1c53 --- /dev/null +++ b/src/pages/PrivacyPage.test.tsx @@ -0,0 +1,39 @@ +/* PrivacyPage.test.tsx — coverage tests for the static legal stop-gap page. */ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { PrivacyPage } from './PrivacyPage' + +describe('PrivacyPage', () => { + it('renders the privacy heading and testid wrapper', () => { + render( + + + , + ) + const section = screen.getByTestId('privacy-page') + expect(section).toBeTruthy() + expect(section.textContent).toContain('Privacy') + }) + + it('exposes a mailto link for the legal contact', () => { + render( + + + , + ) + const links = document.querySelectorAll('a[href^="mailto:"]') + expect(links.length).toBeGreaterThanOrEqual(1) + expect(links[0].getAttribute('href')).toBe('mailto:legal@instanode.dev') + }) + + it('mentions our sub-processors so reviewers can see them', () => { + render( + + + , + ) + expect(document.body.textContent ?? '').toContain('Razorpay') + expect(document.body.textContent ?? '').toContain('Resend') + }) +}) diff --git a/src/pages/TermsPage.test.tsx b/src/pages/TermsPage.test.tsx new file mode 100644 index 0000000..fe774e5 --- /dev/null +++ b/src/pages/TermsPage.test.tsx @@ -0,0 +1,48 @@ +/* TermsPage.test.tsx — coverage tests for the static legal stop-gap page. */ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { TermsPage } from './TermsPage' + +describe('TermsPage', () => { + it('renders the terms heading and testid wrapper', () => { + render( + + + , + ) + const section = screen.getByTestId('terms-page') + expect(section).toBeTruthy() + expect(section.textContent).toContain('Terms') + }) + + it('exposes a mailto link for the legal contact', () => { + render( + + + , + ) + const links = document.querySelectorAll('a[href^="mailto:"]') + expect(links.length).toBeGreaterThanOrEqual(1) + expect(links[0].getAttribute('href')).toBe('mailto:legal@instanode.dev') + }) + + it('links to the public status page (T-tier SLA wording)', () => { + render( + + + , + ) + const status = document.querySelector('a[href="https://status.instanode.dev"]') + expect(status).toBeTruthy() + }) + + it('mentions billing via Razorpay (no free trial copy)', () => { + render( + + + , + ) + expect(document.body.textContent ?? '').toContain('Razorpay') + }) +}) diff --git a/src/pages/UseCasesPage.test.tsx b/src/pages/UseCasesPage.test.tsx new file mode 100644 index 0000000..ea3bab1 --- /dev/null +++ b/src/pages/UseCasesPage.test.tsx @@ -0,0 +1,75 @@ +/* UseCasesPage.test.tsx — coverage for filter / grouping logic + render. */ +import { describe, it, expect } from 'vitest' +import { render, fireEvent, cleanup } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { UseCasesPage } from './UseCasesPage' +import { USE_CASES, type Category } from '../content/useCases' + +function renderPage() { + return render( + + + , + ) +} + +describe('UseCasesPage', () => { + it('renders the hero headline and quickstart CTA', () => { + renderPage() + expect(document.body.textContent ?? '').toContain('Fifty places instanode.dev fits') + const cta = document.querySelector('a[href="/docs#quickstart"]') + expect(cta).toBeTruthy() + cleanup() + }) + + it('renders one card per use case by default', () => { + renderPage() + const cards = document.querySelectorAll('a.uc-card-link') + expect(cards.length).toBe(USE_CASES.length) + cleanup() + }) + + it('renders a filter chip per unique category + the "All" chip', () => { + renderPage() + const uniqueCats = new Set() + for (const c of USE_CASES) uniqueCats.add(c.category) + const chips = document.querySelectorAll('button.uc-chip') + expect(chips.length).toBe(uniqueCats.size + 1) // +1 for "All" + cleanup() + }) + + it('"All" chip is selected by default (has uc-chip-on)', () => { + renderPage() + const first = document.querySelector('button.uc-chip') + expect(first?.classList.contains('uc-chip-on')).toBe(true) + cleanup() + }) + + it('clicking a category chip filters the rendered cards', () => { + if (USE_CASES.length === 0) return + const firstCat = USE_CASES[0].category + const expectedCount = USE_CASES.filter((u) => u.category === firstCat).length + + renderPage() + // Find the chip whose text begins with the category label (stripped of "X. "). + const stripped = firstCat.replace(/^[A-Z]\.\s*/, '') + const chips = Array.from(document.querySelectorAll('button.uc-chip')) + const target = chips.find((c) => (c.textContent ?? '').startsWith(stripped)) + expect(target, `no chip matched category ${firstCat}`).toBeTruthy() + fireEvent.click(target!) + + const cards = document.querySelectorAll('a.uc-card-link') + expect(cards.length).toBe(expectedCount) + expect(target!.classList.contains('uc-chip-on')).toBe(true) + cleanup() + }) + + it('every card links to /use-cases/', () => { + renderPage() + for (const u of USE_CASES) { + const link = document.querySelector(`a[href="/use-cases/${u.slug}"]`) + expect(link, `missing link for ${u.slug}`).toBeTruthy() + } + cleanup() + }) +})