← back to Dear Bubbe Nextjs

tests/test-input-modes.js

400 lines

#!/usr/bin/env node
/**
 * Comprehensive Test Suite for Talk/Text/Both Input Modes
 * Tests all three modes and verifies functionality
 */

const puppeteer = require('puppeteer');
const chalk = require('chalk');

// Test configuration
const TEST_URL = 'http://localhost:3011';
const MOBILE_VIEWPORT = { width: 375, height: 812 }; // iPhone X viewport
const TABLET_VIEWPORT = { width: 768, height: 1024 }; // iPad viewport
const DESKTOP_VIEWPORT = { width: 1920, height: 1080 }; // Desktop viewport

// Test utilities
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));

const log = {
  info: (msg) => console.log(chalk.blue('ℹ'), msg),
  success: (msg) => console.log(chalk.green('✓'), msg),
  error: (msg) => console.log(chalk.red('✗'), msg),
  warning: (msg) => console.log(chalk.yellow('⚠'), msg),
  section: (msg) => console.log(chalk.bold.cyan('\n' + '='.repeat(60) + '\n' + msg + '\n' + '='.repeat(60)))
};

// Test results tracker
const results = {
  passed: [],
  failed: [],
  warnings: []
};

async function runTest(name, testFn) {
  try {
    await testFn();
    results.passed.push(name);
    log.success(`${name} - PASSED`);
    return true;
  } catch (error) {
    results.failed.push({ name, error: error.message });
    log.error(`${name} - FAILED: ${error.message}`);
    return false;
  }
}

async function testInputModes() {
  let browser;
  let page;

  try {
    log.section('STARTING INPUT MODE TESTS');
    log.info('Launching browser...');

    browser = await puppeteer.launch({
      headless: 'new',
      args: [
        '--no-sandbox',
        '--disable-setuid-sandbox',
        '--disable-web-security',
        '--disable-features=IsolateOrigins,site-per-process',
        '--use-fake-ui-for-media-stream',
        '--use-fake-device-for-media-stream'
      ]
    });

    page = await browser.newPage();

    // Grant microphone permissions
    const context = browser.defaultBrowserContext();
    await context.overridePermissions(TEST_URL, ['microphone']);

    // Test 1: Mobile viewport tests
    log.section('TEST 1: MOBILE VIEWPORT (375x812)');
    await page.setViewport(MOBILE_VIEWPORT);
    await page.goto(TEST_URL, { waitUntil: 'networkidle2' });

    // Test mode selector visibility
    await runTest('Mobile: Mode selector visible', async () => {
      const selector = await page.$('.mode-selector-container');
      if (!selector) throw new Error('Mode selector not found');
    });

    // Test all three mode buttons exist
    await runTest('Mobile: All three mode buttons exist', async () => {
      const buttons = await page.$$('.mode-button');
      if (buttons.length !== 3) throw new Error(`Expected 3 mode buttons, found ${buttons.length}`);
    });

    // Test Talk mode
    await runTest('Mobile: Talk mode functionality', async () => {
      // Click Talk button
      await page.click('.mode-button:nth-child(1)');
      await wait(1000);

      // Verify Talk mode is active
      const talkActive = await page.$eval('.mode-button:nth-child(1)', el => el.classList.contains('active'));
      if (!talkActive) throw new Error('Talk button not active');

      // Verify text input is hidden
      const textInput = await page.$('.text-input-container');
      if (textInput) throw new Error('Text input should be hidden in Talk mode');

      // Verify voice manager is visible
      const voiceManager = await page.$('.voice-manager-mobile');
      if (!voiceManager) throw new Error('Voice manager should be visible in Talk mode');

      // Verify status shows voice-related message
      const statusText = await page.$eval('.mobile-status-bar', el => el.textContent);
      if (!statusText.includes('Ready to talk') && !statusText.includes('Waking') && !statusText.includes('Listening')) {
        throw new Error(`Unexpected status in Talk mode: ${statusText}`);
      }
    });

    // Test Text mode
    await runTest('Mobile: Text mode functionality', async () => {
      // Click Text button
      await page.click('.mode-button:nth-child(2)');
      await wait(1000);

      // Verify Text mode is active
      const textActive = await page.$eval('.mode-button:nth-child(2)', el => el.classList.contains('active'));
      if (!textActive) throw new Error('Text button not active');

      // Verify text input is visible
      const textInput = await page.$('.text-input-container');
      if (!textInput) throw new Error('Text input should be visible in Text mode');

      // Verify voice manager is hidden
      const voiceSection = await page.$('.mobile-voice-section');
      if (voiceSection) throw new Error('Voice section should be hidden in Text mode');

      // Verify status shows text-related message
      const statusText = await page.$eval('.mobile-status-bar', el => el.textContent);
      if (!statusText.includes('Type your message') && !statusText.includes('thinking')) {
        throw new Error(`Unexpected status in Text mode: ${statusText}`);
      }
    });

    // Test Both mode
    await runTest('Mobile: Both mode functionality', async () => {
      // Click Both button
      await page.click('.mode-button:nth-child(3)');
      await wait(1000);

      // Verify Both mode is active
      const bothActive = await page.$eval('.mode-button:nth-child(3)', el => el.classList.contains('active'));
      if (!bothActive) throw new Error('Both button not active');

      // Verify text input is visible
      const textInput = await page.$('.text-input-container');
      if (!textInput) throw new Error('Text input should be visible in Both mode');

      // Verify voice manager is visible
      const voiceManager = await page.$('.voice-manager-mobile');
      if (!voiceManager) throw new Error('Voice manager should be visible in Both mode');

      // Verify status shows combined message
      const statusText = await page.$eval('.mobile-status-bar', el => el.textContent);
      if (!statusText.includes('Talk or type') && !statusText.includes('Listening') && !statusText.includes('Processing')) {
        throw new Error(`Unexpected status in Both mode: ${statusText}`);
      }
    });

    // Test text input functionality
    await runTest('Mobile: Text input functionality', async () => {
      // Ensure we're in Text mode
      await page.click('.mode-button:nth-child(2)');
      await wait(1000);

      // Type a message
      await page.type('.text-input-field', 'Test message from Puppeteer');
      
      // Verify text was entered
      const inputValue = await page.$eval('.text-input-field', el => el.value);
      if (inputValue !== 'Test message from Puppeteer') {
        throw new Error(`Input value mismatch: ${inputValue}`);
      }

      // Click send button
      await page.click('.text-send-button');
      await wait(2000);

      // Verify message appears in chat
      const messages = await page.$$('.mobile-message-bubble-user');
      if (messages.length === 0) throw new Error('No user messages found in chat');
      
      const lastMessage = await page.evaluate(
        el => el.textContent,
        messages[messages.length - 1]
      );
      if (!lastMessage.includes('Test message')) {
        throw new Error(`Message not found in chat: ${lastMessage}`);
      }
    });

    // Test 2: Tablet viewport tests
    log.section('TEST 2: TABLET VIEWPORT (768x1024)');
    await page.setViewport(TABLET_VIEWPORT);
    await page.reload({ waitUntil: 'networkidle2' });

    await runTest('Tablet: Mode selector responsive', async () => {
      const selector = await page.$('.mode-selector-container');
      if (!selector) throw new Error('Mode selector not found on tablet');
      
      // Check button sizing
      const buttonWidth = await page.$eval('.mode-button', el => 
        window.getComputedStyle(el).width
      );
      const width = parseInt(buttonWidth);
      if (width > 120 || width < 80) {
        throw new Error(`Button width out of range on tablet: ${width}px`);
      }
    });

    // Test 3: Desktop viewport tests
    log.section('TEST 3: DESKTOP VIEWPORT (1920x1080)');
    await page.setViewport(DESKTOP_VIEWPORT);
    await page.reload({ waitUntil: 'networkidle2' });

    await runTest('Desktop: Debug controls visible', async () => {
      const debugControls = await page.$('.mobile-debug-controls');
      if (!debugControls) throw new Error('Debug controls should be visible on desktop');
      
      // Check if debug controls are displayed
      const display = await page.$eval('.mobile-debug-controls', el => 
        window.getComputedStyle(el).display
      );
      if (display === 'none') {
        throw new Error('Debug controls should be displayed on desktop');
      }
    });

    // Test 4: UI overlap checks
    log.section('TEST 4: UI OVERLAP CHECKS');
    await page.setViewport(MOBILE_VIEWPORT);
    await page.reload({ waitUntil: 'networkidle2' });

    await runTest('Mobile: No overlapping elements', async () => {
      // Get bounding boxes of key elements
      const elements = {
        header: await page.$('.mobile-header-section'),
        modeSelector: await page.$('.mobile-mode-selector-section'),
        voiceSection: await page.$('.mobile-voice-section'),
        chatContainer: await page.$('.mobile-chat-container'),
        textInput: await page.$('.mobile-text-input-section'),
        statusBar: await page.$('.mobile-status-bar')
      };

      const boxes = {};
      for (const [name, element] of Object.entries(elements)) {
        if (element) {
          boxes[name] = await element.boundingBox();
        }
      }

      // Check for overlaps
      const checkOverlap = (box1, box2) => {
        if (!box1 || !box2) return false;
        return !(box1.x + box1.width <= box2.x ||
                 box2.x + box2.width <= box1.x ||
                 box1.y + box1.height <= box2.y ||
                 box2.y + box2.height <= box1.y);
      };

      const overlaps = [];
      const keys = Object.keys(boxes);
      for (let i = 0; i < keys.length; i++) {
        for (let j = i + 1; j < keys.length; j++) {
          if (checkOverlap(boxes[keys[i]], boxes[keys[j]])) {
            overlaps.push(`${keys[i]} overlaps with ${keys[j]}`);
          }
        }
      }

      if (overlaps.length > 0) {
        throw new Error(`Elements overlapping: ${overlaps.join(', ')}`);
      }
    });

    // Test 5: Mode switching transitions
    log.section('TEST 5: MODE SWITCHING TRANSITIONS');

    await runTest('Mode switching is smooth', async () => {
      // Switch between modes rapidly
      for (let i = 0; i < 3; i++) {
        await page.click('.mode-button:nth-child(1)'); // Talk
        await wait(300);
        await page.click('.mode-button:nth-child(2)'); // Text
        await wait(300);
        await page.click('.mode-button:nth-child(3)'); // Both
        await wait(300);
      }

      // Verify final state is Both mode
      const bothActive = await page.$eval('.mode-button:nth-child(3)', el => el.classList.contains('active'));
      if (!bothActive) throw new Error('Both mode should be active after switching');
    });

    // Test 6: Accessibility checks
    log.section('TEST 6: ACCESSIBILITY CHECKS');

    await runTest('All interactive elements have proper ARIA labels', async () => {
      const interactiveElements = await page.$$('button, input, textarea');
      const missingLabels = [];

      for (const element of interactiveElements) {
        const hasAriaLabel = await element.evaluate(el => 
          el.getAttribute('aria-label') || el.getAttribute('title')
        );
        if (!hasAriaLabel) {
          const className = await element.evaluate(el => el.className);
          missingLabels.push(className);
        }
      }

      if (missingLabels.length > 0) {
        log.warning(`Elements missing ARIA labels: ${missingLabels.join(', ')}`);
        // Don't fail the test, just warn
      }
    });

    await runTest('Touch targets are adequate size', async () => {
      await page.setViewport(MOBILE_VIEWPORT);
      const buttons = await page.$$('button');
      const smallButtons = [];

      for (const button of buttons) {
        const box = await button.boundingBox();
        if (box && (box.width < 44 || box.height < 44)) {
          const className = await button.evaluate(el => el.className);
          smallButtons.push(`${className}: ${box.width}x${box.height}`);
        }
      }

      if (smallButtons.length > 0) {
        throw new Error(`Buttons below 44x44px minimum: ${smallButtons.join(', ')}`);
      }
    });

  } catch (error) {
    log.error(`Test suite error: ${error.message}`);
    results.failed.push({ name: 'Test Suite', error: error.message });
  } finally {
    if (browser) {
      await browser.close();
    }
  }

  // Print summary
  log.section('TEST RESULTS SUMMARY');
  log.info(`Total tests: ${results.passed.length + results.failed.length}`);
  log.success(`Passed: ${results.passed.length}`);
  
  if (results.failed.length > 0) {
    log.error(`Failed: ${results.failed.length}`);
    console.log('\nFailed tests:');
    results.failed.forEach(({ name, error }) => {
      console.log(`  - ${name}: ${error}`);
    });
  }

  if (results.warnings.length > 0) {
    log.warning(`Warnings: ${results.warnings.length}`);
  }

  // Exit with appropriate code
  process.exit(results.failed.length > 0 ? 1 : 0);
}

// Check if server is running before starting tests
async function checkServer() {
  const http = require('http');
  return new Promise((resolve) => {
    http.get(TEST_URL, (res) => {
      resolve(res.statusCode === 200);
    }).on('error', () => {
      resolve(false);
    });
  });
}

async function main() {
  log.section('INPUT MODE TEST SUITE');
  log.info('Testing Talk/Text/Both modes on Dear Bubbe...');

  // Check if server is running
  const serverRunning = await checkServer();
  if (!serverRunning) {
    log.error(`Server not running at ${TEST_URL}`);
    log.info('Please start the server with: pm2 start bubbe');
    process.exit(1);
  }

  // Run tests
  await testInputModes();
}

// Run the test suite
main().catch(console.error);