diff --git a/app/auth/forgot-password/page.tsx b/app/auth/forgot-password/page.tsx index fd5083d..823b72e 100644 --- a/app/auth/forgot-password/page.tsx +++ b/app/auth/forgot-password/page.tsx @@ -48,7 +48,7 @@ export default function ForgotPasswordPage() { return (
diff --git a/app/auth/sign-in/page.tsx b/app/auth/sign-in/page.tsx index e454e44..d5beb56 100644 --- a/app/auth/sign-in/page.tsx +++ b/app/auth/sign-in/page.tsx @@ -66,9 +66,15 @@ function SignInForm() { return ( + {searchParams.get('registered') === '1' ? ( + + Account created. Sign in to continue. + + ) : null} +

- Need an account? Create one through your Neon Auth configuration flow first. + Need an account?{' '} + + Create one + + .

diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx index 27fae5f..9993f60 100644 --- a/app/auth/sign-up/page.tsx +++ b/app/auth/sign-up/page.tsx @@ -46,11 +46,18 @@ export default function SignUpPage() { posthog.identify(email, { email, name: name.trim() || email }); posthog.capture('user_signed_up', { email, name: name.trim() || email }); - setSuccess('Account created. You can now sign in.'); - setTimeout(() => { - router.push('/auth/sign-in'); + // Sign the new account straight in: bouncing to the sign-in form made a successful + // sign-up look like a failure. If that doesn't work, say so on the sign-in page. + const signedIn = await auth.signIn.email({ email, password }); + if (signedIn?.error) { + router.push('/auth/sign-in?registered=1'); router.refresh(); - }, 800); + return; + } + + setSuccess('Account created. Taking you to your dashboard…'); + router.push('/dashboard'); + router.refresh(); } catch (err) { posthog.captureException(err); setError(err instanceof Error ? err.message : 'Sign-up failed'); @@ -62,7 +69,7 @@ export default function SignUpPage() { return (
diff --git a/app/dashboard/nodes/nodes-client.tsx b/app/dashboard/nodes/nodes-client.tsx index 329c149..750df85 100644 --- a/app/dashboard/nodes/nodes-client.tsx +++ b/app/dashboard/nodes/nodes-client.tsx @@ -6,6 +6,7 @@ import posthog from 'posthog-js'; import { formatRelativeTime } from '@/app/lib/dashboard/datetime'; import { + buildHermesSetup, buildOpenClawSetupBlock, getNodeHealth, type NodeHealth, @@ -141,15 +142,29 @@ function ConnectGuide({ const pullUrl = `${origin}/api/nodes/pull`; const ackUrl = `${origin}/api/nodes/ack`; const setupBlock = buildOpenClawSetupBlock(origin, token, forwardBaseUrl || undefined); + const hermesSetup = buildHermesSetup(origin, token); return ( <> -

Connect your OpenClaw node

+

Connect your node

- Install this skill on your OpenClaw node. The skill will connect to clawproxy via WebSocket - for real-time event delivery, falling back to HTTP polling when needed. + Your node connects out to clawproxy over WebSocket for real-time delivery, falling back to + HTTP polling when needed. Hermes Agent has a plugin; any other agent can speak the protocol + below.

+
+
+

Hermes Agent — install the plugin

+ +
+
+          {hermesSetup}
+        
+
+

@@ -214,7 +229,7 @@ function ConnectGuide({

setForwardBaseUrl(e.target.value)} />

- The base URL of your OpenClaw instance. Used to fill in the{' '} + Where your agent receives forwarded events. Used to fill in the{' '} forward.webhook_url in the skill configuration below.

@@ -233,7 +248,7 @@ function ConnectGuide({
-

One-paste OpenClaw setup block

+

Any other agent — protocol setup block

- {openClawBaseUrl && ( -

Saved: {openClawBaseUrl}

+ {savedBaseUrl && ( +

Saved: {savedBaseUrl}

)} @@ -507,8 +526,16 @@ export function NodesClient({ initialNodes }: Props) { )} {nodeList.length === 0 ? ( -
-

No nodes have been registered yet.

+
+

+ No nodes yet. A node is the agent that receives your webhooks — Hermes Agent, OpenClaw, + or your own client. +

+ {!showCreateForm && ( + + )}
) : (
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 1ec4203..f755eb1 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,6 +1,7 @@ import 'server-only'; import { count, desc, eq } from 'drizzle-orm'; +import Link from 'next/link'; import { requireAdminUser } from '@/app/lib/auth/require-admin'; import { @@ -10,6 +11,8 @@ import { import { decrypt } from '@/app/lib/crypto/encryption'; import { db } from '@/app/lib/db/client'; import { events, nodes } from '@/db/schema'; +import { overviewIntro } from '@/app/lib/dashboard/overview-intro'; +import { Button } from '@/components/ui/button'; import { DashboardEventsChart } from '@/components/app/dashboard-events-chart'; import { DashboardMetricCards } from '@/components/app/dashboard-metric-cards'; import { DashboardPageHeader } from '@/components/app/dashboard-page-header'; @@ -22,7 +25,7 @@ export const dynamic = 'force-dynamic'; export default async function DashboardPage() { const user = await requireAdminUser(); - const [countsByStatus, dailySeries, recentList] = await Promise.all([ + const [countsByStatus, dailySeries, recentList, nodeCountRows] = await Promise.all([ db .select({ status: events.status, @@ -45,8 +48,11 @@ export default async function DashboardPage() { .where(eq(events.userId, user.id)) .orderBy(desc(events.receivedAt)) .limit(10), + db.select({ count: count() }).from(nodes).where(eq(nodes.userId, user.id)), ]); + const intro = overviewIntro(Number(nodeCountRows[0]?.count ?? 0)); + const totals = countsByStatus.reduce>((acc, row) => { acc[row.status] = Number(row.count); return acc; @@ -70,23 +76,27 @@ export default async function DashboardPage() { return (
- + + + {intro.cta ? ( + + ) : null} - + {intro.showMetrics ? ( + + ) : null}
diff --git a/app/lib/dashboard/node-connect.ts b/app/lib/dashboard/node-connect.ts index c167a5c..0dbd78a 100644 --- a/app/lib/dashboard/node-connect.ts +++ b/app/lib/dashboard/node-connect.ts @@ -11,6 +11,7 @@ description: Pulls and delivers webhook events from clawproxy connection: ws_url: "${wsOrigin}/api/nodes/ws" pull_url: "${origin}/api/nodes/pull" + pull_method: "POST" ack_url: "${origin}/api/nodes/ack" token: "${tokenValue}" @@ -46,6 +47,7 @@ connection: http_fallback: enabled: true pull_url: "${origin}/api/nodes/pull" + pull_method: "POST" ack_url: "${origin}/api/nodes/ack" auth_header: "Authorization: Bearer ${tokenValue}" poll_interval_seconds: 30 @@ -80,3 +82,22 @@ export function getNodeHealth( if (ms < 60 * 60 * 1000) return 'stale'; return 'offline'; } + +/** + * Setup for Hermes Agent, which has a first-party plugin: it holds this node's connection inside + * the gateway and hands each event to the Hermes webhook route of the same name. + */ +export function buildHermesSetup(origin: string, token?: string): string { + const tokenValue = token ?? 'YOUR_NODE_TOKEN_HERE'; + return `# 1. Install the plugin on the machine running Hermes Agent +hermes plugins install ericdahl-dev/clawproxy-hermes + +# 2. Add these to ~/.hermes/.env (or your profile's .env) +CLAWPROXY_NODE_TOKEN=${tokenValue} +CLAWPROXY_HERMES_WEBHOOK_SECRET= +${origin === 'https://clawproxy.io' ? '' : `CLAWPROXY_SERVER_URL=${origin}\n`} +# 3. Restart the gateway, then check: hermes gateway status + +# Route names must match: a clawproxy route named "github-prs" is delivered to the Hermes +# webhook route with the same name, so create that route in Hermes first.`; +} diff --git a/app/lib/dashboard/overview-intro.ts b/app/lib/dashboard/overview-intro.ts new file mode 100644 index 0000000..ff963d5 --- /dev/null +++ b/app/lib/dashboard/overview-intro.ts @@ -0,0 +1,33 @@ +export type OverviewIntro = { + title: string; + description: string; + isFirstRun: boolean; + showMetrics: boolean; + cta: { href: string; label: string } | null; +}; + +/** + * What the dashboard home should say. A new account has no nodes, so greeting it with + * "Welcome back" above eight zeroed metrics tells it nothing; point it at the first step instead. + */ +export function overviewIntro(nodeCount: number): OverviewIntro { + if (nodeCount === 0) { + return { + title: 'Set up your first node', + description: + 'A node is the agent that receives your webhooks. Create one, connect it, then point a provider at the route URL clawproxy gives you.', + isFirstRun: true, + showMetrics: false, + cta: { href: '/dashboard/nodes', label: 'Create your first node' }, + }; + } + + return { + title: 'Welcome back', + description: + 'Manage the nodes, routes and events behind your public ingress and private agent delivery.', + isFirstRun: false, + showMetrics: true, + cta: null, + }; +} diff --git a/app/page.tsx b/app/page.tsx index 8d2d404..1ee60d1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,7 +2,7 @@ import { ArchitectureDiagram } from '@/components/app/architecture-diagram'; import { Button } from '@/components/ui/button'; const proofPoints = [ - 'Built for OpenClaw', + 'Built for agents', 'Durable delivery', 'Private-node friendly', 'ngrok alternative', @@ -20,9 +20,9 @@ const comparisonPoints = [ 'Unlike tunnels that drop in-flight traffic when the process exits, clawproxy persists every event until it is acknowledged delivered.', }, { - title: 'No vendor lock-in', + title: 'Free while in beta', description: - 'No third-party relay in the webhook path, no usage-based pricing, and no rate limits imposed by a vendor.', + 'clawproxy is free while in beta. It is open source, so you can also run the whole relay yourself.', }, ]; @@ -40,7 +40,7 @@ const featureCards = [ { title: 'Live WebSocket delivery', description: - 'OpenClaw nodes can open a persistent WebSocket connection and receive events the instant they arrive — no polling delay. HTTP pull is always available as a fallback.', + 'Your node opens a persistent WebSocket connection and receives events the instant they arrive — no polling delay. HTTP pull is always available as a fallback.', }, { title: 'Built for operators', @@ -63,7 +63,7 @@ const steps = [ { title: 'Deliver in real-time or on demand', description: - 'Your OpenClaw node receives events instantly over a persistent WebSocket connection, or falls back to authenticated HTTP polling — whichever fits your setup.', + 'Your node receives events instantly over a persistent WebSocket connection, or falls back to authenticated HTTP polling — whichever fits your setup.', }, ]; @@ -91,16 +91,17 @@ export default function Home() { clawproxy

- Public webhook ingress for private OpenClaw nodes + Public webhook ingress for private agent nodes — Hermes Agent, OpenClaw, or your + own client

- OpenClaw + Hermes Agent plugin
@@ -339,7 +340,9 @@ export default function Home() { size="lg" className="rounded-full border-border px-6 py-3 text-sm font-semibold" > - Learn about OpenClaw + + Hermes Agent plugin +
diff --git a/components/app/admin-shell.tsx b/components/app/admin-shell.tsx index 8b3997d..82b9b4d 100644 --- a/components/app/admin-shell.tsx +++ b/components/app/admin-shell.tsx @@ -19,7 +19,7 @@ type AdminShellProps = { }; export function AdminShell({ - eyebrow = 'clawproxy admin', + eyebrow = 'clawproxy', title, description, maxWidthClass = 'max-w-md', diff --git a/components/app/dashboard-app-sidebar.tsx b/components/app/dashboard-app-sidebar.tsx index 18fbe2b..e0f6416 100644 --- a/components/app/dashboard-app-sidebar.tsx +++ b/components/app/dashboard-app-sidebar.tsx @@ -114,7 +114,6 @@ export function DashboardAppSidebar({ userEmail }: DashboardAppSidebarProps) {
{userEmail} - Admin
@@ -133,8 +132,7 @@ export function DashboardAppSidebar({ userEmail }: DashboardAppSidebarProps) {
{userEmail} - Admin -
+
diff --git a/tests/auth/dashboard-entry-page.test.ts b/tests/auth/dashboard-entry-page.test.ts index 931f7ff..bfac5f9 100644 --- a/tests/auth/dashboard-entry-page.test.ts +++ b/tests/auth/dashboard-entry-page.test.ts @@ -20,6 +20,8 @@ const { mockDb } = vi.hoisted(() => { }; } return { + // The node-count query awaits `.where()` directly; the recent-events query chains. + where: () => Promise.resolve([{ count: 1 }]), leftJoin: () => ({ where: () => ({ orderBy: () => ({ diff --git a/tests/auth/sign-in-copy.test.tsx b/tests/auth/sign-in-copy.test.tsx new file mode 100644 index 0000000..2f7aa49 --- /dev/null +++ b/tests/auth/sign-in-copy.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const { searchParams } = vi.hoisted(() => ({ searchParams: { value: new URLSearchParams() } })); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }), + useSearchParams: () => searchParams.value, +})); + +vi.mock('@/app/lib/auth/client', () => ({ + createNeonClientAuth: vi.fn(async () => ({ signIn: { email: vi.fn() } })), +})); + +vi.mock('posthog-js', () => ({ + default: { identify: vi.fn(), capture: vi.fn(), captureException: vi.fn() }, +})); + +import SignInPage from '@/app/auth/sign-in/page'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('sign-in page', () => { + let container: HTMLElement; + let root: Root; + + beforeEach(() => { + searchParams.value = new URLSearchParams(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + async function render() { + await act(async () => { + root.render(createElement(SignInPage)); + }); + return container.textContent ?? ''; + } + + test('does not expose the auth vendor to users', async () => { + const text = await render(); + expect(text).not.toContain('Neon Auth'); + expect(text).not.toContain('configuration flow'); + }); + + test('offers a plain link to create an account', async () => { + expect(await render()).toContain('Need an account?'); + }); + + test('confirms the account was created when arriving from sign-up', async () => { + searchParams.value = new URLSearchParams('registered=1'); + expect(await render()).toMatch(/account created/i); + }); +}); diff --git a/tests/auth/sign-up-flow.test.tsx b/tests/auth/sign-up-flow.test.tsx new file mode 100644 index 0000000..e1a4629 --- /dev/null +++ b/tests/auth/sign-up-flow.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const { mockPush, mockRefresh, mockSignUp, mockSignIn } = vi.hoisted(() => ({ + mockPush: vi.fn(), + mockRefresh: vi.fn(), + mockSignUp: vi.fn(), + mockSignIn: vi.fn(), +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush, refresh: mockRefresh }), + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock('@/app/lib/auth/client', () => ({ + createNeonClientAuth: vi.fn(async () => ({ + signUp: { email: mockSignUp }, + signIn: { email: mockSignIn }, + })), +})); + +vi.mock('posthog-js', () => ({ + default: { identify: vi.fn(), capture: vi.fn(), captureException: vi.fn() }, +})); + +import SignUpPage from '@/app/auth/sign-up/page'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('sign-up', () => { + let container: HTMLElement; + let root: Root; + + beforeEach(() => { + vi.clearAllMocks(); + mockSignUp.mockResolvedValue({ data: {} }); + mockSignIn.mockResolvedValue({ data: {} }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + async function submit() { + await act(async () => { + root.render(createElement(SignUpPage)); + }); + const form = container.querySelector('form')!; + (container.querySelector('#sign-up-email') as HTMLInputElement).value = 'new@example.com'; + (container.querySelector('#sign-up-password') as HTMLInputElement).value = 'hunter2hunter2'; + await act(async () => { + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + }); + await act(async () => { + await Promise.resolve(); + }); + } + + test('signs the new account in and goes straight to the dashboard', async () => { + await submit(); + + expect(mockSignUp).toHaveBeenCalled(); + expect(mockSignIn).toHaveBeenCalled(); + expect(mockPush).toHaveBeenCalledWith('/dashboard'); + }); + + test('falls back to the sign-in page with a confirmation when auto sign-in fails', async () => { + mockSignIn.mockResolvedValue({ error: { message: 'nope' } }); + + await submit(); + + expect(mockPush).toHaveBeenCalledWith('/auth/sign-in?registered=1'); + }); +}); diff --git a/tests/auth/sign-up-page.test.ts b/tests/auth/sign-up-page.test.ts index affad06..96a4afe 100644 --- a/tests/auth/sign-up-page.test.ts +++ b/tests/auth/sign-up-page.test.ts @@ -74,9 +74,13 @@ describe('SignUpPage', () => { expect(hrefs).toContain('/'); }); - test('shows success and navigates to sign-in after delay on successful sign-up', async () => { + test('signs in and navigates to the dashboard on successful sign-up', async () => { const signUpEmail = vi.fn().mockResolvedValue({}); - createNeonClientAuthMock.mockResolvedValue({ signUp: { email: signUpEmail } }); + const signInEmail = vi.fn().mockResolvedValue({}); + createNeonClientAuthMock.mockResolvedValue({ + signUp: { email: signUpEmail }, + signIn: { email: signInEmail }, + }); const { default: SignUpPage } = await import('@/app/auth/sign-up/page'); @@ -99,19 +103,18 @@ describe('SignUpPage', () => { password: 'pw', name: 'Ada', }); - expect(container.textContent).toContain('Account created'); - - await act(async () => { - vi.advanceTimersByTime(800); - }); - - expect(pushMock).toHaveBeenCalledWith('/auth/sign-in'); + // Bouncing a new account to the sign-in form read as failure; sign it in instead. + expect(signInEmail).toHaveBeenCalledWith({ email: 'ada@e.com', password: 'pw' }); + expect(pushMock).toHaveBeenCalledWith('/dashboard'); expect(refreshMock).toHaveBeenCalled(); }); test('uses email as name when name is blank', async () => { const signUpEmail = vi.fn().mockResolvedValue({}); - createNeonClientAuthMock.mockResolvedValue({ signUp: { email: signUpEmail } }); + createNeonClientAuthMock.mockResolvedValue({ + signUp: { email: signUpEmail }, + signIn: { email: vi.fn().mockResolvedValue({}) }, + }); const { default: SignUpPage } = await import('@/app/auth/sign-up/page'); @@ -137,7 +140,10 @@ describe('SignUpPage', () => { test('shows API error when signUp returns error', async () => { const signUpEmail = vi.fn().mockResolvedValue({ error: { message: 'Email taken' } }); - createNeonClientAuthMock.mockResolvedValue({ signUp: { email: signUpEmail } }); + createNeonClientAuthMock.mockResolvedValue({ + signUp: { email: signUpEmail }, + signIn: { email: vi.fn().mockResolvedValue({}) }, + }); const { default: SignUpPage } = await import('@/app/auth/sign-up/page'); @@ -160,7 +166,10 @@ describe('SignUpPage', () => { test('shows message when signUp throws', async () => { const signUpEmail = vi.fn().mockRejectedValue(new Error('boom')); - createNeonClientAuthMock.mockResolvedValue({ signUp: { email: signUpEmail } }); + createNeonClientAuthMock.mockResolvedValue({ + signUp: { email: signUpEmail }, + signIn: { email: vi.fn().mockResolvedValue({}) }, + }); const { default: SignUpPage } = await import('@/app/auth/sign-up/page'); diff --git a/tests/dashboard/layout.test.ts b/tests/dashboard/layout.test.ts index 9702c97..b3c142c 100644 --- a/tests/dashboard/layout.test.ts +++ b/tests/dashboard/layout.test.ts @@ -81,7 +81,8 @@ describe('dashboard layout', () => { 'Events', ); expect(document.body.textContent).toContain('admin@example.com'); - expect(document.body.textContent).toContain('Admin'); + // The sidebar used to label every user "Admin"; there are no roles in the product. + expect(document.body.textContent).not.toContain('Admin'); expect(document.body.textContent).toContain('clawproxy'); expect(document.body.textContent).toContain('Dashboard content'); }); diff --git a/tests/dashboard/node-connect.test.ts b/tests/dashboard/node-connect.test.ts index 0363fa4..129a2de 100644 --- a/tests/dashboard/node-connect.test.ts +++ b/tests/dashboard/node-connect.test.ts @@ -89,3 +89,29 @@ describe('getNodeHealth', () => { expect(getNodeHealth(last, now)).toBe('offline'); }); }); + +describe('agent-agnostic connect instructions', () => { + test('buildHermesSetup gives the plugin install command and both env vars', async () => { + const { buildHermesSetup } = await import('@/app/lib/dashboard/node-connect'); + const setup = buildHermesSetup('https://relay.example', 'cpn_token'); + + expect(setup).toContain('hermes plugins install ericdahl-dev/clawproxy-hermes'); + expect(setup).toContain('CLAWPROXY_NODE_TOKEN=cpn_token'); + expect(setup).toContain('CLAWPROXY_HERMES_WEBHOOK_SECRET='); + }); + + test('buildHermesSetup states the route-name rule', async () => { + const { buildHermesSetup } = await import('@/app/lib/dashboard/node-connect'); + expect(await Promise.resolve(buildHermesSetup('https://relay.example'))).toMatch( + /same name|match/i, + ); + }); + + test('the protocol block documents that pull is a POST', async () => { + const { buildOpenClawSetupBlock, buildSkillYaml } = await import( + '@/app/lib/dashboard/node-connect' + ); + expect(buildOpenClawSetupBlock('https://relay.example')).toContain('pull_method: "POST"'); + expect(buildSkillYaml('https://relay.example')).toContain('pull_method: "POST"'); + }); +}); diff --git a/tests/dashboard/nodes-client.ui.test.tsx b/tests/dashboard/nodes-client.ui.test.tsx index 4f01dd7..396d7f7 100644 --- a/tests/dashboard/nodes-client.ui.test.tsx +++ b/tests/dashboard/nodes-client.ui.test.tsx @@ -76,7 +76,7 @@ describe('NodesClient', () => { method: 'DELETE', credentials: 'same-origin', }); - expect(container.textContent).toContain('No nodes have been registered yet'); + expect(container.textContent).toContain('No nodes yet'); }); test('keeps connect modal visible within the viewport', async () => { @@ -106,7 +106,7 @@ describe('NodesClient', () => { }); const modalTitle = Array.from(document.body.querySelectorAll('h3')).find( - (h) => h.textContent?.trim() === 'Connect your OpenClaw node', + (h) => h.textContent?.trim() === 'Connect your node', ); expect(modalTitle).toBeTruthy(); @@ -173,13 +173,13 @@ describe('NodesClient', () => { connectButton!.click(); }); - expect(document.body.textContent).toContain('Connect your OpenClaw node'); + expect(document.body.textContent).toContain('Connect your node'); await act(async () => { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); }); - expect(document.body.textContent).not.toContain('Connect your OpenClaw node'); + expect(document.body.textContent).not.toContain('Connect your node'); }); test('closes connect modal on backdrop click', async () => { @@ -208,7 +208,7 @@ describe('NodesClient', () => { connectButton!.click(); }); - expect(document.body.textContent).toContain('Connect your OpenClaw node'); + expect(document.body.textContent).toContain('Connect your node'); const backdrop = document.body.querySelector('[data-testid="modal-backdrop"]'); expect(backdrop).toBeTruthy(); @@ -217,10 +217,10 @@ describe('NodesClient', () => { backdrop!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); }); - expect(document.body.textContent).not.toContain('Connect your OpenClaw node'); + expect(document.body.textContent).not.toContain('Connect your node'); }); - test('shows OpenClaw prompt and reuses saved URL in connect modal', async () => { + test('shows the delivery-URL prompt and reuses the saved URL in the connect modal', async () => { const initialNodes = [ { id: 'node-1', @@ -237,7 +237,7 @@ describe('NodesClient', () => { root.render(createElement(NodesClient, { initialNodes })); }); - expect(container.textContent).toContain('Connect your OpenClaw instance'); + expect(container.textContent).toContain('Where should events be delivered?'); const urlInput = container.querySelector('#openclaw-base-url') as HTMLInputElement | null; expect(urlInput).toBeTruthy(); @@ -276,7 +276,7 @@ describe('NodesClient', () => { const modalInput = document.body.querySelector('#openclaw-url') as HTMLInputElement | null; expect(modalInput).toBeTruthy(); expect(modalInput?.value).toBe('http://openclaw-host:8080'); - expect(document.body.textContent).toContain('One-paste OpenClaw setup block'); + expect(document.body.textContent).toContain('Any other agent — protocol setup block'); expect(document.body.textContent).toContain( 'After successful forward, send websocket ack using connection.websocket.ack_message_template', ); @@ -306,3 +306,48 @@ describe('NodesClient', () => { expect(urlInput?.value).toBe('http://127.0.0.1:18789'); }); }); + +describe('NodesClient onboarding', () => { + let container: HTMLElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + window.localStorage.clear(); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + test('does not claim a delivery URL is saved when the user never saved one', async () => { + await act(async () => { + root.render(createElement(NodesClient, { initialNodes: [] })); + }); + + expect(container.textContent).not.toContain('Saved:'); + }); + + test('shows the saved delivery URL once one is stored', async () => { + window.localStorage.setItem('nodes.openclawBaseUrl', 'http://127.0.0.1:9999'); + + await act(async () => { + root.render(createElement(NodesClient, { initialNodes: [] })); + }); + + expect(container.textContent).toContain('Saved: http://127.0.0.1:9999'); + }); + + test('an account with no nodes gets a create prompt, not just an empty line', async () => { + await act(async () => { + root.render(createElement(NodesClient, { initialNodes: [] })); + }); + + expect(container.textContent).toMatch(/create your first node/i); + }); +}); diff --git a/tests/dashboard/nodes-page.test.ts b/tests/dashboard/nodes-page.test.ts index 2acd5a0..6209378 100644 --- a/tests/dashboard/nodes-page.test.ts +++ b/tests/dashboard/nodes-page.test.ts @@ -51,6 +51,6 @@ describe('dashboard nodes page', () => { const html = renderToStaticMarkup(element); const dom = new JSDOM(html); - expect(dom.window.document.body.textContent).toContain('No nodes have been registered yet'); + expect(dom.window.document.body.textContent).toContain('No nodes yet'); }); }); diff --git a/tests/dashboard/overview-intro.test.ts b/tests/dashboard/overview-intro.test.ts new file mode 100644 index 0000000..f8b51a9 --- /dev/null +++ b/tests/dashboard/overview-intro.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'vitest'; + +import { overviewIntro } from '@/app/lib/dashboard/overview-intro'; + +describe('overviewIntro', () => { + test('a brand-new account gets a first-run setup prompt, not "Welcome back"', () => { + const intro = overviewIntro(0); + expect(intro.title).not.toMatch(/welcome back/i); + expect(intro.title).toMatch(/first node/i); + expect(intro.isFirstRun).toBe(true); + expect(intro.cta).toEqual({ href: '/dashboard/nodes', label: 'Create your first node' }); + }); + + test('an account with a node gets the returning view and no CTA', () => { + const intro = overviewIntro(1); + expect(intro.title).toBe('Welcome back'); + expect(intro.isFirstRun).toBe(false); + expect(intro.cta).toBeNull(); + }); + + test('metrics are hidden until there is a node to produce them', () => { + expect(overviewIntro(0).showMetrics).toBe(false); + expect(overviewIntro(2).showMetrics).toBe(true); + }); +}); diff --git a/tests/dashboard/overview-page.test.ts b/tests/dashboard/overview-page.test.ts index dc4bae6..847bba3 100644 --- a/tests/dashboard/overview-page.test.ts +++ b/tests/dashboard/overview-page.test.ts @@ -9,7 +9,7 @@ const mockRequireAdminUser = vi.hoisted(() => vi.fn()); * getDailyEventCounts (another select). Call order is not guaranteed, so we use * one from() shape that supports every chain instead of mockReturnValueOnce. */ -const { mockDb, mockStatusGroupBy, mockDailyOrderBy, mockRecentLimit } = vi.hoisted(() => { +const { mockDb, mockStatusGroupBy, mockDailyOrderBy, mockRecentLimit, mockNodeCount } = vi.hoisted(() => { const mockRecentLimit = vi.fn().mockResolvedValue([]); const mockRecentOrderBy = vi.fn().mockReturnValue({ limit: mockRecentLimit }); const mockRecentWhere = vi.fn().mockReturnValue({ orderBy: mockRecentOrderBy }); @@ -21,7 +21,12 @@ const { mockDb, mockStatusGroupBy, mockDailyOrderBy, mockRecentLimit } = vi.hois orderBy: mockDailyOrderBy, }), ); - const mockWhereGrouped = vi.fn().mockReturnValue({ groupBy: mockGroupBy }); + // `.where()` serves two shapes: the grouped status query (`.groupBy()`) and the node-count + // query, which is awaited directly. Make it both a thenable and a chain. + const mockNodeCount = vi.fn().mockResolvedValue([{ count: 0 }]); + const mockWhereGrouped = vi.fn().mockImplementation(() => + Object.assign(mockNodeCount(), { groupBy: mockGroupBy }), + ); const mockFrom = vi.fn().mockImplementation(() => ({ leftJoin: mockLeftJoin, @@ -37,6 +42,7 @@ const { mockDb, mockStatusGroupBy, mockDailyOrderBy, mockRecentLimit } = vi.hois mockStatusGroupBy: mockGroupBy, mockDailyOrderBy, mockRecentLimit, + mockNodeCount, }; }); @@ -69,9 +75,11 @@ describe('dashboard overview page', () => { mockDailyOrderBy.mockResolvedValue([]); mockRecentLimit.mockReset(); mockRecentLimit.mockResolvedValue([]); + mockNodeCount.mockReset(); + mockNodeCount.mockResolvedValue([{ count: 1 }]); }); - test('renders overview heading and description', async () => { + test('renders overview heading and description for an account with a node', async () => { const element = await DashboardOverviewPage(); const html = renderToStaticMarkup(element); const dom = new JSDOM(html); @@ -80,6 +88,18 @@ describe('dashboard overview page', () => { expect(dom.window.document.body.textContent).toContain('Welcome back'); }); + test('an account with no nodes gets a first-run prompt instead of zeroed metrics', async () => { + mockNodeCount.mockResolvedValue([{ count: 0 }]); + + const element = await DashboardOverviewPage(); + const text = new JSDOM(renderToStaticMarkup(element)).window.document.body.textContent ?? ''; + + expect(text).toContain('Set up your first node'); + expect(text).toContain('Create your first node'); + expect(text).not.toContain('Welcome back'); + expect(text).not.toContain('Success rate'); + }); + test('shows empty recent events when none are returned', async () => { const element = await DashboardOverviewPage(); const html = renderToStaticMarkup(element); diff --git a/tests/landing/copy.test.ts b/tests/landing/copy.test.ts new file mode 100644 index 0000000..81fa440 --- /dev/null +++ b/tests/landing/copy.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; + +async function landingHtml() { + const homePage = await import('@/app/page'); + return renderToStaticMarkup(homePage.default()); +} + +describe('landing page copy', () => { + test('does not claim there is no third-party relay (clawproxy is one)', async () => { + expect(await landingHtml()).not.toContain('No third-party relay in the webhook path'); + }); + + test('states the current price instead of implying a pricing model', async () => { + const html = await landingHtml(); + expect(html).toMatch(/free while in beta/i); + expect(html).not.toMatch(/no usage-based pricing/i); + }); + + test('is agent-agnostic: names Hermes Agent and OpenClaw as examples', async () => { + const html = await landingHtml(); + expect(html).toContain('Hermes Agent'); + expect(html).toContain('OpenClaw'); + expect(html).not.toContain('private OpenClaw nodes'); + }); +}); diff --git a/tests/landing/ngrok-comparison.test.ts b/tests/landing/ngrok-comparison.test.ts index 1f1a2f1..9a7fb69 100644 --- a/tests/landing/ngrok-comparison.test.ts +++ b/tests/landing/ngrok-comparison.test.ts @@ -32,7 +32,9 @@ describe('landing page ngrok/Cloudflare Tunnels comparison section', () => { expect(html).toContain('No tunnel dependency'); expect(html).toContain('Events survive restarts'); - expect(html).toContain('No vendor lock-in'); + // Was 'No vendor lock-in', which claimed no third-party relay in the webhook path — + // untrue of hosted clawproxy, which is exactly that. + expect(html).toContain('Free while in beta'); }); test('proof points include ngrok alternative badge', async () => {