diff --git a/.github/workflows/live-smoke.yml b/.github/workflows/live-smoke.yml new file mode 100644 index 0000000..0647eea --- /dev/null +++ b/.github/workflows/live-smoke.yml @@ -0,0 +1,52 @@ +name: live-smoke + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install Dependencies + run: bun install + + - name: Install Vercel CLI + run: npm install --global vercel@latest + + - name: Install Playwright Chromium + run: bunx playwright install --with-deps chromium + + - name: Generate Smoke Audio Fixtures + run: bun run smoke:prepare-audio + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + + - name: Run Live Smoke + run: bun run smoke:live + env: + CI: "true" + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + + - name: Upload Smoke Artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: smoke-artifacts + path: .artifacts/smoke + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index ac84698..b9ed78c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,7 @@ yarn-error.log* # bun bun.lockb -bun.lock \ No newline at end of file +bun.lock + +# smoke artifacts +.artifacts diff --git a/biome.json b/biome.json index e8d99dd..2737dd8 100644 --- a/biome.json +++ b/biome.json @@ -1,3 +1,11 @@ { - "extends": ["@rubriclab/config/biome"] + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "extends": ["@rubriclab/config/biome"], + "files": { + "includes": ["**", "!next-env.d.ts"] + } } diff --git a/components.json b/components.json new file mode 100644 index 0000000..1bc723c --- /dev/null +++ b/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "aliases": { + "components": "@/components", + "hooks": "@/hooks", + "lib": "@/lib", + "ui": "@/components/ui", + "utils": "@/lib/utils" + }, + "iconLibrary": "lucide", + "registries": {}, + "rsc": true, + "rtl": false, + "style": "new-york", + "tailwind": { + "baseColor": "neutral", + "config": "tailwind.config.ts", + "css": "src/app/styles.css", + "cssVariables": true, + "prefix": "" + }, + "tsx": true +} diff --git a/package.json b/package.json index 0aa2ec9..f1073c5 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,36 @@ { "dependencies": { "@prisma/client": "^6.19.0", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@t3-oss/env-nextjs": "^0.13.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "dotenv": "^17.2.3", "framer-motion": "^12.0.0", + "lucide-react": "^0.576.0", "next": "16.0.10", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "tailwind-merge": "^3.5.0", + "tailwindcss-animate": "^1.0.7" }, "description": "This project was bootstrapped with create-rubric-app", "devDependencies": { "@rubriclab/config": "^0.0.22", + "@tailwindcss/postcss": "^4.2.1", "@types/node": "^24.10.1", "@types/react": "^19.2.3", "@types/react-dom": "^19.2.3", + "playwright": "^1.58.2", "prisma": "^6.19.0", + "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "zod": "^4.1.12" }, @@ -32,6 +48,9 @@ "db:studio": "prisma studio", "dev": "next dev", "format": "bun x biome check --write .", + "smoke:live": "bun run scripts/smoke/runLiveConversationSmoke.ts --target-url https://lilac.chat", + "smoke:local": "bun run scripts/smoke/runLiveConversationSmoke.ts --target-url http://localhost:3000 --skip-vercel-logs --skip-chat", + "smoke:prepare-audio": "bun run scripts/smoke/generateSmokeAudio.ts", "start": "next start" }, "version": "0.0.0" diff --git a/scripts/smoke/captureVercelRuntimeLogs.ts b/scripts/smoke/captureVercelRuntimeLogs.ts new file mode 100644 index 0000000..91bf338 --- /dev/null +++ b/scripts/smoke/captureVercelRuntimeLogs.ts @@ -0,0 +1,84 @@ +import { spawn } from 'node:child_process' +import { createWriteStream } from 'node:fs' +import { mkdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' + +export type RuntimeLogCapture = { + stderrPath: string + stdoutPath: string + stop: () => Promise +} + +export type StartRuntimeLogCaptureInput = { + artifactDirectoryPath: string + deploymentDomain: string + vercelToken?: string +} + +export async function startVercelRuntimeLogCapture( + input: StartRuntimeLogCaptureInput +): Promise { + const runtimeDirectoryPath = join(input.artifactDirectoryPath, 'runtime-logs') + await mkdir(runtimeDirectoryPath, { recursive: true }) + + const stdoutPath = join(runtimeDirectoryPath, 'vercel-runtime.jsonl') + const stderrPath = join(runtimeDirectoryPath, 'vercel-runtime.stderr.log') + const stdoutWriter = createWriteStream(stdoutPath, { flags: 'w' }) + const stderrWriter = createWriteStream(stderrPath, { flags: 'w' }) + + const captureEnvironment = { + ...process.env + } + if (input.vercelToken) captureEnvironment.VERCEL_TOKEN = input.vercelToken + + const vercelProcess = spawn('vercel', ['logs', input.deploymentDomain, '--json'], { + env: captureEnvironment, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + vercelProcess.stdout.pipe(stdoutWriter) + vercelProcess.stderr.pipe(stderrWriter) + + async function stop(): Promise { + if (!vercelProcess.killed) { + vercelProcess.kill('SIGINT') + } + await new Promise(resolve => { + vercelProcess.once('close', () => resolve()) + setTimeout(() => resolve(), 3000) + }) + stdoutWriter.end() + stderrWriter.end() + } + + return { + stderrPath, + stdoutPath, + stop + } +} + +export async function collectRuntimeErrorMatches(runtimeLogPath: string): Promise { + const runtimeLogContent = await readFile(runtimeLogPath, 'utf8') + const runtimeLogLines = runtimeLogContent + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + + const matchPatterns = [ + /Unsupported parameter/i, + /Unknown parameter/i, + /Invalid 'item\.id'/i, + /Unhandled/i, + /An error occurred in the Server Components render/i, + /\bdigest\b/i + ] + + const matches: string[] = [] + for (const runtimeLine of runtimeLogLines) { + if (matchPatterns.some(pattern => pattern.test(runtimeLine))) { + matches.push(runtimeLine) + } + } + return matches +} diff --git a/scripts/smoke/generateSmokeAudio.ts b/scripts/smoke/generateSmokeAudio.ts new file mode 100644 index 0000000..4c9cddd --- /dev/null +++ b/scripts/smoke/generateSmokeAudio.ts @@ -0,0 +1,141 @@ +import { spawnSync } from 'node:child_process' +import { mkdir, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' + +type SmokeAudioPrompt = { + fileName: string + languageCode: 'en' | 'es' + text: string +} + +const smokeAudioPrompts: SmokeAudioPrompt[] = [ + { + fileName: 'en_smoke', + languageCode: 'en', + text: + 'Hello everyone. This is a Lilac translation smoke test. We are validating live production conversation flow.' + }, + { + fileName: 'es_smoke', + languageCode: 'es', + text: + 'Hola a todos. Esta es una prueba de humo de traduccion de Lilac. Estamos validando el flujo de conversacion en produccion.' + } +] + +const fixturesDirectoryPath = resolve(process.cwd(), '.artifacts/smoke/fixtures') + +function requireOpenAiApiKey(): string { + const openAiApiKey = process.env.OPENAI_API_KEY?.trim() + if (!openAiApiKey) { + throw new Error('OPENAI_API_KEY is required for smoke audio generation.') + } + return openAiApiKey +} + +function runFfmpeg(args: string[]): void { + const ffmpegResult = spawnSync('ffmpeg', args, { encoding: 'utf8' }) + if (ffmpegResult.status !== 0) { + throw new Error(`ffmpeg failed: ${ffmpegResult.stderr || ffmpegResult.stdout}`) + } +} + +async function generatePromptAudio(prompt: SmokeAudioPrompt, openAiApiKey: string): Promise { + const mp3OutputPath = join(fixturesDirectoryPath, `${prompt.fileName}.mp3`) + const wavOutputPath = join(fixturesDirectoryPath, `${prompt.fileName}.wav`) + + const ttsResponse = await fetch('https://api.openai.com/v1/audio/speech', { + body: JSON.stringify({ + input: prompt.text, + model: 'gpt-4o-mini-tts', + voice: 'alloy' + }), + headers: { + Authorization: `Bearer ${openAiApiKey}`, + 'Content-Type': 'application/json' + }, + method: 'POST' + }) + + if (!ttsResponse.ok) { + const errorText = await ttsResponse.text() + throw new Error(`TTS generation failed for ${prompt.fileName}: ${errorText}`) + } + + const audioBuffer = Buffer.from(await ttsResponse.arrayBuffer()) + await writeFile(mp3OutputPath, audioBuffer) + + runFfmpeg([ + '-y', + '-i', + mp3OutputPath, + '-ar', + '48000', + '-ac', + '1', + '-sample_fmt', + 's16', + wavOutputPath + ]) +} + +function buildConversationFixture(): void { + const conversationOutputPath = join(fixturesDirectoryPath, 'conversation_smoke.wav') + const englishWavPath = join(fixturesDirectoryPath, 'en_smoke.wav') + const spanishWavPath = join(fixturesDirectoryPath, 'es_smoke.wav') + + runFfmpeg([ + '-y', + '-f', + 'lavfi', + '-i', + 'anullsrc=r=48000:cl=mono:d=4', + '-i', + englishWavPath, + '-f', + 'lavfi', + '-i', + 'anullsrc=r=48000:cl=mono:d=3', + '-i', + spanishWavPath, + '-f', + 'lavfi', + '-i', + 'anullsrc=r=48000:cl=mono:d=3', + '-i', + englishWavPath, + '-f', + 'lavfi', + '-i', + 'anullsrc=r=48000:cl=mono:d=3', + '-i', + spanishWavPath, + '-filter_complex', + '[0:a][1:a][2:a][3:a][4:a][5:a][6:a]concat=n=7:v=0:a=1', + '-ar', + '48000', + '-ac', + '1', + '-sample_fmt', + 's16', + conversationOutputPath + ]) +} + +async function main(): Promise { + const openAiApiKey = requireOpenAiApiKey() + await mkdir(fixturesDirectoryPath, { recursive: true }) + + for (const prompt of smokeAudioPrompts) { + await generatePromptAudio(prompt, openAiApiKey) + } + + buildConversationFixture() + + console.log(`Smoke fixtures generated in ${fixturesDirectoryPath}`) +} + +void main().catch(error => { + console.error(error instanceof Error ? error.message : 'Smoke audio generation failed.') + process.exit(1) +}) diff --git a/scripts/smoke/runLiveConversationSmoke.ts b/scripts/smoke/runLiveConversationSmoke.ts new file mode 100644 index 0000000..700e389 --- /dev/null +++ b/scripts/smoke/runLiveConversationSmoke.ts @@ -0,0 +1,480 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { type Browser, type BrowserContext, chromium, type Page } from 'playwright' + +import { + collectRuntimeErrorMatches, + type RuntimeLogCapture, + startVercelRuntimeLogCapture +} from './captureVercelRuntimeLogs' + +type SmokeMode = 'chat' | 'translate' + +type SmokeRunOptions = { + audioPath: string + captureVercelLogs: boolean + runChatScenario: boolean + targetUrl: string +} + +type ScenarioResult = { + failures: string[] + mode: SmokeMode +} + +const genericServerComponentErrorText = + 'An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details.' +const missingSessionTypeErrorText = "Missing required parameter: 'session.type'." +const invalidItemIdErrorText = "Invalid 'item.id':" +const missingToolCallErrorTextList = [ + 'No valid publish_translation tool call was returned.', + 'No publish_translation tool call was returned for the completed turn.' +] +const trackedProtocolErrorTextList = [ + missingSessionTypeErrorText, + invalidItemIdErrorText, + ...missingToolCallErrorTextList +] + +const mobileViewports = [ + { height: 812, width: 375 }, + { height: 844, width: 390 } +] as const + +function parseArguments(argumentList: string[]): SmokeRunOptions { + const parsedOptions: SmokeRunOptions = { + audioPath: resolve(process.cwd(), '.artifacts/smoke/fixtures/conversation_smoke.wav'), + captureVercelLogs: true, + runChatScenario: true, + targetUrl: 'https://lilac.chat' + } + + for (let argumentIndex = 0; argumentIndex < argumentList.length; argumentIndex += 1) { + const argument = argumentList[argumentIndex] + switch (argument) { + case '--target-url': { + parsedOptions.targetUrl = argumentList[argumentIndex + 1] ?? parsedOptions.targetUrl + argumentIndex += 1 + continue + } + case '--audio-path': { + parsedOptions.audioPath = resolve(argumentList[argumentIndex + 1] ?? parsedOptions.audioPath) + argumentIndex += 1 + continue + } + case '--skip-vercel-logs': { + parsedOptions.captureVercelLogs = false + continue + } + case '--skip-chat': { + parsedOptions.runChatScenario = false + continue + } + default: + continue + } + } + + return parsedOptions +} + +function createTimestampLabel(): string { + return new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-') +} + +async function waitForCondition( + condition: () => Promise, + timeoutMilliseconds: number, + errorMessage: string +): Promise { + const startTime = Date.now() + while (Date.now() - startTime <= timeoutMilliseconds) { + const passed = await condition() + if (passed) return + await new Promise(resolveTimeout => setTimeout(resolveTimeout, 350)) + } + throw new Error(errorMessage) +} + +async function waitForConnectionLive(page: Page): Promise { + await waitForCondition( + async function hasLiveBadge(): Promise { + const badgeText = await page.getByTestId('connection-state-badge').innerText() + return badgeText.toLowerCase().includes('live') + }, + 45_000, + 'Connection did not become live within timeout.' + ) +} + +async function switchToModeWithRetry( + page: Page, + targetMode: 'chat' | 'translate', + maxAttempts = 3 +): Promise { + const triggerTestId = targetMode === 'chat' ? 'mode-tab-chat' : 'mode-tab-translate' + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + await page.getByTestId(triggerTestId).click({ timeout: 15_000 }) + try { + await waitForCondition( + async function hasTargetModeBadge(): Promise { + const modeBadgeText = await page.getByTestId('mode-active-badge').innerText() + return modeBadgeText.toLowerCase().includes(targetMode) + }, + 8_000, + `Mode switch to ${targetMode} did not complete.` + ) + return + } catch { + if (attempt === maxAttempts) { + throw new Error(`Mode switch to ${targetMode} did not complete.`) + } + } + } +} + +async function writeScenarioArtifacts( + page: Page, + artifactDirectoryPath: string, + mode: SmokeMode +): Promise { + const modeDirectoryPath = join(artifactDirectoryPath, mode) + await mkdir(modeDirectoryPath, { recursive: true }) + const screenshotPath = join(modeDirectoryPath, 'screenshot.png') + const bodyTextPath = join(modeDirectoryPath, 'body.txt') + const htmlPath = join(modeDirectoryPath, 'page.html') + + await page.screenshot({ fullPage: true, path: screenshotPath }) + const bodyText = await page.locator('body').innerText() + await writeFile(bodyTextPath, bodyText, 'utf8') + await writeFile(htmlPath, await page.content(), 'utf8') + + return bodyText +} + +function collectProtocolErrorText(bodyText: string): string[] { + return trackedProtocolErrorTextList.filter(errorText => bodyText.includes(errorText)) +} + +async function clickSliderByRatio(page: Page, testId: string, ratio: number): Promise { + const sliderLocator = page.getByTestId(testId) + await sliderLocator.waitFor({ state: 'visible', timeout: 15_000 }) + const sliderBox = await sliderLocator.boundingBox() + if (!sliderBox) throw new Error(`Unable to locate slider bounds for ${testId}.`) + + const normalizedRatio = Math.max(0, Math.min(1, ratio)) + const clickX = sliderBox.x + sliderBox.width * normalizedRatio + const clickY = sliderBox.y + sliderBox.height / 2 + await page.mouse.click(clickX, clickY) +} + +async function selectByLabel( + page: Page, + triggerTestId: string, + optionLabel: string +): Promise { + await page.getByTestId(triggerTestId).click() + const optionByRole = page.getByRole('option', { name: optionLabel }).first() + if (await optionByRole.count()) { + await optionByRole.click() + return + } + await page.locator('[data-radix-collection-item]').filter({ hasText: optionLabel }).first().click() +} + +async function runChatScenario(page: Page, artifactDirectoryPath: string): Promise { + const failures: string[] = [] + const typedMessage = `typed smoke ${Date.now()}` + try { + await switchToModeWithRetry(page, 'chat') + await waitForConnectionLive(page) + + await page.getByTestId('chat-settings-open-desktop').click() + await clickSliderByRatio(page, 'chat-turn-delay-slider', 0.68) + await page.waitForTimeout(800) + + const bodyTextAfterSliderUpdate = await page.locator('body').innerText() + if (bodyTextAfterSliderUpdate.includes(missingSessionTypeErrorText)) { + throw new Error('Chat slider interaction triggered session.type error.') + } + + await page.keyboard.press('Escape') + await page.getByTestId('chat-text-input').fill(typedMessage) + await page.getByTestId('chat-text-send').click() + + await waitForCondition( + async function hasVisibleTypedMessage(): Promise { + const visibleCount = await page + .locator('[data-testid^="chat-message-text-"]') + .filter({ hasText: typedMessage }) + .count() + return visibleCount > 0 + }, + 20_000, + 'Typed chat message was not appended to visible transcript.' + ) + + await waitForCondition( + async function hasAssistantMessage(): Promise { + const messageTextList = await page + .locator('[data-testid^="chat-message-text-"]') + .allInnerTexts() + return messageTextList.some(messageText => !messageText.includes(typedMessage)) + }, + 35_000, + 'Chat mode did not produce a follow-up assistant transcript.' + ) + } catch (error) { + failures.push(error instanceof Error ? error.message : 'Chat mode validation failed.') + } + + const bodyText = await writeScenarioArtifacts(page, artifactDirectoryPath, 'chat') + if (bodyText.includes(genericServerComponentErrorText)) { + failures.push('Chat mode rendered generic Server Components error text.') + } + const protocolErrors = collectProtocolErrorText(bodyText) + for (const protocolError of protocolErrors) { + failures.push(`Chat mode rendered protocol error text: ${protocolError}`) + } + + return { + failures, + mode: 'chat' + } +} + +async function runTranslateScenario( + page: Page, + artifactDirectoryPath: string +): Promise { + const failures: string[] = [] + let connectionFailureMessage: null | string = null + try { + await switchToModeWithRetry(page, 'translate') + try { + await waitForConnectionLive(page) + } catch (error) { + connectionFailureMessage = + error instanceof Error ? error.message : 'Translate mode connection did not become live.' + } + + await selectByLabel(page, 'translate-secondary-language', 'French') + await page.waitForTimeout(1000) + const bodyTextAfterLanguageChange = await page.locator('body').innerText() + if (bodyTextAfterLanguageChange.includes(missingSessionTypeErrorText)) { + throw new Error('Translate language change triggered session.type error.') + } + + await waitForCondition( + async function hasTranslateCard(): Promise { + const cardCount = await page.locator('[data-testid^="translate-card-"]').count() + if (cardCount === 0) return false + const targetText = await page + .locator('[data-testid^="translate-card-target-"]') + .first() + .innerText() + return targetText.trim().length > 0 + }, + 45_000, + 'Translate mode did not produce translation card output.' + ) + } catch (error) { + failures.push(error instanceof Error ? error.message : 'Translate mode validation failed.') + } + if (failures.length === 0 && connectionFailureMessage) { + console.warn(connectionFailureMessage) + } + + const bodyText = await writeScenarioArtifacts(page, artifactDirectoryPath, 'translate') + if (bodyText.includes(genericServerComponentErrorText)) { + failures.push('Translate mode rendered generic Server Components error text.') + } + const protocolErrors = collectProtocolErrorText(bodyText) + for (const protocolError of protocolErrors) { + failures.push(`Translate mode rendered protocol error text: ${protocolError}`) + } + + return { + failures, + mode: 'translate' + } +} + +async function runMobileThemeChecks( + browser: Browser, + artifactDirectoryPath: string, + targetUrl: string +): Promise { + const failures: string[] = [] + const layoutDirectoryPath = join(artifactDirectoryPath, 'layout') + await mkdir(layoutDirectoryPath, { recursive: true }) + + for (const colorScheme of ['light', 'dark'] as const) { + for (const viewport of mobileViewports) { + let context: BrowserContext | null = null + try { + context = await browser.newContext({ + colorScheme, + permissions: ['microphone'], + viewport + }) + const page = await context.newPage() + await page.goto(targetUrl, { timeout: 120_000, waitUntil: 'domcontentloaded' }) + + await switchToModeWithRetry(page, 'translate') + + const hasHorizontalOverflow = await page.evaluate(function detectOverflow(): boolean { + return document.documentElement.scrollWidth > window.innerWidth + 1 + }) + const bodyText = await page.locator('body').innerText() + + const screenshotPath = join( + layoutDirectoryPath, + `${colorScheme}-${viewport.width}x${viewport.height}.png` + ) + await page.screenshot({ fullPage: true, path: screenshotPath }) + + if (hasHorizontalOverflow) { + failures.push( + `layout-${colorScheme}-${viewport.width}x${viewport.height}: horizontal overflow detected` + ) + } + if (bodyText.includes(genericServerComponentErrorText)) { + failures.push( + `layout-${colorScheme}-${viewport.width}x${viewport.height}: generic Server Components error text rendered` + ) + } + for (const protocolError of collectProtocolErrorText(bodyText)) { + failures.push( + `layout-${colorScheme}-${viewport.width}x${viewport.height}: protocol error text rendered: ${protocolError}` + ) + } + } catch (error) { + failures.push( + `layout-${colorScheme}-${viewport.width}x${viewport.height}: ${ + error instanceof Error ? error.message : 'layout check failed' + }` + ) + } finally { + if (context) await context.close() + } + } + } + + return failures +} + +async function resolveRuntimeMatches( + logCapture: null | RuntimeLogCapture +): Promise<{ matches: string[]; stderrTail: string }> { + if (!logCapture) return { matches: [], stderrTail: '' } + + const matches = await collectRuntimeErrorMatches(logCapture.stdoutPath) + const stderrContent = await readFile(logCapture.stderrPath, 'utf8') + const stderrLines = stderrContent + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .slice(-20) + + return { + matches, + stderrTail: stderrLines.join('\n') + } +} + +async function main(): Promise { + const options = parseArguments(process.argv.slice(2)) + const artifactDirectoryPath = resolve(process.cwd(), '.artifacts/smoke', createTimestampLabel()) + await mkdir(artifactDirectoryPath, { recursive: true }) + await mkdir(dirname(options.audioPath), { recursive: true }) + + let logCapture: null | RuntimeLogCapture = null + if (options.captureVercelLogs) { + const runtimeCaptureInput = { + artifactDirectoryPath, + deploymentDomain: new URL(options.targetUrl).host, + ...(process.env.VERCEL_TOKEN ? { vercelToken: process.env.VERCEL_TOKEN } : {}) + } + logCapture = await startVercelRuntimeLogCapture({ + ...runtimeCaptureInput + }) + } + + const browser = await chromium.launch({ + args: [ + '--use-fake-ui-for-media-stream', + '--use-fake-device-for-media-stream', + `--use-file-for-fake-audio-capture=${options.audioPath}` + ], + headless: true + }) + + const failureMessages: string[] = [] + const scenarioResults: ScenarioResult[] = [] + + try { + const pageContext = await browser.newContext({ + permissions: ['microphone'] + }) + const page = await pageContext.newPage() + await page.goto(options.targetUrl, { timeout: 120_000, waitUntil: 'domcontentloaded' }) + + if (options.runChatScenario) { + scenarioResults.push(await runChatScenario(page, artifactDirectoryPath)) + } + scenarioResults.push(await runTranslateScenario(page, artifactDirectoryPath)) + await pageContext.close() + + const layoutFailures = await runMobileThemeChecks( + browser, + artifactDirectoryPath, + options.targetUrl + ) + failureMessages.push(...layoutFailures) + } finally { + await browser.close() + if (logCapture) await logCapture.stop() + } + + for (const scenarioResult of scenarioResults) { + for (const failureMessage of scenarioResult.failures) { + failureMessages.push(`${scenarioResult.mode}: ${failureMessage}`) + } + } + + const runtimeSummary = await resolveRuntimeMatches(logCapture) + for (const runtimeMatch of runtimeSummary.matches) { + failureMessages.push(`runtime-log: ${runtimeMatch}`) + } + + const summary = { + artifactDirectoryPath, + failures: failureMessages, + runtimeLog: logCapture + ? { + stderrPath: logCapture.stderrPath, + stderrTail: runtimeSummary.stderrTail, + stdoutPath: logCapture.stdoutPath + } + : null, + scenarios: scenarioResults + } + + await writeFile( + join(artifactDirectoryPath, 'summary.json'), + JSON.stringify(summary, null, 2), + 'utf8' + ) + console.log(JSON.stringify(summary, null, 2)) + + if (failureMessages.length > 0) { + throw new Error(`Smoke run failed with ${failureMessages.length} failure(s).`) + } +} + +void main().catch(error => { + console.error(error instanceof Error ? error.message : 'Smoke runner failed.') + process.exit(1) +}) diff --git a/src/app/(app)/ChatMode.tsx b/src/app/(app)/ChatMode.tsx new file mode 100644 index 0000000..cb3f86c --- /dev/null +++ b/src/app/(app)/ChatMode.tsx @@ -0,0 +1,416 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { ScrollArea } from '@/components/ui/scroll-area' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger +} from '@/components/ui/sheet' +import { Slider } from '@/components/ui/slider' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { useLilacModeRuntime } from '@/realtime/modeRuntimeStore' + +const defaultChatInstructions = + 'You are Lilac. Help users communicate across languages. Keep answers concise, faithful, and practical.' + +type ChatSettingsContentProps = { + chatInstructions: string + chatSpeechOutputEnabled: boolean + draftInstructions: string + draftTurnDelaySeconds: number + saveMessage: string + setChatInstructions: (instructions: string) => void + setChatSpeechOutputEnabled: (enabled: boolean) => void + setChatTurnDelaySeconds: (seconds: number) => void + setDraftInstructions: (instructions: string) => void + setDraftTurnDelaySeconds: (seconds: number) => void + setSaveMessage: (value: string) => void + voiceInputEnabled: boolean +} + +function normalizeTurnDelaySeconds(value: number): number { + const clampedValue = Math.min(6, Math.max(0.2, value)) + return Math.round(clampedValue * 10) / 10 +} + +function ChatSettingsContent({ + chatInstructions, + chatSpeechOutputEnabled, + draftInstructions, + draftTurnDelaySeconds, + saveMessage, + setChatInstructions, + setChatSpeechOutputEnabled, + setChatTurnDelaySeconds, + setDraftInstructions, + setDraftTurnDelaySeconds, + setSaveMessage, + voiceInputEnabled +}: ChatSettingsContentProps) { + return ( +
+
+
+
+
+ Speech output +
+

+ Voice input is {voiceInputEnabled ? 'enabled' : 'disabled'} globally. +

+
+ setChatSpeechOutputEnabled(checked)} + data-testid="chat-speech-output-toggle" + className="data-[state=checked]:bg-[var(--lilac-direction-primary)] data-[state=unchecked]:bg-[var(--lilac-border)]" + /> +
+ +
+
+ End-of-speech delay +
+ { + const nextValue = valueList[0] + if (typeof nextValue !== 'number') return + setDraftTurnDelaySeconds(normalizeTurnDelaySeconds(nextValue)) + }} + onValueCommit={valueList => { + const nextValue = valueList[0] + if (typeof nextValue !== 'number') return + const normalizedValue = normalizeTurnDelaySeconds(nextValue) + setDraftTurnDelaySeconds(normalizedValue) + setChatTurnDelaySeconds(normalizedValue) + }} + data-testid="chat-turn-delay-slider" + className="[&_[data-slot=slider-range]]:bg-[var(--lilac-brand-primary)]" + /> +
+ Delay + + {draftTurnDelaySeconds.toFixed(1)}s + +
+
+
+ +
+
+ Instructions +
+