diff --git a/.gitea/workflows/stage.yaml b/.gitea/workflows/stage.yaml index 77317e2..6b7d754 100644 --- a/.gitea/workflows/stage.yaml +++ b/.gitea/workflows/stage.yaml @@ -381,3 +381,12 @@ jobs: echo "Stage smoke test FAILED" exit 1 + + - name: Run automated test suite + run: | + set -euo pipefail + cd $GITHUB_WORKSPACE + npm ci --prefer-offline 2>/dev/null || npm install + npx playwright install chromium --with-deps + BASE_URL="${{ steps.deploy-stage.outputs.stage_url }}" \ + npx playwright test --reporter=list diff --git a/.gitignore b/.gitignore index 2a4e284..3f22566 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,8 @@ src/assets/js/ dist/soul-demo dist/soul-demo-snapshot.json dist/soul-demo-image.tar + +# Playwright +node_modules/ +test-results/ +playwright-report/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d709366 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "neuron-marketing-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "neuron-marketing-web", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.44.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d6dce12 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "neuron-marketing-web", + "version": "1.0.0", + "private": true, + "devDependencies": { + "@playwright/test": "^1.44.0" + }, + "scripts": { + "test": "playwright test", + "test:api": "playwright test tests/api/", + "test:e2e": "playwright test tests/e2e/" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..db87c71 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, devices } from '@playwright/test'; + +const BASE_URL = process.env.BASE_URL || 'https://marketing-stage-r4tfklscwq-uc.a.run.app'; + +export default defineConfig({ + testDir: './tests', + timeout: 30_000, + retries: process.env.CI ? 2 : 0, + reporter: [['list'], ['html', { open: 'never' }]], + use: { + baseURL: BASE_URL, + extraHTTPHeaders: {}, + }, + projects: [ + { name: 'api', testDir: './tests/api', use: { ...devices['Desktop Chrome'] } }, + { name: 'chromium', testDir: './tests/e2e', use: { ...devices['Desktop Chrome'] } }, + { name: 'mobile', testDir: './tests/e2e', use: { ...devices['Pixel 7'] } }, + ], +}); diff --git a/tests/api/endpoints.test.ts b/tests/api/endpoints.test.ts new file mode 100644 index 0000000..ffb61a9 --- /dev/null +++ b/tests/api/endpoints.test.ts @@ -0,0 +1,150 @@ +import { test, expect } from '@playwright/test'; + +const BASE = process.env.BASE_URL || 'https://marketing-stage-r4tfklscwq-uc.a.run.app'; + +const get = (path: string, headers: Record = {}) => + fetch(`${BASE}${path}`, { headers }); + +// ── /api/health ─────────────────────────────────────────────────────────────── + +test('/api/health — returns 200 with status:ok', async () => { + const r = await get('/api/health'); + expect(r.status).toBe(200); + const body = await r.json() as Record; + expect(body.status).toBe('ok'); + expect(body.service).toBe('neuron-web'); +}); + +test('/api/health — content-type is application/json', async () => { + const r = await get('/api/health'); + expect(r.headers.get('content-type')).toContain('application/json'); +}); + +// ── /api/founding-count ─────────────────────────────────────────────────────── + +test('/api/founding-count — returns numeric fields', async () => { + const r = await get('/api/founding-count'); + expect(r.status).toBe(200); + const body = await r.json() as Record; + expect(typeof body.sold).toBe('number'); + expect(typeof body.total).toBe('number'); + expect(typeof body.remaining).toBe('number'); + // Invariants + expect(body.total).toBe(1000); + expect(body.sold).toBeGreaterThanOrEqual(0); + expect(body.remaining).toBe(body.total - body.sold); +}); + +// ── /api/supabase-config ────────────────────────────────────────────────────── +// Requires a permitted Origin. See security.test.ts for CORS tests. + +test('/api/supabase-config — returns url and anon_key for allowed origin', async () => { + const r = await get('/api/supabase-config', { Origin: 'https://neurontechnologies.ai' }); + expect(r.status).toBe(200); + const body = await r.json() as Record; + expect(body.url).toMatch(/supabase\.co/); + expect(typeof body.anon_key).toBe('string'); + expect(body.anon_key.length).toBeGreaterThan(20); +}); + +test('/api/supabase-config — anon_key is a valid JWT shape', async () => { + const r = await get('/api/supabase-config', { Origin: 'https://neurontechnologies.ai' }); + const body = await r.json() as Record; + // Supabase anon key is a JWT: three base64 segments separated by dots + const parts = body.anon_key.split('.'); + expect(parts).toHaveLength(3); +}); + +// ── /sitemap.xml ────────────────────────────────────────────────────────────── + +test('/sitemap.xml — returns valid XML with production URLs', async () => { + const r = await get('/sitemap.xml'); + expect(r.status).toBe(200); + expect(r.headers.get('content-type')).toContain('xml'); + const text = await r.text(); + expect(text).toContain(' { + const r = await get('/sitemap.xml'); + const text = await r.text(); + expect(text).toContain('neurontechnologies.ai/'); + expect(text).toContain('neurontechnologies.ai/about'); + expect(text).toContain('neurontechnologies.ai/legal/terms'); + expect(text).toContain('neurontechnologies.ai/legal/enterprise-terms'); +}); + +// ── /robots.txt ─────────────────────────────────────────────────────────────── + +test('/robots.txt — accessible with correct directives', async () => { + const r = await get('/robots.txt'); + expect(r.status).toBe(200); + const text = await r.text(); + expect(text).toContain('User-agent'); + // Private paths are disallowed + expect(text).toContain('Disallow: /checkout'); + expect(text).toContain('Disallow: /account'); + expect(text).toContain('Disallow: /api/'); + // Sitemap link points to production + expect(text).toContain('Sitemap: https://neurontechnologies.ai/sitemap.xml'); +}); + +// ── /llms.txt ───────────────────────────────────────────────────────────────── + +test('/llms.txt — accessible', async () => { + const r = await get('/llms.txt'); + expect(r.status).toBe(200); + const text = await r.text(); + expect(text.length).toBeGreaterThan(0); +}); + +// ── 404 handling ───────────────────────────────────────────────────────────── + +test('Unknown route returns 404', async () => { + const r = await get('/this-route-xyz-does-not-exist-abc123'); + expect(r.status).toBe(404); +}); + +// ── /api/webhooks/stripe — POST-only, requires valid signature ──────────────── + +test('/api/webhooks/stripe — rejects missing Stripe-Signature with 400', async () => { + const r = await fetch(`${BASE}/api/webhooks/stripe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'payment_intent.succeeded' }), + }); + expect(r.status).toBe(400); +}); + +// ── /api/demo — POST only, auth-gated ──────────────────────────────────────── + +test('/api/demo — missing access_token returns auth_required', async () => { + const r = await fetch(`${BASE}/api/demo`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'hello' }), + }); + const body = await r.json() as Record; + expect(body.auth_required).toBe(true); +}); + +// ── /api/soul-health — internal gate ───────────────────────────────────────── +// The probe responses embedded in the JSON body may contain literal newlines +// (control characters), so we test via text matching, not JSON.parse. + +test('/api/soul-health — 404 without X-Internal header', async () => { + const r = await get('/api/soul-health'); + expect(r.status).toBe(404); +}); + +test('/api/soul-health — 200 with X-Internal: true, body contains soul_url', async () => { + const r = await get('/api/soul-health', { 'X-Internal': 'true' }); + expect(r.status).toBe(200); + const text = await r.text(); + expect(text).toContain('"soul_url"'); + expect(text).toMatch(/soul_url.*https?:\/\//); +}); diff --git a/tests/api/security.test.ts b/tests/api/security.test.ts new file mode 100644 index 0000000..d777353 --- /dev/null +++ b/tests/api/security.test.ts @@ -0,0 +1,212 @@ +import { test, expect } from '@playwright/test'; + +const BASE = process.env.BASE_URL || 'https://marketing-stage-r4tfklscwq-uc.a.run.app'; + +async function get(path: string, headers: Record = {}) { + return fetch(`${BASE}${path}`, { headers, redirect: 'manual' }); +} + +async function post(path: string, body: unknown, headers: Record = {}) { + return fetch(`${BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); +} + +// ── Security headers ────────────────────────────────────────────────────────── +// All HTML pages and API responses carry the full security header suite. +// The El runtime's handle_request wrapper applies sec_headers_json() to every +// response, so we can assert the same set on both HTML pages and JSON APIs. + +test.describe('Security headers', () => { + const htmlPages = ['/', '/about', '/checkout?plan=professional']; + for (const path of htmlPages) { + test(`HTML ${path} — required security headers present`, async () => { + const r = await get(path); + expect(r.headers.get('x-content-type-options')).toBe('nosniff'); + expect(r.headers.get('x-frame-options')).toMatch(/DENY|SAMEORIGIN/i); + expect(r.headers.get('referrer-policy')).toBeTruthy(); + expect(r.headers.get('content-security-policy')).toBeTruthy(); + }); + } + + test('API responses carry x-content-type-options', async () => { + const r = await get('/api/health'); + expect(r.headers.get('x-content-type-options')).toBe('nosniff'); + }); + + test('permissions-policy header is present', async () => { + const r = await get('/'); + expect(r.headers.get('permissions-policy')).toBeTruthy(); + }); +}); + +// ── CORS enforcement on /api/supabase-config ────────────────────────────────── +// This endpoint enforces an explicit origin allowlist: +// - empty Origin (server-side / curl): BLOCKED (403) +// - https://neurontechnologies.ai: ALLOWED +// - https://www.neurontechnologies.ai: ALLOWED +// - http://localhost:*: ALLOWED (dev) +// - anything else (e.g. evil.com): BLOCKED (403) + +test.describe('CORS enforcement — /api/supabase-config', () => { + test('Rejects requests with no Origin header', async () => { + // No Origin = not from a browser context — the server treats this as + // an unknown caller and returns 403 to prevent server-side exfiltration. + const r = await get('/api/supabase-config'); + expect(r.status).toBe(403); + }); + + test('Rejects evil origin', async () => { + const r = await get('/api/supabase-config', { Origin: 'https://evil.com' }); + expect(r.status).toBe(403); + }); + + test('Allows neurontechnologies.ai origin', async () => { + const r = await get('/api/supabase-config', { Origin: 'https://neurontechnologies.ai' }); + expect(r.status).toBe(200); + const body = await r.json() as Record; + expect(body.url).toMatch(/supabase\.co/); + expect(typeof body.anon_key).toBe('string'); + expect(body.anon_key.length).toBeGreaterThan(20); + }); + + test('Allows www.neurontechnologies.ai origin', async () => { + const r = await get('/api/supabase-config', { Origin: 'https://www.neurontechnologies.ai' }); + expect(r.status).toBe(200); + }); + + test('Allows localhost origin (dev)', async () => { + const r = await get('/api/supabase-config', { Origin: 'http://localhost:3001' }); + expect(r.status).toBe(200); + }); +}); + +// ── Auth enforcement on /api/demo ───────────────────────────────────────────── +// All requests require a valid Supabase access_token. +// Missing or invalid tokens return {"auth_required":true}. + +test.describe('Auth enforcement — /api/demo', () => { + test('Rejects POST with no access_token', async () => { + const r = await post('/api/demo', { message: 'hello' }); + const body = await r.json() as Record; + expect(body.auth_required).toBe(true); + }); + + test('Rejects POST with invalid access_token', async () => { + const r = await post('/api/demo', { message: 'hello', access_token: 'invalid.token.here' }); + const body = await r.json() as Record; + expect(body.auth_required).toBe(true); + }); + + test('Rejects empty message (length guard fires after auth check)', async () => { + // With no token, auth check fires first + const r = await post('/api/demo', { message: '', access_token: 'invalid' }); + const body = await r.json() as Record; + expect(body.auth_required || body.error).toBeTruthy(); + }); +}); + +// ── Stripe webhook signature enforcement ────────────────────────────────────── + +test.describe('Stripe webhook security', () => { + test('Rejects POST with no Stripe-Signature header', async () => { + const r = await post('/api/webhooks/stripe', { + type: 'payment_intent.succeeded', + data: { object: { amount: 9900 } }, + }); + expect(r.status).toBe(400); + }); + + test('Rejects POST with malformed Stripe-Signature', async () => { + const r = await post( + '/api/webhooks/stripe', + { type: 'payment_intent.succeeded', data: { object: {} } }, + { 'Stripe-Signature': 't=1234,v1=fakesignature' }, + ); + expect(r.status).toBe(400); + }); +}); + +// ── Information leakage — source and build files must not be exposed ────────── +// The Docker image copies only compiled artifacts and static assets into +// /srv/landing/. Source files (.el, Makefile, Dockerfile) never land there, +// so all these paths should 404. + +test.describe('Information leakage — source files not served', () => { + const leakyPaths = [ + '/src/main.el', + '/.env', + '/Dockerfile.stage', + '/runtime/el_runtime.c', + '/.gitea/workflows/stage.yaml', + '/dist/neuron-landing', + ]; + for (const path of leakyPaths) { + test(`${path} returns 404`, async () => { + const r = await get(path); + expect(r.status).toBe(404); + }); + } +}); + +// ── /api/soul-health — internal-only diagnostic ─────────────────────────────── +// Returns 404 without the X-Internal: true header. +// Returns 200 with the header (allows in-container health probing). + +test.describe('Soul health — internal gate', () => { + test('Returns 404 without X-Internal header', async () => { + const r = await get('/api/soul-health'); + expect(r.status).toBe(404); + }); + + test('Returns 200 with X-Internal: true and includes soul_url', async () => { + const r = await get('/api/soul-health', { 'X-Internal': 'true' }); + expect(r.status).toBe(200); + // The response embeds raw probe output which may contain literal newlines + // inside JSON strings (invalid JSON). Check via text search to avoid + // JSON.parse failure on the control characters. + const text = await r.text(); + expect(text).toContain('"soul_url"'); + expect(text).toMatch(/soul_url.*https?:\/\//); + }); +}); + +// ── Path traversal ──────────────────────────────────────────────────────────── +// The El runtime only serves files from whitelisted paths (src/assets/, +// src/shares/, src/js/). Any traversal attempt resolves to 404 — the +// runtime never reads outside its served directories. + +test.describe('Path traversal blocked', () => { + const traversals = [ + '/assets/../../../etc/passwd', + '/assets/%2e%2e%2f%2e%2e%2fetc%2fpasswd', + '/js/../../../etc/passwd', + ]; + for (const path of traversals) { + test(`Traversal blocked: ${path}`, async () => { + const r = await get(path); + expect(r.status).toBe(404); + const text = await r.text(); + // Must not contain any /etc/passwd content + expect(text).not.toContain('root:'); + }); + } +}); + +// ── Input validation — /api/demo message length cap ────────────────────────── +// Messages over 8000 chars are rejected before any auth or LLM call. + +test.describe('Input validation', () => { + test('Oversized message (>8000 chars) is rejected with error', async () => { + const r = await post('/api/demo', { + message: 'A'.repeat(10000), + access_token: 'test', + }); + const body = await r.json() as Record; + // Length guard fires before auth check in server code + expect(typeof body.error).toBe('string'); + expect((body.error as string).toLowerCase()).toMatch(/long|length|8000/i); + }); +}); diff --git a/tests/e2e/chat.spec.ts b/tests/e2e/chat.spec.ts new file mode 100644 index 0000000..e81a45e --- /dev/null +++ b/tests/e2e/chat.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; + +// The demo widget is rendered server-side via El components and injected into +// the landing page. Element IDs are stable: #neuron-demo-panel, #neuron-demo-btn, +// #neuron-demo-auth, #neuron-demo-text, #neuron-demo-send, etc. + +test.describe('Demo chat widget — structure', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + test('Demo panel (#neuron-demo-panel) is in the DOM', async ({ page }) => { + await expect(page.locator('#neuron-demo-panel')).toBeAttached(); + }); + + test('Demo open button (#neuron-demo-btn) is in the DOM', async ({ page }) => { + await expect(page.locator('#neuron-demo-btn')).toBeAttached(); + }); + + test('Demo auth section (#neuron-demo-auth) is in the DOM', async ({ page }) => { + await expect(page.locator('#neuron-demo-auth')).toBeAttached(); + }); + + test('Demo text input (#neuron-demo-text) is in the DOM', async ({ page }) => { + await expect(page.locator('#neuron-demo-text')).toBeAttached(); + }); + + test('Demo send button (#neuron-demo-send) is in the DOM', async ({ page }) => { + await expect(page.locator('#neuron-demo-send')).toBeAttached(); + }); +}); + +test.describe('Demo chat widget — auth gate', () => { + test.beforeEach(async ({ page }) => { + // Clear any stored Supabase session so we test the unauthenticated state + await page.goto('/'); + await page.evaluate(() => { + Object.keys(localStorage) + .filter(k => k.startsWith('sb-') || k.includes('supabase')) + .forEach(k => localStorage.removeItem(k)); + }); + await page.reload(); + await page.waitForLoadState('networkidle'); + }); + + test('Send button is disabled when unauthenticated', async ({ page }) => { + const sendBtn = page.locator('#neuron-demo-send'); + await expect(sendBtn).toBeAttached(); + // The send button starts disabled until a valid session is confirmed + const isDisabled = await sendBtn.isDisabled().catch(() => true); + const isHidden = !(await sendBtn.isVisible().catch(() => false)); + expect(isDisabled || isHidden).toBe(true); + }); + + test('Auth gate (#neuron-demo-auth) or gate (#neuron-demo-gate) is visible or panel is closed', async ({ page }) => { + // Either the auth pane is visible, OR the panel itself is closed (not visible). + // Both are correct unauthenticated states. + const authVisible = await page.locator('#neuron-demo-auth').isVisible().catch(() => false); + const gateVisible = await page.locator('#neuron-demo-gate').isVisible().catch(() => false); + const panelClosed = !(await page.locator('#neuron-demo-panel').isVisible().catch(() => true)); + expect(authVisible || gateVisible || panelClosed).toBe(true); + }); +}); + +test.describe('Demo chat widget — API gate (no browser session)', () => { + test('/api/demo rejects unauthenticated POST and returns auth_required', async ({ page }) => { + // Use the Playwright request context to hit the API directly + const r = await page.request.post('/api/demo', { + data: { message: 'Hello Neuron' }, + }); + const body = await r.json() as Record; + expect(body.auth_required).toBe(true); + }); +}); diff --git a/tests/e2e/checkout.spec.ts b/tests/e2e/checkout.spec.ts new file mode 100644 index 0000000..fe332dd --- /dev/null +++ b/tests/e2e/checkout.spec.ts @@ -0,0 +1,58 @@ +import { test, expect } from '@playwright/test'; + +// All three plan variants must render without error +for (const plan of ['free', 'professional', 'founding']) { + test(`Checkout loads for plan=${plan}`, async ({ page }) => { + const r = await page.goto(`/checkout?plan=${plan}`); + expect(r?.status()).toBe(200); + await expect(page.locator('body')).not.toBeEmpty(); + // Title must be set (not empty) + const title = await page.title(); + expect(title.length).toBeGreaterThan(0); + }); +} + +test('Checkout professional — has "Professional" plan name in body', async ({ page }) => { + await page.goto('/checkout?plan=professional'); + await expect(page.locator('body')).toContainText('Professional'); +}); + +test('Checkout founding — has "Founding" plan name in body', async ({ page }) => { + await page.goto('/checkout?plan=founding'); + await expect(page.locator('body')).toContainText('Founding'); +}); + +test('Checkout free — mentions free or sign up in body', async ({ page }) => { + await page.goto('/checkout?plan=free'); + const body = await page.locator('body').textContent(); + expect(body?.toLowerCase()).toMatch(/free|sign|start|account/); +}); + +test('Checkout professional — auth section is present (sign in / create account)', async ({ page }) => { + await page.goto('/checkout?plan=professional'); + // auth-section div is present in the DOM (may be hidden via CSS but rendered) + await expect(page.locator('#auth-section')).toBeAttached(); + // Payment form is present + await expect(page.locator('#payment-form')).toBeAttached(); +}); + +test('Checkout professional — payment element container is present', async ({ page }) => { + await page.goto('/checkout?plan=professional'); + await expect(page.locator('#payment-element')).toBeAttached(); +}); + +test('Checkout — nav has back link to homepage', async ({ page }) => { + await page.goto('/checkout?plan=professional'); + // The checkout nav has both a logo link and an explicit "← Back" nav-link, + // both pointing to /. Use first() to avoid strict-mode violation. + const navLink = page.locator('nav a[href="/"]').first(); + await expect(navLink).toBeAttached(); +}); + +test('Checkout professional — canonical is production URL', async ({ page }) => { + await page.goto('/checkout?plan=professional'); + const canonical = await page.locator('link[rel="canonical"]').getAttribute('href'); + expect(canonical).toContain('neurontechnologies.ai'); + expect(canonical).not.toContain('run.app'); + expect(canonical).not.toContain('stage'); +}); diff --git a/tests/e2e/landing.spec.ts b/tests/e2e/landing.spec.ts new file mode 100644 index 0000000..0b132de --- /dev/null +++ b/tests/e2e/landing.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Landing page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('Has correct title', async ({ page }) => { + await expect(page).toHaveTitle(/Neuron/); + }); + + test('Has exactly one h1', async ({ page }) => { + const h1s = page.locator('h1'); + await expect(h1s).toHaveCount(1); + }); + + test('Has meta description with sufficient length', async ({ page }) => { + const meta = page.locator('meta[name="description"]'); + await expect(meta).toHaveCount(1); + const content = await meta.getAttribute('content'); + expect(content?.length).toBeGreaterThan(50); + }); + + test('Has og:title and og:description', async ({ page }) => { + await expect(page.locator('meta[property="og:title"]')).toHaveCount(1); + await expect(page.locator('meta[property="og:description"]')).toHaveCount(1); + }); + + test('Has canonical URL pointing to production domain', async ({ page }) => { + const canonical = page.locator('link[rel="canonical"]'); + await expect(canonical).toHaveCount(1); + const href = await canonical.getAttribute('href'); + expect(href).toContain('neurontechnologies.ai'); + expect(href).not.toContain('stage'); + expect(href).not.toContain('run.app'); + }); + + test('Nav is rendered and visible', async ({ page }) => { + // Use the specific nav ID — the footer also contains a