performancepuppeteerperformance-testinglighthouselighthouse-ci

Lighthouse user-flow with Puppeteer always forces mobile emulation even when desktop config is set


I’m running a Lighthouse user-flow using Puppeteer to measure performance on our web app. When I execute my script with just Puppeteer, the app loads in desktop view exactly as expected.

But whenever I call Lighthouse functions like:

await flow.navigate(...);
await flow.startTimespan({ stepName: 'Open Organization (List) - Timespan' });

…the app suddenly switches to mobile layout, even though:

The browser window size remains 1920×1080

I explicitly configure Lighthouse for desktop emulation

My Setup

  import puppeteer from 'puppeteer';
    import { startFlow } from 'lighthouse';
    
    const DESKTOP_UA =
      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36';
    
    export default async function runFlow({ baseUrl, creds }) {
      const browser = await puppeteer.launch({ headless: false });
      const page = await browser.newPage();
    
      await page.setUserAgent(DESKTOP_UA);
      await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1, isMobile: false });
    
      const flow = await startFlow(page, {
        name: 'Velaris: Login → Cockpit → Organization',
        configContext: {
          settingsOverrides: {
            formFactor: 'desktop',
            screenEmulation: {
              mobile: false,
              width: 1920,
              height: 1080,
              deviceScaleFactor: 1,
              disabled: false
            },
            emulatedUserAgent: DESKTOP_UA
          }
        }
      });
    
      await flow.navigate(`${baseUrl}/login`, { stepName: 'Login' });
      await flow.startTimespan({ stepName: 'Open Organization' });
      // ... rest of flow
    }

What I Tried

Still, as soon as Lighthouse flow functions run, the site loads in mobile view

enter image description here


Solution

  • After struggling with chatgpt for hours, I was able to fix the issue. So, I generated a meaningful answer here so anyone can refer it later.

    Problem

    Even after setting a desktop UA and viewport in Puppeteer, Lighthouse User Flows kept flipping to mobile emulation as soon as a LH step ran. Passing:

    configContext: { settingsOverrides: { formFactor: 'desktop', screenEmulation: {...} } }
    

    didn’t help because a mobile preset/config elsewhere (or version differences) overrode those overrides.

    Root cause

    Fix

    1. Don’t inherit anything. Give Lighthouse a full config that explicitly forces desktop.

    2. Pass that full config to startFlow and every step (navigate, snapshot, startTimespan, endTimespan).

    3. Align Puppeteer (UA + viewport + window size) to match the config so recordings don’t “flip”.

    Desktop config (JSON)

    configs/lh.desktop.json

    {
      "extends": "lighthouse:default",
      "settings": {
        "preset": "desktop",
        "formFactor": "desktop",
        "emulatedFormFactor": "desktop",
        "screenEmulation": {
          "mobile": false,
          "width": 1920,
          "height": 1080,
          "deviceScaleFactor": 1,
          "disabled": false
        },
        "emulatedUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
        "throttlingMethod": "devtools",
        "throttling": {
          "rttMs": 40,
          "throughputKbps": 10240,
          "cpuSlowdownMultiplier": 1,
          "requestLatencyMs": 0,
          "downloadThroughputKbps": 0,
          "uploadThroughputKbps": 0
        }
      }
    }
    

    User-flow code (Puppeteer + Lighthouse)

    // flows/my.flow.js
    import fs from 'fs';
    import path from 'path';
    import puppeteer from 'puppeteer';          
    import { fileURLToPath } from 'url';
    import { startFlow } from 'lighthouse';
    
    const __filename = fileURLToPath(import.meta.url);
    const __dirname  = path.dirname(__filename);
    
    const selectors = JSON.parse(
      fs.readFileSync(path.join(__dirname, '..', 'selectors', 'velaris.json'), 'utf8')
    );
    
    async function waitVisible(page, sel, timeout = 20000) {
      await page.waitForSelector(sel, { visible: true, timeout });
    }
    
    function loadConfig(configPath) {
      const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
      if (!cfg?.settings?.screenEmulation) {
        throw new Error(`Invalid LH config: missing settings.screenEmulation in ${configPath}`);
      }
      return cfg;
    }
    
    // Usage: runFlow({ baseUrl, creds, configPath: 'configs/lh.desktop.json' })
    export default async function runFlow({ baseUrl, creds, configPath }) {
      if (!baseUrl) throw new Error('baseUrl is required');
      if (!creds?.user || !creds?.pass) throw new Error('creds.user and creds.pass are required');
      if (!configPath) throw new Error('configPath is required');
    
      const cfg = loadConfig(configPath);
      const se  = cfg.settings.screenEmulation;
      const ua  = cfg.settings.emulatedUserAgent;
    
      const browser = await puppeteer.launch({
        headless: false,
     
        args: ['--no-sandbox', '--disable-dev-shm-usage', `--window-size=${se.width},${se.height}`]
      });
    
      try {
        const page = await browser.newPage();
    
        // Keep the visible browser consistent with the LH config
        await page.setUserAgent(ua);
        await page.setViewport({
          width: se.width,
          height: se.height,
          deviceScaleFactor: se.deviceScaleFactor ?? 1,
          isMobile: !!se.mobile
        });
    
        // Bind LH with a FULL config (not settingsOverrides)
        const flow = await startFlow(page, {
          name: 'Velaris: Login → Cockpit (snapshot) → Organization (timespan)',
          config: cfg
        });
    
        // 1) LOGIN (Navigation) — pass the same config on EVERY step
        await flow.navigate(`${baseUrl}/login`, { stepName: 'Open Login', config: cfg });
    
        await waitVisible(page, selectors.loginEmail ?? "input[name='email']");
        await page.type(selectors.loginEmail ?? "input[name='email']", creds.user, { delay: 20 });
        await page.type(selectors.loginPassword ?? "input[name='password']", creds.pass, { delay: 20 });
    
        await Promise.all([
          page.click(selectors.loginButton ?? "button[type='submit']"),
          page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 45000 })
        ]);
    
        // (Optional) workspace selection…
    
        // 2) COCKPIT (navigate + snapshot)
        await Promise.all([
          page.click(selectors.cockpitNav ?? "[data-testid='nav-cockpit']"),
          page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 45000 })
        ]);
        await waitVisible(page, selectors.cockpitReady ?? "main.cockpit, [data-testid='cockpit-root']");
        await flow.snapshot({ stepName: 'Snapshot: Cockpit', config: cfg });
    
        // 3) ORGANIZATION (timespan)
        await flow.startTimespan({ stepName: 'Open Organization (List) - Timespan', config: cfg });
        await Promise.all([
          page.click(selectors.organizationNav ?? "[data-testid='nav-organization']"),
          page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 45000 })
        ]);
        await waitVisible(page, selectors.organizationReady ?? "[data-testid='organization-list'], [data-testid='accounts-list'], table");
        await new Promise(r => setTimeout(r, 500));
        await flow.endTimespan({ config: cfg });
    
        const reportHtml = await flow.generateReport();
        const reportJson = await flow.createFlowResult();
        // …save reports
        return { reportHtml, reportJson };
      } finally {
        await browser.close();
      }
    }
    

    Why this works

    Tip: If you want multiple form-factors, create separate JSONs (e.g., lh.desktop.json, lh.laptop.json, lh.mobile.json) and pass the desired file via CLI (--config configs/lh.desktop.json).