Node.js躬行記(26)——介面攔截和頁面回放實驗

2023-01-03 09:00:30

  最近在研究 Web自動化測試,之前做了些實踐,但效果並不理想。

  對於 QA 來說,公司的網頁互動並不多,用手點點也能滿足。對於前端來說,如果要做成自動化,就得維護一堆的指令碼。

  當然,這些指令碼也可以 QA 來維護,但前提是得讓他們覺得做這件事的 ROI 很高,依目前的情況看,好像不高。

  所以在想,做一個平臺,在這個平臺中可以儲存些資料,並且在旁邊提供個小視窗,呈現要測試的 H5 網頁,如下圖所示(畫圖工具是excalidraw)。

  在修改相關資料後,可以直接看到網頁的變化。

  

  QA 或前端可以不用再寫指令碼程式碼,就能實現自動化測試。

  目前想到兩塊,第一塊是攔截請求,mock 響應;第二塊是記錄頁面行為,然後自動回放,最後截圖,和上一次的截圖做對比分析,看是否相同。

一、攔截請求

  攔截請求就是將響應 mock 成自己想要的資料,然後檢視頁面的呈現。

  這樣就能模擬各種場景,畢竟測試環境的業務資料肯定不能滿足所有場景,所以需要自己造。

  有了平臺後,就能將造的資料儲存在資料庫中,可隨時調取檢視頁面呈現。

1)攔截

  現在就要實現攔截,我首先想到的就是注入指令碼,然後在 XMLHttpRequest 或 fetch() 埋入攔截程式碼。

  以 XMLHttpRequest 為例,在 monitorXHR() 函數中就可以讓請求轉發到代理處。

var _XMLHttpRequest = window.XMLHttpRequest; // 儲存原生的XMLHttpRequest
// 覆蓋XMLHttpRequest
window.XMLHttpRequest = function (flags) {
  var req = new _XMLHttpRequest(flags); // 呼叫原生的XMLHttpRequest
  monitorXHR(req); // 埋入我們的「間諜」
  return req;
};

  例如將所有的請求都 post 到 test/proxy 介面,這是一個 Node 介面,程式碼如下。

  程式碼比較簡單,沒有考慮各種請求,例如自定義的 header、cookie 等。因為沒有經過實踐,只是展示下思路,所以肯定存在著 BUG。

  思路就是將整理好的請求地址、引數等資訊轉發過來後,先從資料庫中檢視是否有指定的 mock 資料。

  如果有就直接返回,若沒有,就再去請求原介面。

router.post("/test/proxy", async (ctx) => {
  const { id, method, url, params } = ctx.request.body;
  // 通過ID查詢儲存在 MongoDB 中的攔截記錄
  const row = await services.app.getOne(id);
  if (row) {
    ctx.body = row.response;
    return;
  }
  // 沒有攔截就請求原介面
  const { data } = await axios[method](url, params);
  ctx.body = data;
});

  理論上,是完成了攔截,但是現在還有個很重要的問題,那就是 XMLHttpRequest 或 fetch() 那段間諜指令碼該怎麼注入。

2)注入指令碼

  暫時想到了三個方法,第一個是通過控制 iframe 在頁面中注入指令碼。

  因為那張 H5 範例頁面,可以放到 iframe 中呈現,所以這種注入方式理論上可行。

  只需要讀取 HTMLIFrameElement 中的 contentDocument 屬性就能得到頁面中的 document。

document.getElementById('inner').contentDocument.body.innerHTML

  但是 iframe 有個同源限制,必須是同源的才能通過指令碼讀取到 contentDocument。

  況且注入的時機也比較講究,必須在發起請求之前,改寫 XMLHttpRequest 或 fetch(),若用 JavaScript 新增 script 元素,恐怕不夠及時。

  那麼第二個方法,就是在構建的時候將指令碼注入,當然,在上線後,這些指令碼都是要去除掉的,僅限測試的時候使用。

  不過這種方法不夠自動化,需要研發配合,像我們這種小公司,就那麼幾個專案,倒也問題不大。

  第三個方法是用無頭瀏覽器(例如 puppeteer)將指令碼注入(如下所示),然後再把新的頁面結構作為響應返回。

await page.evaluate(async () => {
  const img = new Image();
  img.src = "xxx.png";
  document.body.appendChild(img);
});
// 獲取 HTML 結構
const html = await page.content();

  但有個地方要注意,輸出頁面結構的域名要和之前相同(需要運維配合),否則那些指令碼很有可能因為跨域而無法執行了。

二、記錄頁面行為

  網頁就是一棵 DOM 樹,要記錄頁面行為,其實就是記錄發生動作的 DOM 元素以及相關的動作引數。

  指令碼注入的方式可以參考上面的 3 種方法,平臺的佈局也與上面的類似,只是表單中的引數可能略有不同。

1)儲存 DOM 元素

  DOM 元素是不能直接 JSON 序列化的,所以需要將其對映成一個指定結構的物件,如下所示。

{
    "type": "scrollTo",
    "rect": {
        "top": 470,
        "left": 8,
        "width": 359,
        "height": 400
    },
    "scroll": {
        "top": 189.5,
        "left": 0
    },
    "tag": "div"
}

  tag 是元素型別,例如 div、button、window 等;type 是事件型別,例如點選、捲動等;rect 是座標和尺寸,scroll 是捲動距離。

  這種結構就可以順利儲存到資料庫中了。

2)監控行為

  目前實驗,就只監控了點選和捲動兩種行為。

  為 body 元素繫結 click 事件,採用捕獲的事件傳播方式。

/**
 * 監控 body 內的點選行為
 */
document.body.addEventListener('click', (e) => {
  behaviors.push({
    type: 'click',
    rect: offsetRect(e.target),
    tag: e.target.tagName.toLowerCase()
  });
}, true);

  rect 的尺寸和座標本來是通過 getBoundingClientRect() 獲取的,但是該方法參照的是視口的左上角,也就是說會隨著捲動而改變座標。

  

  所以就換了一種能更加精確獲取座標的方法,如下所示,nodeMap 是一個 Map 資料結構,key 可以是一個元素物件,用於快取計算過的元素座標。

// 元素快取
const nodeMap = new Map();
/**
 * 讀取元素真實的座標
 */
function offsetRect(node) {
  // 從快取中讀取node資訊
  const exist = nodeMap.get(node);
  if(exist) {
    return exist;
  }
  let top = 0, left = 0;
  const width = node.offsetWidth
  const height = node.offsetHeight;
  while (node) {
    top += node.offsetTop;
    left += node.offsetLeft;
    node = node.offsetParent;
  }
  const rect = { top, left, width, height };
  nodeMap.set(node, rect);  // 快取node資訊
  return rect;
}

  下面是對捲動的監控程式碼,throttle() 是一個節流函數,不節流會影響捲動的效能。

  在 startScroll() 函數中會計算卷軸距離頂部和左邊的距離,window 和元素讀取的屬性略有不同。

/**
 * 節流
 */
 function throttle(fn, wait) {
  let start = 0;
  return (e) => {
    const now = +new Date();
    if (now - start > wait) {
      fn(e);
      start = now;
    }
  };
}
/**
 * 對捲動節流
 */
const startScroll = throttle((e) => {
  const target = e.target;
  let tag, rect, scroll;
  if(target.defaultView === window) {
    tag = 'window';
    scroll = {
      top: window.pageYOffset,
      left: window.pageXOffset
    };
  }else {
    tag = target.tagName.toLowerCase();
    scroll = {
      top: target.scrollTop,
      left: target.scrollLeft
    };
    rect = offsetRect(target);
  }
  behaviors.push({
    type: 'scrollTo',
    rect,
    scroll,
    tag
  });
}, 100);
/**
 * 監控頁面的捲動行為
 */
window.addEventListener('scroll', (e) => {
  startScroll(e);
}, true);

3)還原

  在得到資料結構後,就得讓其還原,呈現完成一系列動作後的頁面。

  我寫的演演算法比較簡單,還有很大的優化空間。目前就是遍歷儲存的行為陣列,然後深度優先搜尋 body 內的所有子元素。

  當座標和尺寸滿足條件時,返回元素。不過這種方式非常依賴這兩個引數,因此只要結構發生變化,那麼動作就無法完成。

function revert(behaviors) {
  let isFind = false;
  // 深度優先遍歷
  const dfs = (node, target) => {
    if (!node) return;
    const rect = offsetRect(node);
    const tag = node.tagName.toLowerCase();
    // console.log(node, rect, target)
    // 根據座標定位元素
    if (target.tag === tag &&
      target.rect.top === rect.top &&
      target.rect.left === rect.left &&
      target.rect.width === rect.width &&
      target.rect.height === rect.height) {
      target.node = node; //記錄元素
      isFind = true;
      return;
    }
    node.children && Array.from(node.children).forEach((value) => {
      if (isFind) { return; }
      dfs(value, target);
    });
  };
  behaviors.forEach(item => {
    isFind = false;
    // window物件單獨處理
    if(item.tag === 'window') {
      item.node = window;
    }else {
      dfs(document.body, item);
    }
    const { node } = item;
    // 沒有找到符合要求的元素
    if(!node) return;
    switch(item.type) {
      case 'scrollTo':  // 捲動
        node.scrollTo({
          ...item.scroll,
          behavior: 'smooth'
        });
        break;
      default:  // 其他事件
        node[item.type]();
        break;
    }
  });
}

  scrollTo() 是一個捲動的方法,smooth 是一種平滑選項,奇怪的是,當我去掉此選項時,捲動就無法完成了。

4)截圖

  本來是計劃用指令碼來實現截圖的,可選的庫是 dom-to-imagehtml2canvas

  但是測試下來得到的截圖結果都不是很理想,於是就仍然採用 puppeteer 來實現截圖。

  先將行為指令碼注入,然後等幾秒,最後再截圖。這種截圖得到的結果比較準確,但就是執行過程有點慢,經常需要十幾秒甚至更長。

await page.evaluate(async () => {
  const scrpt = document.createElement("script");
  scrpt.src = "xx.js";
  document.body.appendChild(scrpt);
});
await page.waitForTimeout(2000);
await page.screenshot({
  path: `xx/1.png`,
  type: "png"
});

  兩張截圖的對比可以通過 pixelmatch 完成,下面是官方提供的 node.js 使用範例,pngjs 是一個 png 影象編解碼器。

const fs = require('fs');
const PNG = require('pngjs').PNG;
const pixelmatch = require('pixelmatch');

const img1 = PNG.sync.read(fs.readFileSync('img1.png'));
const img2 = PNG.sync.read(fs.readFileSync('img2.png'));
const {width, height} = img1;
const diff = new PNG({width, height});

pixelmatch(img1.data, img2.data, diff.data, width, height, {threshold: 0.1});
fs.writeFileSync('diff.png', PNG.sync.write(diff));