diff --git a/scripts/test-templates.ts b/scripts/test-templates.ts index bde24143b..5a72c40e0 100644 --- a/scripts/test-templates.ts +++ b/scripts/test-templates.ts @@ -19,7 +19,7 @@ import { spawn, type Subprocess } from 'bun'; import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; // Path to local CLI bin (use development version, not npm) @@ -534,6 +534,122 @@ async function typecheckProject(projectDir: string): Promise<{ success: boolean; return { success: true }; } +interface TemplateHonoRpcResponseTypeProbe { + readonly relativePath: string; + readonly source: string; +} + +const DEFAULT_HONO_RPC_RESPONSE_TYPE_PROBE_PATH = join( + 'src', + 'web', + 'hono-rpc-response-type-probe.ts' +); + +function buildHonoRpcResponseTypeProbe( + body: string, + relativePath: string = DEFAULT_HONO_RPC_RESPONSE_TYPE_PROBE_PATH +): TemplateHonoRpcResponseTypeProbe { + return { + relativePath, + source: `import { hc } from 'hono/client'; +import type { InferResponseType } from 'hono/client'; +import type { ApiRouter } from '../api/index'; + +const client = hc('/api'); + +declare function expectType(value: T): void; + +${body} +`, + }; +} + +function getTemplateHonoRpcResponseTypeProbe( + templateId: string +): TemplateHonoRpcResponseTypeProbe | undefined { + switch (templateId) { + case 'default': + return buildHonoRpcResponseTypeProbe(`interface ExpectedHistoryEntry { +\treadonly model: string; +\treadonly sessionId: string; +\treadonly text: string; +\treadonly timestamp: string; +\treadonly tokens: number; +\treadonly toLanguage: string; +\treadonly translation: string; +} + +interface ExpectedHistoryData { +\treadonly history: readonly ExpectedHistoryEntry[]; +\treadonly threadId?: string; +\treadonly translationCount: number; +} + +interface ExpectedTranslateResult extends ExpectedHistoryData { +\treadonly sessionId: string; +\treadonly tokens: number; +\treadonly translation: string; +} + +type HistoryData = InferResponseType; +type TranslateResult = InferResponseType; +type ClearHistoryResult = InferResponseType; + +declare const historyData: HistoryData; +declare const translateResult: TranslateResult; +declare const clearHistoryResult: ClearHistoryResult; + +expectType(historyData); +expectType(translateResult); +expectType(clearHistoryResult); +`); + + case 'auth': + return buildHonoRpcResponseTypeProbe(`interface ExpectedHealthData { +\treadonly status: string; +\treadonly timestamp: string; +} + +interface ExpectedProtectedRouteResult { +\treadonly authMethod: string; +\treadonly email: string; +\treadonly id: string; +\treadonly memberSince: string | null; +\treadonly name: string | null; +} + +type HealthData = InferResponseType; + +declare const healthData: HealthData; + +expectType(healthData); + +async function verifyProtectedRouteResult(): Promise { +\tconst res = await client.me.$get(); +\tif (!res.ok) { +\t\treturn; +\t} + +\texpectType(await res.json()); +} +`); + + default: + return undefined; + } +} + +async function verifyHonoRpcResponseTypes( + projectDir: string, + probe: TemplateHonoRpcResponseTypeProbe +): Promise<{ success: boolean; error?: string }> { + const probePath = join(projectDir, probe.relativePath); + mkdirSync(dirname(probePath), { recursive: true }); + writeFileSync(probePath, probe.source); + + return typecheckProject(projectDir); +} + async function startServer( projectDir: string, port: number, @@ -794,6 +910,31 @@ async function testTemplate( } logSuccess('Typecheck passed'); + // Step 4.5: Verify template Hono RPC response inference + const honoRpcResponseTypeProbe = getTemplateHonoRpcResponseTypeProbe(template.id); + if (honoRpcResponseTypeProbe) { + logStep(`Verifying ${template.name} Hono RPC response types...`); + stepStart = Date.now(); + const rpcTypeResult = await verifyHonoRpcResponseTypes( + projectDir, + honoRpcResponseTypeProbe + ); + result.steps.push({ + name: 'Verify Hono RPC response types', + passed: rpcTypeResult.success, + error: rpcTypeResult.error, + duration: Date.now() - stepStart, + }); + if (!rpcTypeResult.success) { + result.passed = false; + logError( + `${template.name} Hono RPC response type check failed: ${rpcTypeResult.error}` + ); + return result; + } + logSuccess(`${template.name} Hono RPC response types verified`); + } + // Step 5: Start server and test endpoints logStep('Starting server...'); stepStart = Date.now(); diff --git a/templates/auth/src/web/App.tsx b/templates/auth/src/web/App.tsx index 1fbc4c4d4..74149c52e 100644 --- a/templates/auth/src/web/App.tsx +++ b/templates/auth/src/web/App.tsx @@ -7,6 +7,7 @@ import { useAuthenticate, } from '@daveyplate/better-auth-ui'; import { hc } from 'hono/client'; +import type { InferResponseType } from 'hono/client'; import { AnimatePresence, motion } from 'motion/react'; import { Toaster, toast } from 'sonner'; import type { ApiRouter } from '../api/index'; @@ -15,6 +16,8 @@ import './App.css'; const client = hc('/api'); +type ProtectedRouteResult = InferResponseType; + const NEXT_STEPS: ReadonlyArray<{ readonly key: string; readonly title: string; @@ -61,14 +64,6 @@ const NEXT_STEPS: ReadonlyArray<{ }, ]; -interface ProtectedRouteResult { - readonly authMethod: string; - readonly email: string; - readonly id: string; - readonly memberSince: string | null; - readonly name: string | null; -} - function formatDate(value: string | null | undefined): string { if (!value) return 'Not set'; try { diff --git a/templates/default/src/api/index.ts b/templates/default/src/api/index.ts index 16493dabc..aa3c13143 100644 --- a/templates/default/src/api/index.ts +++ b/templates/default/src/api/index.ts @@ -60,8 +60,12 @@ const TranslateOutput = s.object({ translationCount: s.number().describe('Total translations in this thread'), }); +type TranslateResult = s.infer; + const HistoryOutput = TranslateOutput.pick(['history', 'threadId', 'translationCount']); +type HistoryData = s.infer; + const api = new Hono() // Translate text .post( @@ -132,14 +136,16 @@ const api = new Hono() translationCount, }); - return c.json({ + const result: TranslateResult = { history, sessionId: c.var.sessionId, threadId: c.var.thread.id, tokens, translation, translationCount, - }); + }; + + return c.json(result); } ) // Retrieve translation history @@ -148,22 +154,26 @@ const api = new Hono() const translationCount = (await c.var.thread.state.get(TRANSLATION_COUNT_KEY)) ?? history.length; - return c.json({ + const result: HistoryData = { history, threadId: c.var.thread.id, translationCount, - }); + }; + + return c.json(result); }) // Clear translation history .delete('/translate/history', validator({ output: HistoryOutput }), async (c) => { await c.var.thread.state.delete(HISTORY_KEY); await c.var.thread.state.delete(TRANSLATION_COUNT_KEY); - return c.json({ + const result: HistoryData = { history: [], threadId: c.var.thread.id, translationCount: 0, - }); + }; + + return c.json(result); }); export type ApiRouter = typeof api; diff --git a/templates/default/src/web/App.tsx b/templates/default/src/web/App.tsx index 8ed4a821c..46bddd813 100644 --- a/templates/default/src/web/App.tsx +++ b/templates/default/src/web/App.tsx @@ -1,7 +1,9 @@ import { useAnalytics } from '@agentuity/react'; import { hc } from 'hono/client'; +import type { InferResponseType } from 'hono/client'; +import { Fragment, useCallback, useEffect, useState } from 'react'; +import type { ChangeEvent } from 'react'; import type { ApiRouter } from '../api/index'; -import { type ChangeEvent, Fragment, useCallback, useEffect, useState } from 'react'; import './App.css'; const LANGUAGES = ['Spanish', 'French', 'German', 'Chinese'] as const; @@ -11,27 +13,9 @@ const DEFAULT_TEXT = const client = hc('/api'); -interface HistoryEntry { - readonly model: string; - readonly sessionId: string; - readonly text: string; - readonly timestamp: string; - readonly tokens: number; - readonly toLanguage: string; - readonly translation: string; -} - -interface HistoryData { - readonly history: readonly HistoryEntry[]; - readonly threadId?: string; - readonly translationCount: number; -} - -interface TranslateResult extends HistoryData { - readonly sessionId: string; - readonly tokens: number; - readonly translation: string; -} +type HistoryData = InferResponseType; +type TranslateResult = InferResponseType; +type HistoryEntry = HistoryData['history'][number]; export function App() { const [text, setText] = useState(DEFAULT_TEXT); @@ -47,7 +31,7 @@ export function App() { // Fetch history on mount const fetchHistory = useCallback(async () => { const res = await client.translate.history.$get(); - setHistoryData((await res.json()) as HistoryData); + setHistoryData(await res.json()); }, []); useEffect(() => { @@ -55,7 +39,7 @@ export function App() { }, [fetchHistory]); // Prefer fresh data from translation, fall back to initial fetch - const history = translateResult?.history ?? historyData?.history ?? []; + const history: readonly HistoryEntry[] = translateResult?.history ?? historyData?.history ?? []; const threadId = translateResult?.threadId ?? historyData?.threadId; const handleTranslate = useCallback(async () => { @@ -63,7 +47,7 @@ export function App() { setIsLoading(true); try { const res = await client.translate.$post({ json: { text, toLanguage, model } }); - setTranslateResult((await res.json()) as TranslateResult); + setTranslateResult(await res.json()); } finally { setIsLoading(false); } @@ -71,10 +55,10 @@ export function App() { const handleClearHistory = useCallback(async () => { track('clear_history'); - await client.translate.history.$delete(); + const res = await client.translate.history.$delete(); setTranslateResult(null); - await fetchHistory(); - }, [fetchHistory, track]); + setHistoryData(await res.json()); + }, [track]); return (