← back to Small Business Builder

_qa/click-through.mjs

176 lines

// Browserbase click-through QA on https://builds.agentabrams.com/builds
// Captures 10 screenshots + console errors + network anomalies + final findings.
//
// Run: node _qa/click-through.mjs

import 'dotenv/config';
import dotenv from 'dotenv';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { chromium } from 'playwright-core';
import Browserbase from '@browserbasehq/sdk';

dotenv.config({ path: path.join(os.homedir(), '.claude/skills/browserbase/.env') });

const PROJECT = process.env.BROWSERBASE_PROJECT_ID;
const KEY = process.env.BROWSERBASE_API_KEY;
if (!PROJECT || !KEY) { console.error('missing browserbase env'); process.exit(1); }

const URL = 'https://builds.agentabrams.com/builds';
const AUTH_B64 = Buffer.from('admin:DWSecure2024!').toString('base64');
const OUT = path.join(process.cwd(), '_qa', '2026-05-05-builds');
fs.mkdirSync(OUT, { recursive: true });

const findings = [];
const consoleErrs = [];
const nav = [];

const bb = new Browserbase({ apiKey: KEY });

let trackedEnd = () => {};
async function startSession() {
  try {
    const helper = await import(path.join(os.homedir(), '.claude/skills/browserbase/track-session.js'));
    const wrapped = await helper.trackedSession(bb, PROJECT, { app: 'sbb-qa-builds', note: URL });
    trackedEnd = wrapped.end;
    return wrapped.session;
  } catch {
    return bb.sessions.create({ projectId: PROJECT });
  }
}

(async () => {
  const session = await startSession();
  console.log(`session=${session.id}\nlive=${session.connectUrl}`);
  console.log(`debug→ ${session.seleniumRemoteUrl ? '' : ''}https://www.browserbase.com/sessions/${session.id}`);

  const browser = await chromium.connectOverCDP(session.connectUrl);
  const ctx = browser.contexts()[0];
  const page = ctx.pages()[0] || await ctx.newPage();

  page.on('console', msg => {
    if (msg.type() === 'error' || msg.type() === 'warning') {
      consoleErrs.push({ type: msg.type(), text: msg.text().slice(0, 240) });
    }
  });
  page.on('pageerror', err => consoleErrs.push({ type: 'pageerror', text: String(err.message).slice(0, 240), stack: String(err.stack || '').slice(0, 600) }));
  page.on('framenavigated', frame => { if (frame === page.mainFrame()) nav.push(frame.url()); });

  await page.setViewportSize({ width: 1440, height: 900 });
  // ★ Send basic-auth header on every request so JS-side fetch() inherits creds.
  // URL-embedded user:pass@host is stripped by Chromium for sub-resource fetches,
  // which is why the drawer's /api/wa/.../peek call was failing.
  await page.setExtraHTTPHeaders({ Authorization: 'Basic ' + AUTH_B64 });

  async function shoot(label) {
    const file = path.join(OUT, `${String(findings.length + 1).padStart(2,'0')}-${label}.png`);
    await page.screenshot({ path: file, fullPage: false });
    findings.push({ step: label, file: path.basename(file) });
    console.log(`  ★ ${label} → ${path.basename(file)}`);
  }

  try {
    // 1. login + initial page
    console.log('1. login + load /builds');
    await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
    await page.waitForSelector('#grid', { timeout: 15000 });
    await shoot('initial');

    // 2. type "morning" into filter
    console.log('2. type morning');
    await page.fill('#filter', 'morning');
    await page.waitForTimeout(400);
    await shoot('filter-morning');

    // 3. click RESET
    console.log('3. RESET');
    await page.click('#filter-reset');
    await page.waitForTimeout(400);
    await shoot('after-reset');

    // 4. toggle TOP
    console.log('4. TOP chip');
    await page.click('#filter-top');
    await page.waitForTimeout(400);
    await shoot('top-chip');

    // 5. toggle STALE (auto-deselects TOP)
    console.log('5. STALE chip');
    await page.click('#filter-stale');
    await page.waitForTimeout(400);
    await shoot('stale-chip');

    // reset before peek so we have a card visible
    await page.click('#filter-reset');
    await page.waitForTimeout(400);

    // 6. click first PEEK button (force click since it's hover-revealed)
    console.log('6. PEEK first card');
    const firstPeek = page.locator('.peek-btn').first();
    if (await firstPeek.count() > 0) {
      await firstPeek.click({ force: true });
    } else {
      console.log('  no .peek-btn found — falling back to clicking first card with analysis_slug');
      await page.evaluate(() => {
        const c = document.querySelector('.bcard[data-slug]');
        if (c) window.openPeek(c.dataset.slug);
      });
    }
    await page.waitForSelector('#drawer.is-open', { timeout: 8000 }).catch(() => findings.push({ step: 'drawer-not-opened', issue: true }));
    await page.waitForTimeout(800);
    await shoot('drawer-open');

    // 7. drawer DEEP-DIVE
    console.log('7. drawer DEEP-DIVE');
    const ddBtn = page.locator('#drawer-body form[action$="/deep-dive"] button');
    if (await ddBtn.count() > 0) {
      await ddBtn.click();
      await page.waitForTimeout(2200); // give the synthesis + reload time
      await shoot('drawer-after-dd');
    } else {
      findings.push({ step: 'deep-dive-btn-missing', issue: true });
      await shoot('drawer-no-dd-btn');
    }

    // 8. drawer NEXT
    console.log('8. drawer NEXT');
    await page.click('#drawer-next');
    await page.waitForTimeout(800);
    await shoot('drawer-next');

    // 9. ESC to close
    console.log('9. ESC close');
    await page.keyboard.press('Escape');
    await page.waitForTimeout(400);
    await shoot('drawer-closed');

    // Capture viewport metrics
    const metrics = await page.evaluate(() => ({
      title: document.title,
      gridCards: document.querySelectorAll('#grid .bcard').length,
      visibleCards: Array.from(document.querySelectorAll('#grid .bcard')).filter(c => c.style.display !== 'none').length,
      gradeCounts: ['A','B','C','D','F'].map(g => [g, document.querySelectorAll(`.bcard[data-grade="${g}"]`).length]),
      pageHeight: document.body.scrollHeight,
      pageWidth: document.body.scrollWidth,
    }));
    findings.push({ step: 'metrics', ...metrics });
    console.log('metrics:', metrics);

  } catch (e) {
    console.error('ERROR:', e.message);
    findings.push({ step: 'fatal-error', message: String(e.message).slice(0, 400) });
  } finally {
    await browser.close();
    await trackedEnd();
  }

  fs.writeFileSync(path.join(OUT, 'findings.json'), JSON.stringify({ findings, consoleErrs, nav }, null, 2));
  console.log(`\n=== DONE ===\nfiles: ${OUT}\nconsole-errors: ${consoleErrs.length}\nnav-events: ${nav.length}`);
  if (consoleErrs.length) {
    console.log('\nConsole errors:');
    consoleErrs.slice(0, 10).forEach(e => console.log(`  [${e.type}] ${e.text}`));
  }
  process.exit(0);
})();