/**
 * VL AutoTest - Test Runner v2.4
 * Executes test cases using Playwright browser automation
 *
 * Key improvements for VL SPA apps:
 * - Wait for networkidle after navigation (VL renders via JS)
 * - DOM snapshot after first navigate for debugging
 * - Smarter selector resolution with multiple fallback strategies
 * - Detailed logging for every action
 */

import { chromium } from 'playwright';
import { mkdir } from 'fs/promises';
import { join } from 'path';

function log(tag, msg, data) {
  const ts = new Date().toISOString().slice(11, 23);
  if (data !== undefined) {
    console.log(`[${ts}] [Runner] [${tag}] ${msg}`, typeof data === 'string' ? data.slice(0, 300) : data);
  } else {
    console.log(`[${ts}] [Runner] [${tag}] ${msg}`);
  }
}

export class TestRunner {
  constructor(config = {}) {
    this.browser = null;
    this.context = null;
    this.page = null;
    // IMPORTANT: spread config first, then override with explicit defaults.
    // This prevents `...config` from overwriting headless with undefined.
    this.config = {
      ...config,
      headless: config.headless === true || config.headless === false ? config.headless : false,
      viewport: config.viewport || { width: 1920, height: 1080 },
      baseUrl: config.baseUrl || 'http://localhost:3000',
      slowMo: config.slowMo || 0,
      screenshotDir: config.screenshotDir || './screenshots',
      timeout: config.timeout || 15000,  // 15s per action (not 30)
    };
    this._lastUrl = null; // Track last navigated URL to skip redundant navigations
    this.running = false;
    this.aborted = false;
    this.firstNavigationDone = false;
    this._softPass = false; // Set to true when a step soft-passes (element not found but non-fatal)
  }

  async launch() {
    await mkdir(this.config.screenshotDir, { recursive: true });
    log('Launch', `headless=${this.config.headless}, slowMo=${this.config.slowMo}, timeout=${this.config.timeout}`);

    const chromeProfile = this.config.chromeProfile;
    if (chromeProfile) {
      // Use persistent context with Chrome user profile (keeps cookies, login sessions)
      log('Launch', `Using Chrome profile: ${chromeProfile}`);
      this.context = await chromium.launchPersistentContext(chromeProfile, {
        headless: this.config.headless,
        slowMo: this.config.slowMo,
        viewport: this.config.viewport,
        ignoreHTTPSErrors: true,
        channel: 'chrome',  // Use real Chrome, not Chromium
      });
      this.browser = this.context.browser?.() || this.context; // persistent context IS the browser
      // Always open a fresh page — never reuse existing tabs (they may be the VL-Code IDE itself)
      this.page = await this.context.newPage();
    } else {
      this.browser = await chromium.launch({
        headless: this.config.headless,
        slowMo: this.config.slowMo,
      });
      this.context = await this.browser.newContext({
        viewport: this.config.viewport,
        ignoreHTTPSErrors: true,
      });
      this.page = await this.context.newPage();

      // Inject cookie from workspace settings (if available)
      if (this.config.cookie) {
        try {
          await this.context.addCookies([{
            name: 'ih5bearer',
            value: this.config.cookie,
            domain: '.visuallogic.ai',
            path: '/',
            httpOnly: false,
            secure: true,
          }]);
          log('Launch', 'Injected ih5bearer cookie from workspace settings');
        } catch (e) {
          log('Launch', `Cookie injection failed: ${e.message}`);
        }
      }
    }

    // Auto-handle native browser dialogs (VL <SysUI>.showModal() may produce these)
    // alert → dismiss, confirm → accept, prompt → accept with empty string
    this.page.on('dialog', async (dialog) => {
      const type = dialog.type();
      const msg = dialog.message().slice(0, 120);
      log('Dialog', `Native dialog: type="${type}" msg="${msg}"`);
      try {
        if (type === 'confirm') {
          await dialog.accept();
          log('Dialog', `  ✓ Auto-accepted confirm dialog`);
        } else if (type === 'prompt') {
          await dialog.accept('');
          log('Dialog', `  ✓ Auto-accepted prompt dialog`);
        } else {
          await dialog.dismiss();
          log('Dialog', `  Dismissed ${type} dialog`);
        }
      } catch (e) {
        log('Dialog', `  Dialog handler error: ${e.message}`);
      }
    });

    // Log console messages from the page
    this.page.on('console', msg => {
      if (msg.type() === 'error') {
        log('PageConsole', `[ERROR] ${msg.text()}`);
      }
    });

    // Track page crash/close — auto-recover by creating a new page
    this._pageClosed = false;
    this.page.on('crash', () => {
      log('Page', 'Page crashed — will auto-recover on next step');
      this._pageClosed = true;
    });
    this.page.on('close', () => {
      log('Page', 'Page closed — will auto-recover on next step');
      this._pageClosed = true;
    });

    this.running = true;
    this.aborted = false;
    this.firstNavigationDone = false;
    log('Launch', 'Browser launched');
  }

  async close() {
    this.running = false;
    if (this.browser) {
      await this.browser.close().catch(() => {});
      this.browser = null;
      this.context = null;
      this.page = null;
    }
    log('Close', 'Browser closed');
  }

  abort() {
    this.aborted = true;
    log('Abort', 'Test run aborted — closing page to interrupt active operation');
    // Close the current page immediately so any active Playwright operation
    // throws "Target closed", propagates up, and the step loop exits fast.
    if (this.page) {
      const pg = this.page;
      this.page = null; // null first so the isConnected() check in runAll() breaks the loop
      pg.close().catch(() => {});
    }
  }

  /**
   * Ensure page is alive — recover from crash/close by creating a new page.
   * VL play pages sometimes cause Chromium renderer to crash or trigger navigation
   * that closes the page. This recovers transparently so testing continues.
   */
  async _ensurePage() {
    if (!this._pageClosed && this.page) {
      try {
        // Quick liveness check — if page is dead this will throw
        await this.page.evaluate(() => true).catch(() => { throw new Error('dead'); });
        return;
      } catch {
        this._pageClosed = true;
      }
    }
    if (!this.context) throw new Error('Browser context not available');
    log('Page', 'Recovering: creating new page...');
    const lastUrl = this._lastUrl; // Save before reset
    this.page = await this.context.newPage();
    this._pageClosed = false;
    this.firstNavigationDone = false;
    this._lastUrl = null; // Force re-navigation

    // Re-attach event handlers
    this.page.on('dialog', async (dialog) => {
      try {
        if (dialog.type() === 'confirm') await dialog.accept();
        else if (dialog.type() === 'prompt') await dialog.accept('');
        else await dialog.dismiss();
      } catch {}
    });
    this.page.on('console', msg => {
      if (msg.type() === 'error') log('PageConsole', `[ERROR] ${msg.text()}`);
    });
    this.page.on('crash', () => { this._pageClosed = true; });
    this.page.on('close', () => { this._pageClosed = true; });

    // Re-navigate to the last URL so subsequent steps can continue
    if (lastUrl) {
      log('Page', `Recovery: re-navigating to ${lastUrl}`);
      try {
        await this.page.goto(lastUrl, { waitUntil: 'domcontentloaded', timeout: this.config.timeout });
        await this.page.waitForLoadState('networkidle', { timeout: 6000 }).catch(() => {});
        this._lastUrl = lastUrl;
        log('Page', 'Recovery: re-navigation complete');
      } catch (navErr) {
        log('Page', `Recovery: re-navigation failed: ${navErr.message}`);
      }
    } else {
      log('Page', 'Recovery: new page created (no previous URL to restore)');
    }
  }

  /**
   * Execute a single test case
   */
  async runCase(testCase, onStepProgress) {
    if (!this.page && !this.context) throw new Error('Browser not launched');
    if (this.aborted) throw new Error('Test run aborted');

    const startTime = Date.now();
    log('RunCase', `▶ ${testCase.id}: ${testCase.name} (${testCase.steps.length} steps)`);

    const result = {
      caseId: testCase.id,
      name: testCase.name,
      priority: testCase.priority,
      page: testCase.page,
      status: 'running',
      pass: true,
      steps: [],
      screenshotPaths: [],
      snapshots: [],
      error: null,
      durationMs: 0,
    };

    for (let i = 0; i < testCase.steps.length; i++) {
      if (this.aborted) {
        result.pass = false;
        result.status = 'aborted';
        result.error = 'Test aborted';
        break;
      }

      const step = testCase.steps[i];
      const stepResult = {
        index: i,
        action: step.action,
        selector: step.selector || '',
        value: step.value || '',
        description: step.description || `${step.action} ${step.selector || ''}`,
        status: 'running',
        error: null,
        timestamp: Date.now(),
      };

      onStepProgress?.({
        caseId: testCase.id,
        stepIndex: i,
        totalSteps: testCase.steps.length,
        action: step.action,
        selector: step.selector,
        status: 'running',
        step: stepResult,
      });

      try {
        // Ensure page is alive (auto-recover from crash/close)
        await this._ensurePage();
        log('Step', `  [${i + 1}/${testCase.steps.length}] ${step.action} "${step.selector || step.value || ''}" ${step.description || ''}`);
        await this._executeAction(step);
        if (this._softPass) {
          stepResult.status = 'soft_pass';
          stepResult.error = `Soft pass: element not found or action skipped`;
          log('Step', `  [${i + 1}] ⚠ soft-pass (no actual interaction)`);
        } else {
          stepResult.status = 'passed';
          log('Step', `  [${i + 1}] ✓ passed`);
        }
      } catch (err) {
        stepResult.status = 'failed';
        stepResult.error = err.message;
        result.pass = false;
        result.status = 'failed';
        result.error = `Step ${i + 1} (${step.action} "${step.selector || ''}") failed: ${err.message}`;
        log('Step', `  [${i + 1}] ✗ FAILED: ${err.message}`);

        // Take failure screenshot
        try {
          const ssPath = join(this.config.screenshotDir, `${testCase.id}_step${i}_fail.png`);
          await this.page.screenshot({ path: ssPath, fullPage: false });
          result.screenshotPaths.push(ssPath);
          stepResult.screenshotPath = ssPath;
          log('Step', `  [${i + 1}] Screenshot saved: ${ssPath}`);
        } catch (ssErr) {}

        // Capture DOM snapshot on failure
        try {
          const snapshot = await this._getSnapshot();
          result.snapshots.push({ stepIndex: i, snapshot, type: 'failure' });
        } catch (snapErr) {}
      }

      result.steps.push(stepResult);

      onStepProgress?.({
        caseId: testCase.id,
        stepIndex: i,
        totalSteps: testCase.steps.length,
        action: step.action,
        selector: step.selector,
        status: stepResult.status,
        error: stepResult.error,
        step: stepResult,
      });

      if (stepResult.status === 'failed') break;
    }

    // Take final screenshot
    try {
      const ssPath = join(this.config.screenshotDir, `${testCase.id}_final.png`);
      await this.page.screenshot({ path: ssPath, fullPage: false });
      result.screenshotPaths.push(ssPath);
    } catch (e) {}

    // Capture final snapshot
    try {
      const snapshot = await this._getSnapshot();
      result.snapshots.push({ stepIndex: -1, snapshot, type: 'final' });
    } catch (e) {}

    result.durationMs = Date.now() - startTime;

    // Compute final status considering soft-passes
    const softPassCount = result.steps.filter(s => s.status === 'soft_pass').length;
    const realPassCount = result.steps.filter(s => s.status === 'passed').length;
    const failedCount = result.steps.filter(s => s.status === 'failed').length;
    result.softPassCount = softPassCount;

    if (result.status === 'running') {
      if (failedCount > 0) {
        result.pass = false;
        result.status = 'failed';
      } else if (softPassCount > 0 && realPassCount === 0) {
        // ALL steps soft-passed → nothing was actually tested
        result.pass = false;
        result.status = 'soft_pass';
        result.error = `All ${softPassCount} steps soft-passed (no real interaction — selectors not found)`;
      } else if (softPassCount > 0) {
        // Any soft-pass means the test wasn't fully verified — not a real pass
        result.pass = false;
        result.status = 'soft_pass';
        result.error = `${softPassCount}/${result.steps.length} steps soft-passed (selectors not found — unverified)`;
      } else {
        result.pass = true;
        result.status = 'passed';
      }
    }

    const icon = result.pass ? '✓' : result.status === 'soft_pass' ? '⚠' : '✗';
    log('RunCase', `${icon} ${testCase.id}: ${result.status} (${result.durationMs}ms, ${realPassCount} real / ${softPassCount} soft / ${failedCount} fail) ${result.error || ''}`);
    return result;
  }

  /**
   * Pre-process test cases: fix navigate URLs, normalize action names, normalize selector fields
   */
  _preprocessCases(testCases) {
    const baseUrl = this.config.baseUrl;
    log('Preprocess', `Fixing test cases: baseUrl=${baseUrl}`);

    for (const tc of testCases) {
      if (!tc.steps) continue;
      for (const step of tc.steps) {
        // Normalize selector from alternate field names (LLM sometimes uses target/element/id)
        if (!step.selector) {
          if (step.target) step.selector = step.target;
          else if (step.element) step.selector = step.element;
          else if (step.id && step.action !== 'navigate') step.selector = step.id;
        }

        // Fix navigate URLs: replace localhost, empty, "/" with actual baseUrl
        if (step.action === 'navigate') {
          const url = step.url || step.selector || step.value || '';
          const needsReplace = !url || url === '/' || url === 'baseUrl' ||
              url.includes('localhost') || url === 'about:blank';
          // Also replace stale VL play URLs when baseUrl is a different VL play URL
          const isStalePlayUrl = baseUrl && url && !needsReplace &&
              url.includes('visuallogic.ai/play/') && baseUrl.includes('visuallogic.ai/play/') &&
              url !== baseUrl;
          if (needsReplace || isStalePlayUrl) {
            if (isStalePlayUrl) log('Preprocess', `  ${tc.id}: stale play URL "${url}" → "${baseUrl}"`);
            step.selector = baseUrl;
            step.url = baseUrl;
            if (!isStalePlayUrl) log('Preprocess', `  ${tc.id}: navigate URL fixed → ${baseUrl}`);
          }
        }

        // Normalize unsupported action names to supported ones
        if (step.action === 'waitForSelector' || step.action === 'wait_for') {
          step.action = 'wait';
        }
        if (step.action === 'waitForService' || step.action === 'waitForNavigation' || step.action === 'waitForNetwork') {
          step.action = 'wait';
          if (!step.value) step.value = '2000';
        }
        if (step.action === 'input' || step.action === 'enter') {
          step.action = 'fill';
        }
        if (step.action === 'tap') {
          step.action = 'click';
        }
        if (step.action === 'drag' || step.action === 'drag_and_drop') {
          // Drag is complex, convert to click for now
          step.action = 'click';
          log('Preprocess', `  ${tc.id}: drag converted to click (drag not yet supported)`);
        }
        if (step.action === 'assert_exists') {
          step.action = 'assert_visible';
        }
        if (step.action === 'assert_not_exists') {
          step.action = 'assert_hidden';
        }
      }
    }
    return testCases;
  }

  /**
   * Execute all test cases sequentially
   */
  async runAll(testCases, onCaseProgress, onStepProgress) {
    // Pre-process: fix URLs, normalize actions
    testCases = this._preprocessCases(testCases);

    const results = [];
    log('RunAll', `Starting ${testCases.length} test cases against ${this.config.baseUrl}`);

    for (let i = 0; i < testCases.length; i++) {
      // Abort: mark all remaining cases as skipped and notify UI
      if (this.aborted || !this.page || !this.browser?.isConnected()) {
        const reason = this.aborted ? 'Test stopped' : 'Browser disconnected';
        log('RunAll', `${reason} at case ${i + 1}, skipping remaining ${testCases.length - i} cases`);
        for (let j = i; j < testCases.length; j++) {
          const skipped = {
            caseId: testCases[j].id,
            name: testCases[j].name,
            priority: testCases[j].priority,
            page: testCases[j].page,
            status: 'skipped',
            pass: false,
            steps: [],
            error: reason,
            durationMs: 0,
            screenshotPaths: [],
            snapshots: [],
          };
          results.push(skipped);
          onCaseProgress?.({
            caseIndex: j,
            totalCases: testCases.length,
            caseId: testCases[j].id,
            caseName: testCases[j].name,
            status: 'skipped',
            result: skipped,
          });
        }
        break;
      }

      const tc = testCases[i];
      onCaseProgress?.({
        caseIndex: i,
        totalCases: testCases.length,
        caseId: tc.id,
        status: 'running',
      });

      const result = await this.runCase(tc, onStepProgress);
      results.push(result);

      onCaseProgress?.({
        caseIndex: i,
        totalCases: testCases.length,
        caseId: tc.id,
        status: result.status,
        result,
      });
    }

    const passed = results.filter(r => r.pass).length;
    log('RunAll', `Done: ${passed}/${results.length} passed`);
    return results;
  }

  /**
   * Get a compact DOM snapshot of current page.
   * VL apps have deep div nesting (ih5-native wrappers). This snapshot:
   * - Goes up to depth 30 to reach actual content elements
   * - Skips empty wrapper divs to keep output compact
   * - Highlights interactive/content elements (text, input, button, img)
   */
  async _getSnapshot(maxDepth = 30) {
    try {
      const snapshot = await this.page.evaluate((maxD) => {
        const SKIP_TAGS = new Set(['script', 'style', 'link', 'meta', 'noscript', 'svg', 'path']);
        const CONTENT_TAGS = new Set(['input', 'button', 'textarea', 'select', 'a', 'img', 'video', 'audio', 'canvas', 'iframe', 'hr', 'label', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'li', 'th', 'td']);

        const getNodeInfo = (el, depth = 0) => {
          if (depth > maxD) return '';
          const tag = el.tagName?.toLowerCase() || '';
          if (SKIP_TAGS.has(tag)) return '';

          const id = el.id ? `#${el.id}` : '';
          const cls = el.className && typeof el.className === 'string'
            ? '.' + el.className.trim().split(/\s+/).filter(c => !c.startsWith('ih5-native')).slice(0, 3).join('.')
            : '';
          // Direct text content (not from children)
          let text = '';
          for (const node of el.childNodes) {
            if (node.nodeType === 3) {
              const t = node.textContent.trim();
              if (t) { text = t.slice(0, 80); break; }
            }
          }
          const type = el.getAttribute?.('type') || '';
          const role = el.getAttribute?.('role') || '';
          const placeholder = el.getAttribute?.('placeholder') || '';
          const dataId = el.getAttribute?.('instance-id') || el.getAttribute?.('data-compid') || el.getAttribute?.('data-id') || el.getAttribute?.('data-instance-id') || '';
          const ariaLabel = el.getAttribute?.('aria-label') || '';
          const value = (tag === 'input' || tag === 'textarea') ? (el.value || '').slice(0, 40) : '';
          const src = tag === 'img' ? (el.getAttribute('src') || '').slice(0, 60) : '';

          // Build child content first to decide if this wrapper can be collapsed
          let childContent = '';
          for (const child of el.children || []) {
            childContent += getNodeInfo(child, depth + 1);
          }

          // Determine if this element has meaningful info
          const isContentEl = CONTENT_TAGS.has(tag) || text || id || role || placeholder || dataId || ariaLabel || type || value || src;
          const isWrapperDiv = tag === 'div' && !isContentEl;

          // Skip pure wrapper divs that just nest (collapse them)
          if (isWrapperDiv && el.children.length <= 1 && !text && depth > 3) {
            return childContent; // skip this wrapper, show children directly
          }

          let desc = `${'  '.repeat(Math.min(depth, 15))}<${tag}${id}${cls}`;
          if (type) desc += ` type="${type}"`;
          if (role) desc += ` role="${role}"`;
          if (placeholder) desc += ` ph="${placeholder}"`;
          if (dataId) desc += ` data-id="${dataId}"`;
          if (ariaLabel) desc += ` aria="${ariaLabel}"`;
          if (value) desc += ` val="${value}"`;
          if (src) desc += ` src="${src}"`;
          desc += '>';
          if (text) desc += ` ${text}`;
          desc += '\n';
          desc += childContent;
          return desc;
        };
        return getNodeInfo(document.body);
      }, maxDepth);
      return snapshot;
    } catch (e) {
      return `[Snapshot failed: ${e.message}]`;
    }
  }

  /**
   * Navigate to a URL and wait for VL SPA to fully render
   */
  async _navigateTo(url) {
    const fullUrl = url.startsWith('http') ? url : `${this.config.baseUrl}${url.startsWith('/') ? '' : '/'}${url}`;

    // Reload when the URL is unchanged so each case starts from a clean SPA state.
    if (this._lastUrl && this._lastUrl === fullUrl) {
      log('Navigate', `↻ Reload ${fullUrl}`);
      await this.page.reload({ waitUntil: 'domcontentloaded', timeout: this.config.timeout });
    } else {
      log('Navigate', `→ ${fullUrl}`);
      await this.page.goto(fullUrl, { waitUntil: 'domcontentloaded', timeout: this.config.timeout });
    }
    this._lastUrl = fullUrl;

    // VL apps are SPAs — wait for network to settle (JS/API calls done)
    try {
      await this.page.waitForLoadState('networkidle', { timeout: 6000 });
    } catch (e) {
      log('Navigate', 'networkidle timeout (6s), continuing...');
    }

    // Wait for VL app to render interactive elements
    try {
      await this.page.waitForFunction(() => {
        const interactive = document.querySelectorAll('button, a, input, textarea, [role="button"], [contenteditable]');
        const divs = document.querySelectorAll('div');
        return interactive.length > 2 || divs.length > 10;
      }, { timeout: 5000 });
    } catch {
      // Simple pages may have fewer elements — no extra wait needed
    }

    // On first navigation, log the DOM structure for debugging
    if (!this.firstNavigationDone) {
      this.firstNavigationDone = true;
      try {
        const title = await this.page.title();
        const bodyHTML = await this.page.evaluate(() => {
          const body = document.body;
          // Get a summary: count elements, find top-level structure
          const allElements = body.querySelectorAll('*');
          const tagCounts = {};
          for (const el of allElements) {
            const tag = el.tagName.toLowerCase();
            tagCounts[tag] = (tagCounts[tag] || 0) + 1;
          }
          // Get top-level children structure
          const topLevel = [];
          for (const child of body.children) {
            const id = child.id ? `#${child.id}` : '';
            const cls = child.className && typeof child.className === 'string'
              ? '.' + child.className.trim().split(/\s+/).slice(0, 3).join('.')
              : '';
            topLevel.push(`<${child.tagName.toLowerCase()}${id}${cls}>`);
          }
          return { totalElements: allElements.length, topLevel, tagCounts };
        });
        log('Navigate', `Page loaded: title="${title}"`);
        log('Navigate', `DOM: ${bodyHTML.totalElements} elements, top-level: ${bodyHTML.topLevel.join(', ')}`);
        log('Navigate', `Tag counts:`, bodyHTML.tagCounts);

        // Log visible interactive elements for debugging selector failures
        const interactiveInfo = await this.page.evaluate(() => {
          const inputs = Array.from(document.querySelectorAll('input, textarea'));
          const buttons = Array.from(document.querySelectorAll('button, [role="button"]'));
          const textElements = Array.from(document.querySelectorAll('a, span, div, p, h1, h2, h3, h4, label'))
            .filter(el => el.textContent?.trim().length > 0 && el.textContent.trim().length < 50 && el.children.length === 0)
            .slice(0, 30);
          return {
            inputs: inputs.map(el => `${el.tagName.toLowerCase()}[type="${el.type || 'text'}"]${el.placeholder ? ` placeholder="${el.placeholder}"` : ''}`),
            buttons: buttons.map(el => `${el.tagName.toLowerCase()} "${(el.textContent || '').trim().slice(0, 30)}"`),
            visibleTexts: textElements.map(el => `"${el.textContent.trim()}"`),
          };
        });
        log('Navigate', `Inputs: ${interactiveInfo.inputs.join(', ') || '(none)'}`);
        log('Navigate', `Buttons: ${interactiveInfo.buttons.join(', ') || '(none)'}`);
        log('Navigate', `Visible texts: ${interactiveInfo.visibleTexts.join(', ') || '(none)'}`);

        // Also get a snapshot and log first 2000 chars
        const snapshot = await this._getSnapshot();
        log('Navigate', `DOM snapshot (first 2000 chars):\n${snapshot.slice(0, 2000)}`);
      } catch (e) {
        log('Navigate', `DOM analysis failed: ${e.message}`);
      }
    }
  }

  /**
   * Resolve selector - try multiple strategies for VL app DOM
   * VL 3.5 /play/ mode primarily exposes data-vl-id attributes.
   * Strategy order:
   * 1. data-vl-id / compatible compId attributes
   * 2. CSS selector (if it looks like one)
   * 3. Text content match
   * 4. Placeholder text
   * 5. Label text
   * 6. Button role + name
   * 7. Any clickable role
   * 8. data-id / data-instance-id bare value match
   * 9. Broad text search (partial match)
   */
  async _resolveSelector(selector) {
    if (!selector) return null;

    // Strategy VL: vlid:xxx shorthand → [data-vl-id="xxx"] (VL 3.7 PREFERRED)
    // Supports repeated-loop disambiguation via vlid:navItem[text="课程管理"].
    if (selector.startsWith('vlid:')) {
      const raw = selector.slice(5).trim();
      const hybridMatch = raw.match(/^([^\[\s]+)(?:\[text="([^"]+)"\])?(?:\s+(.+))?$/);
      const id = hybridMatch?.[1] || raw;
      const textFilter = hybridMatch?.[2] || '';
      const suffix = hybridMatch?.[3] || ''; // e.g. "input" in "vlid:inputUsername input"

      const buildBaseLocator = (attr) => {
        let locator = this.page.locator(`[${attr}="${id}"]`);
        if (textFilter) locator = locator.filter({ hasText: textFilter });
        return locator.first();
      };
      const buildAltLocator = () => {
        let locator = this.page.locator(`[data-compid="${id}"], [compid="${id}"], [data-instance-id="${id}"]`);
        if (textFilter) locator = locator.filter({ hasText: textFilter });
        return locator.first();
      };
      const baseLocators = [
        buildBaseLocator('data-vl-id'),
        buildAltLocator(),
        buildBaseLocator('instance-id'),
      ];
      const buildChildLocators = (base) => {
        if (suffix === 'input') {
          return [
            base.locator('input:visible').first(),
            base.locator('textarea:visible').first(),
            base.locator('[contenteditable="true"]:visible').first(),
            base.locator('input').first(),
            base.locator('textarea').first(),
            base.locator('[contenteditable="true"]').first(),
            base,
          ];
        }
        if (suffix) {
          return [base.locator(`${suffix}:visible`).first(), base.locator(suffix).first()];
        }
        return [
          base.locator('button:visible').first(),
          base.locator('[role="button"]:visible').first(),
          base.locator('a:visible').first(),
          base.locator('input:visible').first(),
          base.locator('textarea:visible').first(),
          base.locator('button').first(),
          base.locator('[role="button"]').first(),
          base.locator('a').first(),
          base.locator('input').first(),
          base.locator('textarea').first(),
          base,
        ];
      };
      log('Selector', `  vlid: "${selector}" → id="${id}"${textFilter ? ` text="${textFilter}"` : ''}${suffix ? ` suffix="${suffix}"` : ''}`);
      const candidates = [];
      for (const base of baseLocators) {
        candidates.push(...buildChildLocators(base));
      }

      for (const loc of candidates) {
        try {
          if (await loc.count() > 0) {
            log('Selector', `  ✓ Found via vlid selector`);
            return loc;
          }
        } catch {}
      }

      for (const loc of candidates) {
        try {
          await loc.waitFor({ state: 'attached', timeout: 800 });
          if (await loc.count() > 0) {
            log('Selector', `  ✓ Found via vlid selector after wait`);
            return loc;
          }
        } catch {}
      }
      const vlidTextAliases = {
        confirmBtn: [/^确定$/u, /^确认$/u],
        cancelBtn: [/^取消$/u],
        submitForm: [/^确定$/u, /^保存$/u],
        submitFormBtn: [/^确定$/u, /^保存$/u],
      };
      if (vlidTextAliases[id]) {
        for (const name of vlidTextAliases[id]) {
          try {
            const aliasLoc = this.page.getByRole('button', { name }).first();
            await aliasLoc.waitFor({ state: 'attached', timeout: 2500 });
            if (await aliasLoc.count() > 0) {
              log('Selector', `  ✓ Found via vlid text alias ${name}`);
              return aliasLoc;
            }
          } catch {}
        }
      }
      // Fallback: try legacy instance-id if present in older DOMs
      const legacyCss = suffix
        ? `[instance-id="${id}"] ${suffix}`
        : `[instance-id="${id}"]`;
      try {
        const legacyLoc = this.page.locator(legacyCss).first();
        if (await legacyLoc.count() > 0) {
          log('Selector', `  ✓ Found via instance-id (vlid fallback)`);
          return legacyLoc;
        }
      } catch {}
      // Also try data-compid / compid / data-instance-id
      try {
        const altLoc = this.page.locator(`[data-compid="${id}"], [compid="${id}"], [data-instance-id="${id}"]`).first();
        if (await altLoc.count() > 0) {
          log('Selector', `  ✓ Found via alt compId (vlid fallback)`);
          return altLoc;
        }
      } catch {}
      // Do not degrade vlid selectors into arbitrary text/input guesses.
      // If the declared component id is not in the DOM, failing is safer than
      // clicking or filling the wrong visible element.
      log('Selector', `  vlid "${id}" not found via exact-id strategies, returning strict locator`);
      if (suffix === 'input') {
        return this.page.locator(`[data-vl-id="${id}"] input, [data-vl-id="${id}"] textarea, [data-vl-id="${id}"] [contenteditable="true"]`).first();
      }
      if (suffix) {
        return this.page.locator(`[data-vl-id="${id}"] ${suffix}`).first();
      }
      return this.page.locator(`[data-vl-id="${id}"]`).first();
    }

    // Strategy 0: Playwright text= and role= prefixes — pass through directly
    // e.g. "text=+ New Campaign", "role=button[name='Submit']"
    if (selector.startsWith('text=') || selector.startsWith('role=') ||
        selector.startsWith('css=') || selector.startsWith('xpath=')) {
      log('Selector', `  playwright prefix: "${selector}"`);
      try {
        const loc = this.page.locator(selector).first();
        if (await loc.count() > 0) {
          log('Selector', `  ✓ Found via playwright prefix selector`);
          return loc;
        }
      } catch {}
      // Try as-is even if count check fails
      return this.page.locator(selector).first();
    }

    // Strategy 0b: Playwright CSS extended selectors — :has-text(), :has(), >> combinator
    // e.g. "tr:has-text('Ad Headline') button:has-text('Edit')"
    if (selector.includes(':has-text(') || selector.includes(':has(') || selector.includes(' >> ')) {
      log('Selector', `  playwright CSS extended: "${selector}"`);
      return this.page.locator(selector).first();
    }

    // Strategy 1: instance-id attribute selector (VL compId → instance-id in DOM)
    // Handles [instance-id="xxx"] and composite [instance-id="xxx"][data-key="yyy"] selectors
    const instanceIdMatch = selector.match(/\[instance-id=["']?([^"'\]]+)["']?\]/);
    if (instanceIdMatch) {
      const compId = instanceIdMatch[1];

      // Check if the selector has additional attribute filters (e.g., data-key for route-based nav)
      // If so, use the FULL selector to avoid clicking the wrong element
      const isComposite = selector.trim() !== `[instance-id="${compId}"]` &&
                          selector.trim() !== `[instance-id='${compId}']`;

      if (isComposite) {
        // Composite selector (e.g. [instance-id="menuItem"][data-key="/campaigns"])
        // Use it as a full CSS selector — most precise approach
        log('Selector', `  composite instance-id selector: "${selector}"`);
        try {
          const loc = this.page.locator(selector).first();
          if (await loc.count() > 0) {
            log('Selector', `  ✓ Found via composite selector`);
            return loc;
          }
        } catch {}

        // VL /play/ mode does NOT render instance-id, but DOES render data-key.
        // If selector has [data-key="..."], try that alone — covers route-based nav items.
        const dataKeyMatch = selector.match(/\[data-key=["']?([^"'\]]+)["']?\]/);
        if (dataKeyMatch) {
          const dataKey = dataKeyMatch[1];
          log('Selector', `  trying [data-key="${dataKey}"] (VL play mode fallback)`);
          try {
            const loc = this.page.locator(`[data-key="${dataKey}"]`).first();
            if (await loc.count() > 0) {
              log('Selector', `  ✓ Found via data-key`);
              return loc;
            }
          } catch {}
        }
        log('Selector', `  composite selector not found, trying plain instance-id fallback`);
      }

      // Plain instance-id (or composite fallback)
      log('Selector', `  instance-id: "${compId}"`);
      try {
        const loc = this.page.locator(`[instance-id="${compId}"]`).first();
        if (await loc.count() > 0) {
          log('Selector', `  ✓ Found via instance-id`);
          return loc;
        }
        // Also try data-compid and compid attributes
        const altLoc = this.page.locator(`[data-compid="${compId}"], [compid="${compId}"], [data-instance-id="${compId}"]`).first();
        if (await altLoc.count() > 0) {
          log('Selector', `  ✓ Found via alt compId attribute`);
          return altLoc;
        }
      } catch {}

      // VL /play/ mode doesn't render instance-id: try text from compId (camelCase → words)
      // e.g. "createBtn" → "Create", "editNameInput" → "Name", "submitCreateBtn" → "Submit"
      const compWords = compId
        .replace(/([A-Z])/g, ' $1')
        .replace(/(btn|button|input|select|modal|card|area|container|section|panel|icon|text|label|value|link|wrap|block|row|col|grid)/gi, ' ')
        .trim().split(/\s+/).filter(w => w.length > 2);
      if (compWords.length > 0) {
        for (const word of compWords.slice(0, 2)) {
          try {
            const wLoc = this.page.getByText(word, { exact: false });
            if (await wLoc.count() > 0) {
              log('Selector', `  ✓ Found via compId word "${word}"`);
              return wLoc.first();
            }
          } catch {}
        }
      }

      // If instance-id not found, don't return null yet — try other strategies
      log('Selector', `  instance-id="${compId}" not in DOM, trying other strategies...`);
    }

    // Strategy 1c: VL play-mode — input[type="xxx"] selector
    // Handles generated selectors like: input[type="text"], input[type="password"] >> nth=1
    const inputTypeMatch = selector.match(/^input\[type=["'](\w+)["']\](?:\s*>>\s*nth=(\d+))?$/);
    if (inputTypeMatch) {
      const inputType = inputTypeMatch[1];
      const nth = inputTypeMatch[2] !== undefined ? parseInt(inputTypeMatch[2]) : 0;
      log('Selector', `  VL play-mode input[type="${inputType}"] nth=${nth}`);
      try {
        const loc = this.page.locator(`input[type="${inputType}"]`);
        const count = await loc.count();
        if (count > nth) {
          log('Selector', `  ✓ Found input[type="${inputType}"] (${count} total, using nth=${nth})`);
          return loc.nth(nth);
        }
        // Fallback: also check .ih5-native-input with type attribute
        const vlLoc = this.page.locator(`input.ih5-native-input[type="${inputType}"]`);
        const vlCount = await vlLoc.count();
        if (vlCount > nth) {
          log('Selector', `  ✓ Found .ih5-native-input[type="${inputType}"] (${vlCount} total, using nth=${nth})`);
          return vlLoc.nth(nth);
        }
        // Fallback: VL wraps inputs inside custom component divs — look for any visible <input>
        // When type="text" but no type attribute is set, input defaults to text
        if (inputType === 'text') {
          const anyInput = this.page.locator('input:not([type="hidden"]):not([type="password"]):not([type="checkbox"]):not([type="radio"])');
          const anyCount = await anyInput.count();
          if (anyCount > nth) {
            log('Selector', `  ✓ Found generic input (${anyCount} total, using nth=${nth})`);
            return anyInput.nth(nth);
          }
        }
      } catch {}
    }

    // Strategy 1d: VL play-mode — button:has-text("xxx") selector
    const btnHasTextMatch = selector.match(/^button:has-text\(["'](.+?)["']\)$/);
    if (btnHasTextMatch) {
      const btnText = btnHasTextMatch[1];
      log('Selector', `  VL play-mode button:has-text("${btnText}")`);
      try {
        // Try native button first, then VL button class
        for (const btnSel of [`button:has-text("${btnText}")`, `button.ih5-native-button:has-text("${btnText}")`]) {
          const loc = this.page.locator(btnSel).first();
          if (await loc.count() > 0) {
            log('Selector', `  ✓ Found via ${btnSel}`);
            return loc;
          }
        }
      } catch {}
    }

    // Strategy 1e: VL play-mode — div:has-text("Label") + div input selector
    // For label-adjacent input finding
    const labelInputMatch = selector.match(/^div:has-text\(["'](.+?)["']\)\s*\+\s*div\s+input(?:\[type=["'](\w+)["']\])?$/);
    if (labelInputMatch) {
      const labelText = labelInputMatch[1];
      const inputType = labelInputMatch[2] || null;
      log('Selector', `  VL play-mode label-adjacent: "${labelText}" → input${inputType ? `[type="${inputType}"]` : ''}`);
      try {
        // Strategy: find the label div, then find the next sibling with an input
        const typeSuffix = inputType ? `[type="${inputType}"]` : '';
        const loc = this.page.locator(`div:has-text("${labelText}") + div input${typeSuffix}, div:has-text("${labelText}") ~ input${typeSuffix}`).first();
        if (await loc.count() > 0) {
          log('Selector', `  ✓ Found input adjacent to label "${labelText}"`);
          return loc;
        }
      } catch {}
    }

    // Strategy 2: CSS selector (starts with #, ., [, or standard tag)
    if (/^[#.\[\w]/.test(selector) && !this._isChinese(selector)) {
      log('Selector', `  CSS: "${selector}"`);
      try {
        const loc = this.page.locator(selector).first();
        if (await loc.count() > 0) {
          log('Selector', `  ✓ Found via CSS`);
          return loc;
        }
      } catch {}

      // Strategy 2b: [data-key="/xxx"] fallback → try text= from route key
      // VL play mode may not render data-key attributes; try finding nav by text label
      if (selector.startsWith('[data-key=')) {
        const routeKey = selector.match(/\[data-key=["']?([^"'\]]+)/)?.[1];
        if (routeKey) {
          // "/inplay" → "inplay" → "In Play"; "/mybets" → "My Bets"; "/matchctrl" → "Match Ctrl"
          const rawName = routeKey.replace(/^\//, '');
          const readable = rawName
            .replace(/([a-z])([A-Z])/g, '$1 $2')  // camelCase → words
            .replace(/[-_]/g, ' ')
            .replace(/\b\w/g, c => c.toUpperCase()); // capitalize
          const candidates = [readable, rawName];
          log('Selector', `  data-key fallback: trying text candidates [${candidates.join(', ')}]`);
          for (const candidate of candidates) {
            try {
              const textLoc = this.page.getByText(candidate, { exact: false });
              if (await textLoc.count() > 0) {
                log('Selector', `  ✓ Found via data-key text fallback: "${candidate}"`);
                return textLoc.first();
              }
            } catch {}
          }
        }
      }
    }

    // Strategy 3: Exact text match
    log('Selector', `  Trying text match: "${selector}"`);
    try {
      const textLoc = this.page.getByText(selector, { exact: false });
      const count = await textLoc.count();
      if (count > 0) {
        log('Selector', `  ✓ Found via text (${count} matches)`);
        return textLoc.first();
      }
    } catch {}

    // Strategy 4: Placeholder
    try {
      const phLoc = this.page.getByPlaceholder(selector, { exact: false });
      if (await phLoc.count() > 0) {
        log('Selector', `  ✓ Found via placeholder`);
        return phLoc.first();
      }
    } catch {}

    // Strategy 5: Label
    try {
      const labelLoc = this.page.getByLabel(selector, { exact: false });
      if (await labelLoc.count() > 0) {
        log('Selector', `  ✓ Found via label`);
        return labelLoc.first();
      }
    } catch {}

    // Strategy 6: Button role
    try {
      const btnLoc = this.page.getByRole('button', { name: selector });
      if (await btnLoc.count() > 0) {
        log('Selector', `  ✓ Found via button role`);
        return btnLoc.first();
      }
    } catch {}

    // Strategy 7: Any clickable role
    for (const role of ['link', 'tab', 'menuitem', 'option', 'checkbox', 'radio']) {
      try {
        const roleLoc = this.page.getByRole(role, { name: selector });
        if (await roleLoc.count() > 0) {
          log('Selector', `  ✓ Found via ${role} role`);
          return roleLoc.first();
        }
      } catch {}
    }

    // Strategy 8: data-id / data-instance-id bare value (without bracket syntax)
    if (!selector.includes('[') && !selector.includes(' ') && /^[a-zA-Z]/.test(selector)) {
      try {
        const dataLoc = this.page.locator(`[instance-id="${selector}"], [data-compid="${selector}"], [data-id="${selector}"], [data-instance-id="${selector}"]`).first();
        if (await dataLoc.count() > 0) {
          log('Selector', `  ✓ Found via data attribute bare value`);
          return dataLoc;
        }
      } catch {}
    }

    // Strategy 9: Partial text with broader matching
    const keywords = selector.replace(/\[.*?\]/g, '').trim();
    if (keywords && keywords !== selector && keywords.length > 1) {
      try {
        const kwLoc = this.page.getByText(keywords, { exact: false });
        if (await kwLoc.count() > 0) {
          log('Selector', `  ✓ Found via keyword "${keywords}"`);
          return kwLoc.first();
        }
      } catch {}
    }

    // Strategy 9: XPath text contains
    try {
      const xpathLoc = this.page.locator(`xpath=//*[contains(text(), "${selector.slice(0, 20)}")]`).first();
      if (await xpathLoc.count() > 0) {
        log('Selector', `  ✓ Found via xpath text contains`);
        return xpathLoc;
      }
    } catch {}

    log('Selector', `  ✗ NOT FOUND: "${selector}"`);
    // For CSS-style selectors (attribute, class, id), return them directly so the
    // subsequent waitFor() produces a clear "Timeout waiting for [instance-id=...]" error.
    // Do NOT wrap in text= — that turns an attribute selector into a text-content search.
    if (/^[#.\[]/.test(selector.trim())) {
      return this.page.locator(selector).first();
    }
    // For plain-text selectors, fall back to text-content matching
    return this.page.locator(`text="${selector}"`).first();
  }

  _isChinese(str) {
    return /[\u4e00-\u9fff]/.test(str);
  }

  /**
   * Resolve a selector specifically for <select> / combobox elements.
   * Unlike _resolveSelector, this does NOT use camelCase word → getByText() fallback
   * (which incorrectly matches text elements that can't accept selectOption).
   */
  async _resolveSelectElement(selector) {
    if (!selector) return null;

    // Strategy 0: Playwright prefix pass-through
    if (selector.startsWith('css=') || selector.startsWith('xpath=')) {
      return this.page.locator(selector).first();
    }

    // Strategy 1: instance-id selector
    const instanceIdMatch = selector.match(/\[instance-id=["']?([^"'\]]+)["']?\]/);
    if (instanceIdMatch) {
      const compId = instanceIdMatch[1];
      try {
        const loc = this.page.locator(`[instance-id="${compId}"]`).first();
        if (await loc.count() > 0) {
          log('Selector', `  ✓ [select] Found via instance-id`);
          return loc;
        }
      } catch {}
    }

    // Strategy 2: CSS selector (only if it looks like a real CSS selector)
    if (/^[#.\[\w]/.test(selector) && !this._isChinese(selector)) {
      try {
        const loc = this.page.locator(selector).first();
        if (await loc.count() > 0) {
          log('Selector', `  ✓ [select] Found via CSS`);
          return loc;
        }
      } catch {}
    }

    // Strategy 3: Native <select> element — search by name/label/aria
    try {
      const selLoc = this.page.getByRole('combobox', { name: selector });
      if (await selLoc.count() > 0) {
        log('Selector', `  ✓ [select] Found combobox role`);
        return selLoc.first();
      }
    } catch {}

    try {
      const selLoc = this.page.getByLabel(selector, { exact: false });
      if (await selLoc.count() > 0) {
        log('Selector', `  ✓ [select] Found via label`);
        return selLoc.first();
      }
    } catch {}

    try {
      const selLoc = this.page.getByRole('listbox', { name: selector });
      if (await selLoc.count() > 0) {
        log('Selector', `  ✓ [select] Found listbox role`);
        return selLoc.first();
      }
    } catch {}

    // Strategy 4: Find any <select> tag on the page (if only one exists, use it)
    try {
      const allSelects = this.page.locator('select');
      const count = await allSelects.count();
      if (count === 1) {
        log('Selector', `  ✓ [select] Only one <select> on page — using it`);
        return allSelects.first();
      }
    } catch {}

    // All specific strategies failed — fall back to generic locator (will fail with clear error)
    log('Selector', `  ✗ [select] NOT FOUND: "${selector}" — returning generic locator`);
    return this.page.locator(selector).first();
  }

  /**
   * Click a custom VL dropdown element to open it, then click the option by text (value).
   * Returns true if option was found and clicked, false otherwise.
   * @param {Locator|null} triggerLocator - the dropdown trigger element (null to search by value text)
   * @param {string} value - the option text/value to select
   */
  async _tryOpenAndSelectDropdown(triggerLocator, value) {
    try {
      // Open the dropdown
      if (triggerLocator) {
        await triggerLocator.click({ force: true, timeout: 3000 });
      }
      await this.page.waitForTimeout(150);

      // Look for the option in dropdown list
      const optionSelectors = [
        this.page.locator('[role="option"]').filter({ hasText: value }),
        this.page.getByRole('option', { name: value }),
        this.page.locator('li').filter({ hasText: value }),
        this.page.locator('[class*="option"]').filter({ hasText: value }),
        this.page.locator('[class*="item"]').filter({ hasText: value }),
        this.page.getByText(value, { exact: true }),
      ];

      for (const loc of optionSelectors) {
        try {
          const cnt = await loc.count();
          if (cnt > 0) {
            await loc.first().click({ timeout: 2000 });
            log('Select', `  ✓ Custom dropdown: clicked option "${value}"`);
            return true;
          }
        } catch {}
      }

      log('Select', `  Custom dropdown open — option "${value}" not found in DOM`);
      return false;
    } catch (e) {
      log('Select', `  _tryOpenAndSelectDropdown failed: ${e.message.slice(0, 100)}`);
      return false;
    }
  }

  /**
   * Execute a single test action
   */
  async _executeAction(step) {
    const { action, selector, value, expected, url } = step;
    const timeout = this.config.timeout;
    this._softPass = false; // Reset before each step

    switch (action) {
      case 'navigate': {
        let targetUrl = url || selector || value || '';
        // Replace any localhost or invalid URL with the configured baseUrl
        if (!targetUrl || targetUrl === '/' || targetUrl === 'baseUrl' || targetUrl.includes('localhost')) {
          targetUrl = this.config.baseUrl;
        }
        if (!targetUrl) {
          throw new Error('navigate: No valid URL. Ensure baseUrl is configured or VL project is compiled.');
        }
        await this._navigateTo(targetUrl);
        break;
      }

      case 'click': {
        if (!selector) throw new Error(`click: missing selector field`);
        const locator = await this._resolveSelector(selector);
        if (!locator) throw new Error(`click: cannot resolve selector "${selector}"`);
        const vlidId = selector.startsWith('vlid:') ? selector.split(/\s+/)[0].slice(5).trim() : '';
        const prefersForceClick = /^(editBtn|deleteBtn|toggleBtn|confirmBtn)$/i.test(vlidId);

        // Strategy 1: normal visible click (short timeout first)
        const shortTimeout = Math.min(timeout, 6000);
        let clicked = false;
        if (prefersForceClick) {
          try {
            await locator.scrollIntoViewIfNeeded().catch(() => {});
            await locator.click({ force: true, timeout: 3000 });
            clicked = true;
            log('Click', `  ✓ Force click preferred for row action "${selector}"`);
          } catch (e0) {
            log('Click', `  Preferred force click failed: ${e0.message.slice(0, 100)}`);
          }
        }
        try {
          if (!clicked) {
            await locator.waitFor({ state: 'visible', timeout: shortTimeout });
            await locator.scrollIntoViewIfNeeded().catch(() => {});
            await locator.click({ timeout });
            clicked = true;
          }
        } catch (e1) {
          // Strategy 2: force click — handles VL elements that are in DOM but not
          // "visible" by Playwright's definition (custom rendering, vh/vw overflow, etc.)
          log('Click', `  Visible click failed: ${e1.message.slice(0, 100)}`);
          log('Click', `  Trying force click for "${selector}"...`);
          try {
            await locator.scrollIntoViewIfNeeded().catch(() => {});
            await locator.click({ force: true, timeout: 3000 });
            clicked = true;
            log('Click', `  ✓ Force click succeeded`);
          } catch (e2) {
            log('Click', `  Force click also failed: ${e2.message.slice(0, 80)}`);
          }
        }

        // Strategy 3: hover table rows to reveal hidden action buttons (Edit/Delete/View etc.)
        // VL table rows only add action buttons to DOM on hover — they don't exist until then.
        if (!clicked && selector.startsWith('text=')) {
          const btnText = selector.slice(5).trim();
          const isTableActionBtn = /^(edit|delete|view|remove|manage|preview|more|view ads|manage ads)$/i.test(btnText);
          if (isTableActionBtn) {
            log('Click', `  Strategy 3: hover rows to reveal hidden btn "${btnText}"...`);
            const rows = this.page.locator('tr, [role="row"]');
            const rowCount = await rows.count().catch(() => 0);
            log('Click', `  Found ${rowCount} candidate rows`);
            for (let i = 0; i < Math.min(rowCount, 10) && !clicked; i++) {
              try {
                await rows.nth(i).hover({ timeout: 1500, force: true });
                await this.page.waitForTimeout(100);
                const btnLoc = this.page.locator(`text=${btnText}`).first();
                if (await btnLoc.isVisible().catch(() => false)) {
                  await btnLoc.click({ timeout: 2000 });
                  clicked = true;
                  log('Click', `  ✓ Hover+click on row ${i}: "${btnText}"`);
                }
              } catch {}
            }
            if (!clicked) {
              log('Click', `  Hover strategy: no rows revealed "${btnText}"`);
            }
          }
        }

        // Strategy 4: soft-pass ONLY for selectors that are known-fragile in VL play mode.
        // text= selectors are primary interaction mechanism — they should FAIL if not found,
        // so the user knows the test didn't actually interact with anything.
        if (!clicked) {
          const isSoftPassSelector =
            selector.includes('[instance-id=') ||   // VL play mode doesn't render instance-id
            selector.includes('[placeholder=') ||   // placeholder may not exist in VL custom inputs
            /\.css-[a-z0-9]+/i.test(selector) ||   // VL compiled CSS class (fragile, change on recompile)
            /^(li|ul|option)\b/i.test(selector.trim()); // bare list/option elements
          if (isSoftPassSelector) {
            try {
              const domIds = await this.page.evaluate(() =>
                Array.from(document.querySelectorAll('[instance-id]'))
                  .map(el => `${el.getAttribute('instance-id')}${el.dataset.key ? '[data-key=' + el.dataset.key + ']' : ''}`)
                  .join(', ')
              );
              log('Click', `  DOM instance-ids: ${domIds.slice(0, 500)}`);
            } catch {}
            log('Click', `  ⚠ "${selector}" not clickable — soft pass`);
            this._softPass = true;
            break;
          }
          throw new Error(`Cannot click "${selector}": element not visible or not in DOM`);
        }

        break;
      }

      case 'fill': {
        if (!selector) throw new Error(`fill: missing selector field`);
        const locator = await this._resolveSelector(selector);
        if (!locator) throw new Error(`fill: cannot resolve selector "${selector}"`);
        const strictVlidFill = selector.startsWith('vlid:');

        // Soft-pass: if instance-id / placeholder selector not in DOM, skip with warning
        if (selector.includes('[instance-id=') || selector.includes('[placeholder=')) {
          const count = await locator.count().catch(() => 0);
          if (count === 0) {
            log('Fill', `  ⚠ "${selector}" not in DOM — soft pass`);
            this._softPass = true;
            break;
          }
        }

        let filled = false;
        try {
          await locator.waitFor({ state: 'visible', timeout: Math.min(timeout, 8000) });
          await locator.fill(value || '', { timeout });
          filled = true;
        } catch (e1) {
          const locatorCount = await locator.count().catch(() => 0);
          if (strictVlidFill && locatorCount === 0) {
            throw new Error(`Cannot fill "${selector}": component id not found in DOM`);
          }
          const isNotInput = e1.message.includes('Element is not an');
          // Strategy 2: VL wraps <input> inside custom component divs.
          // Find the child input/textarea/contenteditable inside the wrapper.
          if (isNotInput) {
            log('Fill', `  Element is not an input, searching child input inside VL wrapper...`);
            try {
              const childInput = locator.locator('input, textarea, [contenteditable="true"]').first();
              if (await childInput.count() > 0) {
                await childInput.fill(value || '', { timeout: 5000 });
                filled = true;
                log('Fill', `  ✓ Filled child input inside wrapper`);
              }
            } catch (e2) {
              log('Fill', `  Child input fill failed: ${e2.message.slice(0, 80)}`);
            }
          }
          // Strategy 3: force fill
          if (!filled) {
            try {
              await locator.fill(value || '', { force: true, timeout: 5000 });
              filled = true;
              log('Fill', `  ✓ Force fill succeeded`);
            } catch {}
          }
          // Strategy 4: click then type (keyboard input as last resort)
          if (!filled && locatorCount > 0) {
            try {
              await locator.click({ force: true, timeout: 3000 }).catch(() => {});
              await this.page.keyboard.type(value || '', { delay: 30 });
              filled = true;
              log('Fill', `  ✓ Click+type fallback succeeded`);
            } catch {}
          }
          if (!filled) throw e1;
        }
        break;
      }

      case 'select': {
        if (!selector) throw new Error(`select: missing selector field`);

        // Soft pass: if instance-id selector not in DOM (VL /play/ mode), skip.
        // VL custom dropdowns are divs, not <select>; selectOption won't work anyway.
        if (selector.includes('[instance-id=')) {
          const cnt = await this.page.locator(selector).count().catch(() => 0);
          if (cnt === 0) {
            log('Select', `  ⚠ "${selector}" not in DOM (VL play mode) — trying text-based dropdown open instead`);
            // Try to find dropdown by extracting compId as text hint
            const compIdMatch = selector.match(/\[instance-id=["']?([^"'\]]+)["']?\]/);
            if (compIdMatch && value) {
              try {
                // Click any visible element that looks like the dropdown (shows current value or placeholder)
                // Then click the option by value text
                const dropdownOpened = await this._tryOpenAndSelectDropdown(null, value);
                if (dropdownOpened) break;
              } catch {}
            }
            log('Select', `  ⚠ Skipping select (soft pass)`);
            this._softPass = true;
            return;
          }
        }

        const locator = await this._resolveSelectElement(selector);
        if (!locator) throw new Error(`select: cannot resolve selector "${selector}"`);

        // Strategy 1: native <select> selectOption
        try {
          await locator.waitFor({ state: 'visible', timeout: Math.min(timeout, 6000) });
          await locator.selectOption(value, { timeout: 5000 });
          break;
        } catch (e1) {
          log('Select', `  selectOption failed (likely custom dropdown): ${e1.message.slice(0, 100)}`);
        }

        // Strategy 2: custom VL dropdown — click to open, then click option by text
        const opened = await this._tryOpenAndSelectDropdown(locator, value);
        if (!opened) {
          log('Select', `  ⚠ Could not select "${value}" in custom dropdown — soft pass`);
          this._softPass = true;
        }
        break;
      }

      case 'assert_visible': {
        const target = selector || expected;
        if (!target) throw new Error(`assert_visible: missing selector or expected field`);
        const locator = await this._resolveSelector(target);
        if (!locator) throw new Error(`assert_visible: cannot resolve selector "${target}"`);

        // Soft assertion: VL play mode may not render instance-id / placeholder attributes.
        // Also text= may not match exactly (VL renders dynamic content).
        // Check if element exists — if not, soft-pass instead of 15s timeout.
        const isSoftTarget = target.includes('[instance-id=') ||
          target.includes('[placeholder=') ||
          target.startsWith('text=');
        if (isSoftTarget) {
          const count = await locator.count().catch(() => 0);
          if (count === 0) {
            log('Assert', `  ⚠ "${target}" not in DOM — soft pass`);
            this._softPass = true;
            break;
          }
        }

        try {
          await locator.waitFor({ state: 'visible', timeout });
        } catch (e) {
          // If timeout on text= selector, soft-pass (VL dynamic content may differ)
          if (isSoftTarget) {
            log('Assert', `  ⚠ "${target}" visibility timeout — soft pass`);
            this._softPass = true;
            break;
          }
          throw e;
        }
        break;
      }

      case 'assert_hidden': {
        if (!selector) throw new Error(`assert_hidden: missing selector field`);
        const locator = await this._resolveSelector(selector);
        if (!locator) throw new Error(`assert_hidden: cannot resolve selector "${selector}"`);
        await locator.waitFor({ state: 'hidden', timeout });
        break;
      }

      case 'assert_text': {
        if (!selector) throw new Error(`assert_text: missing selector field`);
        const locator = await this._resolveSelector(selector);
        if (!locator) throw new Error(`assert_text: cannot resolve selector "${selector}"`);
        await locator.waitFor({ state: 'visible', timeout });
        const text = await locator.textContent({ timeout });
        if (expected && !text.includes(expected)) {
          throw new Error(`Text assertion failed: expected "${expected}", actual "${text.slice(0, 100)}"`);
        }
        break;
      }

      case 'assert_url': {
        const currentUrl = this.page.url();
        const exp = expected || value;
        if (exp && !currentUrl.includes(exp)) {
          throw new Error(`URL assertion failed: expected to contain "${exp}", actual "${currentUrl}"`);
        }
        break;
      }

      case 'assert_count': {
        const count = await this.page.locator(selector).count();
        const expectedCount = parseInt(expected);
        if (!isNaN(expectedCount) && count !== expectedCount) {
          throw new Error(`Count assertion failed: expected ${expectedCount}, actual ${count}`);
        }
        break;
      }

      case 'screenshot': {
        try {
          const ssPath = join(this.config.screenshotDir, `manual_${Date.now()}.png`);
          await this.page.screenshot({ path: ssPath, fullPage: false });
        } catch (ssErr) {
          // Screenshot is a diagnostic action, not a functional test — soft-pass on failure
          log('Screenshot', `Screenshot failed (non-fatal): ${ssErr.message.slice(0, 100)}`);
          this._softPass = true;
        }
        break;
      }

      case 'wait': {
        if (selector) {
          const locator = await this._resolveSelector(selector);
          if (!locator) throw new Error(`wait: cannot resolve selector "${selector}"`);
          await locator.waitFor({ state: 'visible', timeout });
        } else {
          const ms = Math.min(parseInt(value || '500'), 2000); // Cap at 2s
          await this.page.waitForTimeout(ms);
        }
        break;
      }

      case 'type': {
        if (!selector) throw new Error(`type: missing selector field`);
        const locator = await this._resolveSelector(selector);
        if (!locator) throw new Error(`type: cannot resolve selector "${selector}"`);
        await locator.waitFor({ state: 'visible', timeout });
        await locator.pressSequentially(value || '', { delay: 50 });
        break;
      }

      case 'press': {
        await this.page.keyboard.press(value || selector);
        break;
      }

      case 'hover': {
        const locator = await this._resolveSelector(selector);
        await locator.waitFor({ state: 'visible', timeout });
        await locator.hover();
        break;
      }

      // 'assert' is a generic alias: checks text on page or element visibility.
      // selector can be 'text=xxx' (checks page contains text) or a CSS/instanceId selector.
      case 'assert': {
        const target = selector || expected || value;
        if (!target) { log('Assert', '  assert: nothing to assert — soft pass'); this._softPass = true; break; }

        // 'assert text=xxx' → page-level text presence check (most common LLM usage)
        if (target.startsWith('text=')) {
          const searchText = target.slice(5).trim();
          try {
            const loc = this.page.locator(`text=${searchText}`).first();
            const visible = await loc.isVisible({ timeout: Math.min(timeout, 5000) }).catch(() => false);
            if (!visible) {
              log('Assert', `  ⚠ assert "${searchText}" not found — soft pass`);
              this._softPass = true;
            } else {
              log('Assert', `  ✓ assert "${searchText}" found`);
            }
          } catch {
            log('Assert', `  ⚠ assert "${searchText}" error — soft pass`);
            this._softPass = true;
          }
          break;
        }

        // Otherwise: assert element visible (like assert_visible)
        const loc = await this._resolveSelector(target);
        if (!loc) { log('Assert', `  ⚠ assert: cannot resolve "${target}" — soft pass`); this._softPass = true; break; }
        if (target.includes('[instance-id=') || target.startsWith('text=')) {
          const count = await loc.count().catch(() => 0);
          if (count === 0) {
            log('Assert', `  ⚠ "${target}" not in DOM — soft pass`);
            this._softPass = true;
            break;
          }
        }
        try {
          await loc.waitFor({ state: 'visible', timeout: Math.min(timeout, 6000) });
          if (expected) {
            const txt = await loc.textContent({ timeout: 3000 }).catch(() => '');
            if (!txt.includes(expected)) {
              log('Assert', `  ⚠ assert text mismatch: expected "${expected}", got "${txt.slice(0,80)}" — soft pass`);
              this._softPass = true;
            }
          }
          log('Assert', `  ✓ assert passed`);
        } catch {
          log('Assert', `  ⚠ assert element not visible — soft pass`);
          this._softPass = true;
        }
        break;
      }

      default:
        throw new Error(`Unknown action: ${action}`);
    }
  }

  /**
   * Navigate to the app and capture a deep DOM snapshot for test generation.
   * Returns: { url, title, snapshot, elementSummary }
   */
  async discoverDOM(url) {
    url = url || this.config.baseUrl;
    log('DiscoverDOM', `Navigating to ${url} for DOM discovery...`);
    await this._navigateTo(url);

    const snapshot = await this._getSnapshot(30);
    const title = await this.page.title();

    // Also get a structured summary of interactive elements
    const elementSummary = await this.page.evaluate(() => {
      const results = [];
      const allEls = document.querySelectorAll('button, input, textarea, select, a, [role], [data-id], [data-instance-id], [instance-id], [data-compid], [placeholder], [aria-label]');
      for (const el of allEls) {
        const tag = el.tagName.toLowerCase();
        const info = { tag };
        if (el.id) info.id = el.id;
        if (el.textContent?.trim()) info.text = el.textContent.trim().slice(0, 80);
        if (el.getAttribute('type')) info.type = el.getAttribute('type');
        if (el.getAttribute('role')) info.role = el.getAttribute('role');
        if (el.getAttribute('placeholder')) info.placeholder = el.getAttribute('placeholder');
        if (el.getAttribute('instance-id')) info.instanceId = el.getAttribute('instance-id');
        else if (el.getAttribute('data-compid')) info.instanceId = el.getAttribute('data-compid');
        else if (el.getAttribute('data-instance-id')) info.instanceId = el.getAttribute('data-instance-id');
        if (el.getAttribute('data-id')) info.dataId = el.getAttribute('data-id');
        if (el.getAttribute('aria-label')) info.ariaLabel = el.getAttribute('aria-label');
        if (el.value) info.value = el.value.slice(0, 40);
        if (el.className && typeof el.className === 'string') {
          info.classes = el.className.trim().split(/\s+/).filter(c => !c.startsWith('ih5-native')).slice(0, 5).join(' ');
        }
        results.push(info);
      }
      // Also find elements with visible text
      const textEls = document.querySelectorAll('*');
      for (const el of textEls) {
        if (el.children.length === 0 || (el.childNodes.length === 1 && el.childNodes[0].nodeType === 3)) {
          const text = el.textContent?.trim();
          if (text && text.length > 0 && text.length < 100) {
            const tag = el.tagName.toLowerCase();
            if (!['script', 'style', 'meta', 'link'].includes(tag)) {
              const existing = results.find(r => r.text === text && r.tag === tag);
              if (!existing) {
                const info = { tag, text };
                if (el.className && typeof el.className === 'string') {
                  info.classes = el.className.trim().split(/\s+/).filter(c => !c.startsWith('ih5-native')).slice(0, 3).join(' ');
                }
                results.push(info);
              }
            }
          }
        }
      }
      return results.slice(0, 100); // Cap at 100 elements
    });

    log('DiscoverDOM', `Found ${elementSummary.length} interactive/text elements`);
    log('DiscoverDOM', `Snapshot: ${snapshot.length} chars`);

    return { url, title, snapshot, elementSummary };
  }

  /**
   * Discover pages by crawling
   */
  async discoverPages(maxPages = 10) {
    const pages = [];
    const visited = new Set();

    const crawl = async (url) => {
      if (visited.has(url) || pages.length >= maxPages) return;
      visited.add(url);
      try {
        await this._navigateTo(url);
        const snapshot = await this._getSnapshot();
        const title = await this.page.title();
        pages.push({ url, title, snapshot });
        const links = await this.page.evaluate((baseUrl) => {
          return Array.from(document.querySelectorAll('a[href]'))
            .map(a => a.href)
            .filter(href => href.startsWith(baseUrl) || href.startsWith('/'))
            .slice(0, 20);
        }, this.config.baseUrl);
        for (const link of links) {
          if (pages.length >= maxPages) break;
          await crawl(link);
        }
      } catch (e) {}
    };

    await crawl(this.config.baseUrl);
    return pages;
  }

  /**
   * Static one-shot nav discovery: behaviorally determines which [data-vl-id]/[instance-id] elements
   * are true page navigation buttons vs in-page action buttons.
   *
   * Strategy:
   * 1. Load home page, snapshot all visible [data-vl-id]/[instance-id] elements + their positions
   * 2. For each sidebar/topbar candidate: click it, wait, check if major DOM change occurred
   *    - "Major change" = 3+ new elements became visible that were not visible on home page
   *    - This correctly identifies page-switching nav buttons
   *    - Action buttons (open modal/dialog/etc.) DON'T make previously-invisible section elements appear
   * 3. Reclassify candidates that don't cause page changes as 'content'
   *
   * Returns array of: { instanceId, text, x, y, w, h, position, isNavButton, revealsText }
   * position: 'sidebar' | 'topbar' | 'content'
   */
  static async discoverNavElementsOnce(baseUrl) {
    const { chromium } = await import('playwright');
    log('NavDiscover', `Launching temp browser for behavioral nav discovery: ${baseUrl}`);
    const br = await chromium.launch({ headless: true });
    const ctx = await br.newContext({
      viewport: { width: 1920, height: 1080 },
      ignoreHTTPSErrors: true,
    });
    const page = await ctx.newPage();

    const getPageState = async () => page.evaluate(() => {
      return Array.from(document.querySelectorAll('[data-vl-id], [instance-id]')).map(el => {
        const rect = el.getBoundingClientRect();
        const visible = rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight && rect.bottom > 0;
        return {
          instanceId: el.getAttribute('data-vl-id') || el.getAttribute('instance-id'),
          text: (el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60),
          visible,
          x: Math.round(rect.x),
          y: Math.round(rect.y),
          w: Math.round(rect.width),
          h: Math.round(rect.height),
        };
      });
    });

    try {
      await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 30000 });
      await page.waitForTimeout(800); // Let SPA fully render

      const homeState = await getPageState();
      const visibleAtHome = new Set(homeState.filter(e => e.visible).map(e => e.instanceId));

      // Initial position classification (heuristic)
      const classify = (el) => {
        if (el.x < 220 && el.h < 150 && el.visible) return 'sidebar';
        if (el.y < 70 && el.h < 80 && el.visible) return 'topbar';
        return 'content';
      };

      const elements = homeState.map(e => ({
        ...e,
        position: classify(e),
        isNavButton: false,
        revealsText: '',
      }));

      // Behavioral test: click each sidebar/topbar candidate and check for major page change
      const navCandidates = elements.filter(e => e.position === 'sidebar' || e.position === 'topbar');
      log('NavDiscover', `Testing ${navCandidates.length} sidebar/topbar candidates behaviorally...`);

      for (const candidate of navCandidates) {
        try {
          // Reset to home page
          await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 15000 });
          await page.waitForTimeout(500);

          // Click the candidate element
          const loc = page.locator(`[data-vl-id="${candidate.instanceId}"], [instance-id="${candidate.instanceId}"]`).first();
          if (await loc.count() === 0) continue;
          await loc.click({ timeout: 3000 });
          await page.waitForTimeout(600); // Wait for SPA page transition

          // Check what changed
          const afterState = await getPageState();
          // Elements that are NOW visible but were NOT visible on home page
          const newlyVisible = afterState.filter(e => e.visible && !visibleAtHome.has(e.instanceId));

          // It's a navigation button if 3+ NEW content elements appeared
          // (page switched to a different section with new elements)
          const isNav = newlyVisible.length >= 3;

          candidate.isNavButton = isNav;
          if (isNav) {
            const revealedTexts = [...new Set(newlyVisible.map(e => e.text).filter(t => t && t.length > 1))].slice(0, 4);
            candidate.revealsText = revealedTexts.join(', ');
            candidate.revealsIds = newlyVisible.map(e => e.instanceId).slice(0, 6);
            log('NavDiscover', `  ✓ NAV: "${candidate.instanceId}" (${candidate.text}) → reveals ${newlyVisible.length} new elements [${candidate.revealsIds.join(', ')}]`);
          } else {
            // Sidebar element but NOT a nav button (opens modal, dialog, etc.) → reclassify
            candidate.position = 'content';
            log('NavDiscover', `  ✗ NOT nav: "${candidate.instanceId}" (${candidate.text}) → only ${newlyVisible.length} new elements (action button)`);
          }
        } catch (err) {
          // Click failed or element gone — treat as non-nav
          candidate.position = 'content';
          candidate.isNavButton = false;
          log('NavDiscover', `  ? Error testing "${candidate.instanceId}": ${err.message}`);
        }
      }

      const navCount = elements.filter(e => e.isNavButton).length;
      const contentCount = elements.filter(e => e.position === 'content').length;
      log('NavDiscover', `Done: ${navCount} nav buttons, ${contentCount} content elements`);
      return elements;
    } catch (err) {
      log('NavDiscover', `Failed: ${err.message}`);
      return [];
    } finally {
      await br.close().catch(() => {});
    }
  }
}
