7. Cookie、存储与状态管理

7.Cookie、存储与状态管理

在 Web 自动化测试中,状态管理是一个绕不开的话题。登录态保持、用户偏好设置、会话信息恢复……这些场景背后都离不开 Cookie、本地存储和浏览器权限的支持。Puppeteer 作为直接操作 Chrome 浏览器的工具,提供了非常底层的 API 来管理这些状态数据。本章将深入探讨如何在 Puppeteer 中读取、设置、删除 Cookie,管理本地存储,以及控制浏览器权限。

读取页面 Cookie 信息

Cookie 是服务器留在浏览器里的小纸条,记录着用户的身份凭证、偏好设置等信息。在自动化测试中,我们经常需要读取这些信息来验证登录状态或追踪用户行为。

从浏览器上下文获取 Cookie

Puppeteer 提供了两种读取 Cookie 的方式:通过 Browser 对象或 BrowserContext 对象。默认情况下,Browser 对象的方法操作的是默认浏览器上下文。

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com');

// 通过页面脚本设置一个测试 Cookie
await page.evaluate(() => {
  document.cookie = 'myCookie=MyCookieValue; path=/';
});

// 获取所有可用的 Cookie
const cookies = await browser.cookies();
console.log(cookies);

这段代码首先启动浏览器并打开一个页面,然后通过 page.evaluate 在页面上下文中执行 JavaScript 代码来设置 Cookie。最后调用 browser.cookies() 获取当前浏览器上下文中所有的 Cookie。返回的 Cookie 是一个数组,每个元素都是一个包含 name、value、domain、path 等属性的对象。

需要注意的是,browser.cookies() 获取的是浏览器存储中的所有 Cookie,不仅限于当前页面。如果只想获取特定域名的 Cookie,可以传递 URL 参数:

// 只获取 example.com 域下的 Cookie
const domainCookies = await browser.cookies('https://example.com');
console.log(domainCookies);

从页面上下文直接读取

有时候我们需要在页面执行过程中实时获取 Cookie,这时可以直接在 page.evaluate 中读取 document.cookie:

// 获取当前页面所有 Cookie 的字符串形式
const cookieString = await page.evaluate(() => {
  return document.cookie;
});
console.log(cookieString); // "myCookie=MyCookieValue; anotherCookie=123"

// 解析为对象形式
const cookies = await page.evaluate(() => {
  return document.cookie.split(';').reduce((acc, cookie) => {
    const [name, value] = cookie.trim().split('=');
    acc[name] = value;
    return acc;
  }, {} as Record<string, string>);
});
console.log(cookies); // { myCookie: 'MyCookieValue', anotherCookie: '123' }

这种方式获取的是当前页面可访问的 Cookie,受 HttpOnly 标志的限制——标记为 HttpOnly 的 Cookie 无法通过 JavaScript 读取,这是浏览器的重要安全机制。因此,如果需要获取完整的 Cookie 信息,包括 HttpOnly 类型的,必须使用 browser.cookies() 方法。

处理跨域 Cookie

在现代 Web 应用中,跨域请求和第三方 Cookie 很常见。Puppeteer 默认会返回所有匹配的 Cookie,包括第三方 Cookie。如果需要区分,可以通过 domain 属性进行过滤:

const allCookies = await browser.cookies();
const firstPartyCookies = allCookies.filter(cookie => 
  cookie.domain === '.example.com' || cookie.domain === 'example.com'
);
const thirdPartyCookies = allCookies.filter(cookie => 
  !cookie.domain.includes('example.com')
);

console.log('第一方 Cookie:', firstPartyCookies);
console.log('第三方 Cookie:', thirdPartyCookies);

这段代码展示了如何区分第一方和第三方 Cookie。第一方 Cookie 的域名与当前页面域名一致,而第三方 Cookie 来自其他域名,通常是广告追踪或分析服务设置的。

设置与删除 Cookie

读取 Cookie 只是第一步,更强大的能力在于主动设置和删除 Cookie。这在模拟登录状态、测试不同用户场景时特别有用。

设置 Cookie 的完整语法

Puppeteer 的 browser.setCookie() 方法允许我们精确控制 Cookie 的每个属性:

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();

// 设置两个测试 Cookie
await browser.setCookie(
  {
    name: 'session_id',
    value: 'abc123xyz',
    domain: 'localhost',
    path: '/',
    expires: -1, // -1 表示会话 Cookie,浏览器关闭后失效
    httpOnly: true, // 禁止 JavaScript 访问
    secure: false, // 允许 HTTP 传输
    sameSite: 'Lax', // 防止 CSRF 攻击
  },
  {
    name: 'user_preference',
    value: 'dark_mode',
    domain: 'localhost',
    path: '/settings',
    expires: Date.now() / 1000 + 86400 * 30, // 30 天后过期
    httpOnly: false,
    secure: true, // 仅 HTTPS 传输
    sameSite: 'Strict',
  },
);

console.log(await browser.cookies());

这段代码展示了设置 Cookie 的核心参数。每个参数都有特定用途:

  • name 和 value:Cookie 的名称和值,这是唯一必需的参数
  • domain:Cookie 所属的域名,必须以点号开头或完全匹配
  • path:Cookie 生效的路径,默认为 /
  • expires:过期时间,以秒为单位的 Unix 时间戳,-1 表示会话 Cookie
  • httpOnly:如果为 true,Cookie 无法通过 JavaScript 访问,能有效防止 XSS 攻击
  • secure:如果为 true,Cookie 只能通过 HTTPS 传输
  • sameSite:防止 CSRF 攻击,可选值为 Strict、Lax 或 None
  • sameParty:Chrome 的 First-Party Sets 功能相关,通常设为 false

在特定上下文中设置 Cookie

如果使用了多个浏览器上下文,需要在特定上下文中设置 Cookie:

const context = await browser.createBrowserContext();
const page = await context.newPage();

await context.setCookie({
  name: 'context_specific',
  value: 'value123',
  domain: '.example.com',
});

// 这个 Cookie 只在当前 context 中可见
console.log(await context.cookies());

这种方式实现了 Cookie 的隔离,不同上下文之间的 Cookie 互不干扰,非常适合多用户并发测试场景。

删除 Cookie 的精确控制

删除 Cookie 需要精确匹配其属性,不仅仅是名称:

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();

// 删除特定 Cookie
await browser.deleteCookie(
  {
    name: 'session_id',
    domain: 'localhost',
    path: '/',
  },
  {
    name: 'user_preference',
    domain: 'localhost',
    path: '/settings',
  },
);

console.log(await browser.cookies()); // 确认删除结果

browser.deleteCookie() 方法需要指定 Cookie 的 name、domain 和 path 才能准确定位并删除。如果提供的属性不匹配,删除操作会静默失败,不会抛出错误。因此,删除前最好先读取确认 Cookie 的完整信息。

批量清空 Cookie

测试结束后,通常需要清理环境。可以批量删除所有 Cookie:

// 获取并删除所有 Cookie
const cookies = await browser.cookies();
for (const cookie of cookies) {
  await browser.deleteCookie({
    name: cookie.name,
    domain: cookie.domain,
    path: cookie.path,
  });
}

// 验证清空结果
console.log('剩余 Cookie 数量:', (await browser.cookies()).length); // 0

这段代码先获取所有 Cookie,然后遍历删除。注意删除操作是异步的,但 Puppeteer 内部会按顺序处理,不需要额外等待。

实际应用场景:登录状态保持

在自动化测试中,最常见的用途是保持登录状态,避免每次测试都重复登录流程:

async function saveLoginState(browser: puppeteer.Browser, filePath: string) {
  const cookies = await browser.cookies();
  await fs.promises.writeFile(filePath, JSON.stringify(cookies, null, 2));
}

async function restoreLoginState(browser: puppeteer.Browser, filePath: string) {
  const cookieData = await fs.promises.readFile(filePath, 'utf-8');
  const cookies = JSON.parse(cookieData);
  for (const cookie of cookies) {
    await browser.setCookie(cookie);
  }
}

// 使用示例
// 第一次运行:执行登录并保存状态
await page.goto('https://example.com/login');
await page.locator('#username').fill('testuser');
await page.locator('#password').fill('password');
await page.locator('#login-btn').click();
await page.waitForNavigation();
await saveLoginState(browser, './cookies.json');

// 后续运行:直接恢复状态
await restoreLoginState(browser, './cookies.json');
await page.goto('https://example.com/dashboard');
// 已处于登录状态

这个模式将登录后的 Cookie 持久化到文件,后续测试直接加载,大幅提升了测试效率。需要注意的是,Cookie 可能有过期时间,需要定期更新保存的登录状态。

管理本地存储与 Session

除了 Cookie,现代 Web 应用还广泛使用 localStorage 和 sessionStorage 来存储客户端数据。Puppeteer 通过 page.evaluate 提供了对这些存储的完全控制。

操作 localStorage

localStorage 用于持久化存储键值对数据,除非手动清除,否则长期有效:

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com');

// 设置 localStorage 数据
await page.evaluate(() => {
  localStorage.setItem('theme', 'dark');
  localStorage.setItem('language', 'zh-CN');
  localStorage.setItem('userId', 'user123');
});

// 读取 localStorage 数据
const theme = await page.evaluate(() => {
  return localStorage.getItem('theme');
});
console.log('当前主题:', theme); // dark

// 批量读取所有数据
const allStorage = await page.evaluate(() => {
  const items: Record<string, string> = {};
  for (let i = 0; i < localStorage.length; i++) {
    const key = localStorage.key(i);
    if (key) {
      items[key] = localStorage.getItem(key) || '';
    }
  }
  return items;
});
console.log('所有 localStorage 数据:', allStorage);

// 删除特定项
await page.evaluate(() => {
  localStorage.removeItem('theme');
});

// 清空所有 localStorage
await page.evaluate(() => {
  localStorage.clear();
});

这段代码展示了 localStorage 的完整操作流程。由于 localStorage 是页面域名的隔离存储,必须在页面加载完成后才能访问。如果尝试在 about:blank 页面或其他域名页面访问,会抛出安全错误。

操作 sessionStorage

sessionStorage 与 localStorage API 类似,但数据只在当前会话有效,关闭标签页后自动清除:

// 设置 sessionStorage
await page.evaluate(() => {
  sessionStorage.setItem('tempData', 'someValue');
  sessionStorage.setItem('sessionId', 'sess123');
});

// 读取 sessionStorage
const sessionData = await page.evaluate(() => {
  const items: Record<string, string> = {};
  for (let i = 0; i < sessionStorage.length; i++) {
    const key = sessionStorage.key(i);
    if (key) {
      items[key] = sessionStorage.getItem(key) || '';
    }
  }
  return items;
});
console.log('sessionStorage 数据:', sessionData);

sessionStorage 的使用场景通常是临时数据存储,比如表单草稿、页面间传递的临时参数等。在自动化测试中,我们可能需要模拟这些临时数据的设置和读取。

持久化本地存储状态

与 Cookie 类似,本地存储的状态也可以持久化并在后续测试中恢复:

import fs from 'fs';
import puppeteer from 'puppeteer';

async function saveStorageState(page: puppeteer.Page, filePath: string) {
  const storageState = await page.evaluate(() => {
    const local: Record<string, string> = {};
    const session: Record<string, string> = {};
    
    // 保存 localStorage
    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i);
      if (key) {
        local[key] = localStorage.getItem(key) || '';
      }
    }
    
    // 保存 sessionStorage
    for (let i = 0; i < sessionStorage.length; i++) {
      const key = sessionStorage.key(i);
      if (key) {
        session[key] = sessionStorage.getItem(key) || '';
      }
    }
    
    return { local, session };
  });
  
  await fs.promises.writeFile(filePath, JSON.stringify(storageState, null, 2));
}

async function restoreStorageState(page: puppeteer.Page, filePath: string) {
  const storageData = await fs.promises.readFile(filePath, 'utf-8');
  const { local, session } = JSON.parse(storageData);
  
  await page.evaluate((data) => {
    // 恢复 localStorage
    Object.entries(data.local).forEach(([key, value]) => {
      localStorage.setItem(key, value);
    });
    
    // 恢复 sessionStorage
    Object.entries(data.session).forEach(([key, value]) => {
      sessionStorage.setItem(key, value);
    });
  }, { local, session });
}

// 使用示例
await page.goto('https://example.com');
// ... 执行一些操作,产生本地存储数据
await saveStorageState(page, './storage-state.json');

// 在新会话中恢复
const newPage = await browser.newPage();
await newPage.goto('https://example.com');
await restoreStorageState(newPage, './storage-state.json');

这个模式对于测试依赖本地存储的应用非常有用,比如单页应用的用户设置、缓存数据等。

IndexedDB 的高级操作

对于复杂的数据存储需求,现代应用会使用 IndexedDB。Puppeteer 同样可以通过 page.evaluate 操作 IndexedDB,但 API 较为复杂:

// 读取 IndexedDB 数据
const dbData = await page.evaluate(() => {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('MyDatabase', 1);
    
    request.onsuccess = (event) => {
      const db = (event.target as IDBOpenDBRequest).result;
      const transaction = db.transaction(['users'], 'readonly');
      const store = transaction.objectStore('users');
      const getAllRequest = store.getAll();
      
      getAllRequest.onsuccess = () => {
        resolve(getAllRequest.result);
      };
      
      getAllRequest.onerror = () => {
        reject(getAllRequest.error);
      };
    };
    
    request.onerror = () => {
      reject(request.error);
    };
  });
});

console.log('IndexedDB 数据:', dbData);

由于 IndexedDB 操作是异步的,需要在 page.evaluate 中返回 Promise。Puppeteer 会自动等待 Promise resolve。这种方式可以读取、修改甚至清空 IndexedDB 中的数据,但代码相对复杂,需要处理数据库版本、事务等概念。

浏览器权限控制

现代浏览器提供了多种权限 API,如地理位置、通知、摄像头等。在自动化测试中,我们经常需要模拟用户授予或拒绝这些权限。

权限控制基础

Puppeteer 通过 BrowserContext.overridePermissions 方法控制权限:

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const context = browser.defaultBrowserContext();

// 授予地理位置权限
await context.overridePermissions('https://example.com', ['geolocation']);

// 授予多个权限
await context.overridePermissions('https://example.com', [
  'notifications',
  'camera',
  'microphone'
]);

// 撤销所有权限
await context.clearPermissionOverrides();

权限设置是针对整个浏览器上下文的,会影响该上下文中的所有页面。权限一旦设置,会持续生效直到被清除或浏览器关闭。

常见权限类型

Puppeteer 支持的权限类型包括:

// 地理位置权限
await context.overridePermissions('https://maps.example.com', ['geolocation']);

// 通知权限
await context.overridePermissions('https://chat.example.com', ['notifications']);

// 媒体设备权限
await context.overridePermissions('https://video.example.com', [
  'camera',
  'microphone'
]);

// 剪贴板访问权限
await context.overridePermissions('https://docs.example.com', [
  'clipboard-read',
  'clipboard-write'
]);

// 传感器权限
await context.overridePermissions('https://game.example.com', [
  'accelerometer',
  'gyroscope'
]);

每个权限字符串对应浏览器的一种功能。设置权限后,页面调用相关 API 时不会弹出授权对话框,而是直接获得授权状态。

实际应用:测试地理定位功能

假设要测试一个依赖地理位置的 Web 应用:

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const context = browser.defaultBrowserContext();
const page = await browser.newPage();

// 1. 授予地理位置权限
await context.overridePermissions('https://weather.example.com', ['geolocation']);

// 2. 模拟地理位置坐标
await page.setGeolocation({
  latitude: 39.9042, // 北京纬度
  longitude: 116.4074, // 北京经度
  accuracy: 100 // 精度,单位为米
});

// 3. 访问页面并测试
await page.goto('https://weather.example.com');
await page.waitForSelector('.location-info');

// 4. 验证位置信息
const locationText = await page.locator('.location-info').textContent();
console.log('检测到的位置:', locationText); // 应显示北京相关信息

// 5. 测试拒绝权限的场景
await context.overridePermissions('https://weather.example.com', []);
await page.reload();
const errorMessage = await page.locator('.error-message').textContent();
console.log('错误信息:', errorMessage); // 应显示权限被拒绝的提示

这个例子展示了完整的权限测试流程:授权、模拟数据、验证功能,以及测试拒绝权限的异常情况。page.setGeolocation 用于模拟具体的地理位置坐标,与权限授权配合使用。

权限与存储的关系

权限设置和存储管理经常配合使用。例如,测试 PWA 应用时,可能需要同时设置存储权限和初始化 IndexedDB:

// 为 PWA 设置必要权限
await context.overridePermissions('https://pwa.example.com', [
  'notifications',
  'persistent-storage' // 持久化存储权限
]);

// 初始化应用数据
await page.goto('https://pwa.example.com');
await page.evaluate(() => {
  // 初始化 IndexedDB
  const request = indexedDB.open('PWA_DB', 1);
  request.onupgradeneeded = (event) => {
    const db = (event.target as IDBOpenDBRequest).result;
    if (!db.objectStoreNames.contains('settings')) {
      db.createObjectStore('settings');
    }
  };
});

// 设置初始配置
await page.evaluate(() => {
  const dbRequest = indexedDB.open('PWA_DB', 1);
  dbRequest.onsuccess = (event) => {
    const db = (event.target as IDBOpenDBRequest).result;
    const transaction = db.transaction(['settings'], 'readwrite');
    const store = transaction.objectStore('settings');
    store.put('dark', 'theme');
    store.put(true, 'notificationsEnabled');
  };
});

这种模式确保 PWA 应用在测试环境中拥有正确的权限和初始数据,避免测试过程中的不确定行为。

状态管理的最佳实践

在实际项目中,状态管理代码需要良好的组织才能维护。

封装状态管理类

import puppeteer from 'puppeteer';
import fs from 'fs';

class BrowserStateManager {
  private browser: puppeteer.Browser;
  
  constructor(browser: puppeteer.Browser) {
    this.browser = browser;
  }
  
  async saveAllState(page: puppeteer.Page, filePath: string) {
    const [cookies, storage] = await Promise.all([
      this.browser.cookies(),
      page.evaluate(() => {
        const local: Record<string, string> = {};
        const session: Record<string, string> = {};
        
        for (let i = 0; i < localStorage.length; i++) {
          const key = localStorage.key(i);
          if (key) local[key] = localStorage.getItem(key) || '';
        }
        
        for (let i = 0; i < sessionStorage.length; i++) {
          const key = sessionStorage.key(i);
          if (key) session[key] = sessionStorage.getItem(key) || '';
        }
        
        return { local, session };
      })
    ]);
    
    await fs.promises.writeFile(filePath, JSON.stringify({ cookies, storage }, null, 2));
  }
  
  async restoreAllState(page: puppeteer.Page, filePath: string) {
    const data = JSON.parse(await fs.promises.readFile(filePath, 'utf-8'));
    
    // 恢复 Cookie
    for (const cookie of data.cookies) {
      await this.browser.setCookie(cookie);
    }
    
    // 恢复存储
    await page.evaluate((storage) => {
      Object.entries(storage.local).forEach(([k, v]) => localStorage.setItem(k, v));
      Object.entries(storage.session).forEach(([k, v]) => sessionStorage.setItem(k, v));
    }, data.storage);
  }
  
  async clearAllState(page: puppeteer.Page) {
    // 清除 Cookie
    const cookies = await this.browser.cookies();
    for (const cookie of cookies) {
      await this.browser.deleteCookie({
        name: cookie.name,
        domain: cookie.domain,
        path: cookie.path
      });
    }
    
    // 清除存储
    await page.evaluate(() => {
      localStorage.clear();
      sessionStorage.clear();
    });
  }
}

// 使用示例
const browser = await puppeteer.launch();
const page = await browser.newPage();
const stateManager = new BrowserStateManager(browser);

await page.goto('https://example.com');
// ... 执行操作
await stateManager.saveAllState(page, './full-state.json');

// 清理环境
await stateManager.clearAllState(page);

// 恢复状态
await stateManager.restoreAllState(page, './full-state.json');

这个封装类提供了统一的状态管理接口,将 Cookie、localStorage 和 sessionStorage 的操作整合在一起,简化了测试代码。

状态隔离策略

在并行测试中,状态隔离至关重要。每个测试用例应该使用独立的浏览器上下文:

import puppeteer from 'puppeteer';

async function runTest(userId: string) {
  const browser = await puppeteer.launch();
  const context = await browser.createBrowserContext(); // 创建独立上下文
  
  const page = await context.newPage();
  
  // 设置该用户特有的状态
  await context.setCookie({
    name: 'user_id',
    value: userId,
    domain: '.example.com'
  });
  
  await page.evaluate((id) => {
    localStorage.setItem('currentUser', id);
  }, userId);
  
  // 执行测试
  await page.goto('https://example.com/dashboard');
  // ... 断言和验证
  
  // 清理:关闭上下文,所有状态自动清除
  await context.close();
  await browser.close();
}

// 并行运行多个测试
await Promise.all([
  runTest('user1'),
  runTest('user2'),
  runTest('user3')
]);

这种方式确保每个测试用例在完全隔离的环境中运行,避免了状态污染导致的 flaky tests。

敏感数据处理

处理包含敏感信息的状态时,需要注意数据安全:

// 不推荐:直接保存包含密码的 Cookie
const cookies = await browser.cookies();
await fs.promises.writeFile('./cookies.json', JSON.stringify(cookies));

// 推荐:过滤敏感信息后再保存
const safeCookies = cookies.filter(cookie => 
  !cookie.name.toLowerCase().includes('password') &&
  !cookie.name.toLowerCase().includes('token')
);
await fs.promises.writeFile('./cookies-safe.json', JSON.stringify(safeCookies));

// 或者对敏感值进行加密
import crypto from 'crypto';
const encrypt = (text: string) => {
  const cipher = crypto.createCipher('aes-256-cbc', 'secret-key');
  return cipher.update(text, 'utf8', 'hex') + cipher.final('hex');
};

const encryptedCookies = cookies.map(cookie => ({
  ...cookie,
  value: cookie.httpOnly ? encrypt(cookie.value) : cookie.value
}));

这些措施可以防止敏感信息泄露到日志文件或版本控制系统中。

总结

本章深入探讨了 Puppeteer 中的状态管理技术。Cookie 的读取、设置和删除提供了模拟用户身份的能力;localStorage 和 sessionStorage 的管理让我们能够控制客户端持久化数据;浏览器权限控制则为测试现代 Web API 提供了基础。

这些技术在实际项目中往往需要组合使用。例如,测试一个完整的电商流程可能需要:设置登录 Cookie、初始化用户偏好 localStorage、授予地理位置权限来定位最近门店。通过合理封装和状态隔离,可以构建出稳定可靠的自动化测试体系。

在下一章中,我们将探讨如何将页面内容保存为截图和 PDF,以及如何处理文件上传下载等媒体操作,进一步扩展 Puppeteer 的实战能力。