鸿蒙平台 Cypress 前端测试框架适配实战:基于 Electron 壳方案的纯 JavaScript BDD 测试引擎开发

Cypress 是前端生态中最流行的端到端测试框架之一,提供 describe/it/expect BDD 风格测试语法、丰富的断言库和实时测试报告。本文记录将 Cypress 核心测试能力基于 Electron 壳方案适配到鸿蒙 PC 平台的完整流程,实现一个纯前端 JavaScript 测试执行引擎,无需安装 Node.js 或联网,在离线环境下即可完成测试编写、执行和结果分析。

欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_Cypress

一、技术架构分析

1.1 功能定位

Cypress 的核心功能是对 JavaScript 代码进行单元测试和集成测试,通过 describe/it 组织测试套件,使用 expect 断言验证结果。与在命令行运行 npm install cypress 不同,本方案将测试执行引擎完全用 JavaScript 实现,无需 Node.js 子进程调用,在浏览器渲染进程中即可完成全部测试执行和结果展示。

1.2 目标架构(鸿蒙 Electron)

  • 技术栈:Electron + HTML/CSS/JavaScript + 鸿蒙 web_engine 模块
  • 核心逻辑:纯前端 JavaScript 实现 BDD 测试执行引擎,渲染进程直接完成测试运行和结果渲染
  • 无后端依赖:不启动 Node.js 子进程,不加载外部测试框架,分析结果毫秒级返回
  • 离线可用:所有测试执行在本地完成,无需网络连接

1.3 架构设计

层级 职责 技术实现
主进程 窗口管理 + 文件操作 IPC Electron BrowserWindow + fs
渲染进程 UI 交互 + 套件管理 原生 DOM + JavaScript
测试引擎 BDD 测试注册与执行 describe/it/expect + eval 沙箱
断言库 18 种 BDD 断言方法 Assertion 原型链 + not 取反
高亮引擎 JavaScript 语法着色 Tokenizer + 双层叠加渲染
样式层 深色主题 Catppuccin Mocha 配色方案

1.4 断言方法清单

本引擎实现了 18 种 BDD 风格断言方法,覆盖相等性、类型、包含、比较、正则和异常六大类别:

类别 断言方法 功能说明
相等性 toEqual, toBe 深度相等(JSON 比较)和严格相等(===)
空值判断 toBeNull, toBeUndefined, toBeDefined null/undefined/已定义检查
真假值 toBeTruthy, toBeFalsy 真值/假值判断
包含与长度 toContain, toHaveLength 数组/字符串包含检查和长度验证
数值比较 toBeGreaterThan, toBeLessThan, toBeGreaterThanOrEqual, toBeLessThanOrEqual, toBeCloseTo 大小比较和浮点精度比较
正则匹配 toMatch 正则表达式匹配
异常检查 toThrow 函数是否抛出异常(支持消息匹配)
类型与属性 toBeInstanceOf, toHaveProperty 原型链检查和属性存在性检查

所有断言方法均支持 not 取反,例如 expect(1).not.toBe(2)。

二、环境准备

2.1 开发环境要求

项目 版本/信息
操作系统 Windows 10/11
核心框架 Electron (Node.js + Chromium)
技术栈 HTML5/CSS3/Vanilla JavaScript(纯前端,零外部依赖)
目标设备 鸿蒙 PC
目标架构 arm64-v8a
开发工具 DevEco Studio(鸿蒙官方 IDE)
SDK 版本 HarmonyOS API 21+(5.0.5+)

2.2 项目结构

bash 复制代码
ohos_hap/
├── electron-apps/
│   └── Cypress/                    # Cypress 测试框架应用源码
│       ├── main.js                 # Electron 主进程(窗口 + 文件 IPC)
│       ├── renderer.js             # 渲染进程(编辑器 + 测试执行 + 结果展示)
│       ├── cypress-engine.js       # 纯 JS 测试执行引擎(331 行)
│       ├── index.html              # IDE 布局(工具栏 + 侧栏 + 编辑器 + 底部面板)
│       ├── package.json            # 项目配置
│       └── styles/
│           └── cypress.css         # Catppuccin Mocha 深色主题(733 行)
├── web_engine/                     # 鸿蒙 web_engine 模块
│   └── src/main/resources/
│       └── resfile/resources/app/  # 部署目录
├── electron/                           # Electron 原生库
│   └── libs/arm64-v8a/
│       ├── libelectron.so              # Electron 核心库
│       ├── libadapter.so               # 鸿蒙适配层库
│       └── libffmpeg.so                # 多媒体库
└── build-profile.json5                 # 鸿蒙构建配置

开发流程:在 electron-apps/Cypress/ 中开发,每次修改后同步到 web_engine/src/main/resources/resfile/resources/app/ 部署目录。

三、主进程适配(main.js)

主进程职责收敛为两件事:创建窗口和提供文件操作 IPC。测试执行完全在渲染进程中通过 CypressEngine 完成,主进程不碰业务。以下为完整真实代码:

js 复制代码
// Cypress - 前端测试框架 主进程
var electron = require('electron');
var app = electron.app;
var BrowserWindow = electron.BrowserWindow;
var screen = electron.screen;
var ipcMain = electron.ipcMain;
var dialog = electron.dialog;
var fs = require('fs');

app.disableHardwareAcceleration();

var mainWindow = null;

function createWindow() {
  var display = screen.getPrimaryDisplay();
  var width = display.workAreaSize.width;
  var height = display.workAreaSize.height;
  mainWindow = new BrowserWindow({
    width: Math.floor(width * 0.9),
    height: Math.floor(height * 0.85),
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false
    }
  });
  mainWindow.loadFile('index.html');
}

app.whenReady().then(createWindow);
app.on('window-all-closed', function() { app.quit(); });

文件操作 IPC 保留 dialog:openFile、dialog:saveFile、fs:readFile、fs:writeFile 四个 handler,供渲染进程进行测试脚本文件的打开和保存。

文件操作 IPC 完整实现如下(与真实 main.js 顺序一致):

js 复制代码
// 读取文件内容
ipcMain.handle('fs:readFile', async function(event, filePath) {
  try {
    var content = fs.readFileSync(filePath, 'utf-8');
    return { ok: true, content: content, path: filePath };
  } catch (e) {
    return { ok: false, error: e.message };
  }
});

// 写入文件内容
ipcMain.handle('fs:writeFile', async function(event, filePath, content) {
  try {
    fs.writeFileSync(filePath, content, 'utf-8');
    return { ok: true, path: filePath };
  } catch (e) {
    return { ok: false, error: e.message };
  }
});

// 打开文件对话框
ipcMain.handle('dialog:openFile', async function() {
  try {
    var result = await dialog.showOpenDialog(mainWindow, {
      properties: ['openFile'],
      filters: [
        { name: 'Test Scripts', extensions: ['js', 'spec.js'] },
        { name: '所有文件', extensions: ['*'] }
      ]
    });
    if (result.canceled) return { ok: false };
    return { ok: true, path: result.filePaths[0] };
  } catch (e) {
    return { ok: false, error: e.message };
  }
});

// 保存文件对话框
ipcMain.handle('dialog:saveFile', async function(event, defaultName) {
  try {
    var result = await dialog.showSaveDialog(mainWindow, {
      defaultPath: defaultName || 'test.spec.js',
      filters: [
        { name: 'Test Scripts', extensions: ['js'] },
        { name: '所有文件', extensions: ['*'] }
      ]
    });
    if (result.canceled) return { ok: false };
    return { ok: true, path: result.filePath };
  } catch (e) {
    return { ok: false, error: e.message };
  }
});

3.2 IDE 四区域布局

界面采用经典 IDE 四区域布局:左侧边栏(测试套件列表 + 结果摘要)、中间编辑器(带语法高亮)、底部面板(测试结果/控制台/断言详情三标签)、顶部工具栏。

js 复制代码
<!-- 工具栏 -->
<div id="toolbar">
  <div class="toolbar-left">
    <span class="app-logo">C</span>
    <span class="app-title">Cypress</span>
    <button id="btnNew" class="tb-btn" title="新建测试">📄 新建</button>
    <button id="btnOpen" class="tb-btn" title="打开文件">📂 打开</button>
    <button id="btnSave" class="tb-btn" title="保存文件">💾 保存</button>
  </div>
  <div class="toolbar-right">
    <button id="btnRunAll" class="tb-btn primary" title="运行全部测试">▶ 运行全部</button>
    <button id="btnRunSel" class="tb-btn primary" title="运行选中代码">▶▶ 选中运行</button>
    <button id="btnClear" class="tb-btn" title="清空结果">🗑️ 清空</button>
  </div>
</div>

<!-- 主体区域 -->
<div id="mainArea">
  <!-- 侧栏:测试套件 + 结果摘要 -->
  <div id="sidebar">
    <div class="sidebar-section">
      <div class="sidebar-header"><span>📝 测试套件</span></div>
      <div id="suiteTabs" class="sidebar-list"></div>
      <button id="btnAddSuite" class="sidebar-add">+ 新建测试</button>
    </div>
    <div class="sidebar-section flex-grow">
      <div class="sidebar-header"><span>📊 测试结果</span></div>
      <div id="resultSummary" class="result-summary">
        <div class="summary-empty">尚未运行测试</div>
      </div>
    </div>
  </div>

  <!-- 侧栏拖拽条 -->
  <div id="sidebarResize" class="resize-handle-v"></div>

  <!-- 右侧内容区 -->
  <div id="content">
    <!-- 编辑器(带语法高亮覆盖层) -->
    <div id="editorArea">
      <div id="editorHeader">
        <span id="fileLabel" class="file-label">未命名测试.spec.js</span>
      </div>
      <div id="editorBody" class="editor-wrap">
        <div id="lineNumbers" class="line-numbers">1</div>
        <div id="codeHighlight" class="highlight"></div>
        <textarea id="codeEditor" class="code-editor" spellcheck="false"></textarea>
      </div>
    </div>

    <!-- 底部面板拖拽条 -->
    <div id="panelResize" class="resize-handle-h"></div>

    <!-- 底部面板:测试结果/控制台/断言详情 -->
    <div id="bottomPanel">
      <div id="bottomTabs">
        <button class="bottom-tab active" data-tab="results">测试结果</button>
        <button class="bottom-tab" data-tab="console">控制台</button>
        <button class="bottom-tab" data-tab="assertions">断言详情</button>
      </div>
      <div id="tabResults" class="tab-content active">
        <div id="resultsContainer" class="results-container"></div>
      </div>
      <div id="tabConsole" class="tab-content">
        <div id="consoleOutput" class="console-output"></div>
      </div>
      <div id="tabAssertions" class="tab-content">
        <div id="assertionsContainer" class="assertions-container"></div>
      </div>
    </div>
  </div>
</div>

编辑器采用透明 textarea + 高亮覆盖层的双层渲染方案。textarea 的文字颜色和背景色均设为 transparent,仅保留 caret-color 显示光标;底层 highlight div 负责显示带语法着色的代码文本,两层滚动位置实时同步。

四、测试执行引擎(cypress-engine.js)

测试引擎是整个应用的核心,分为断言库和测试执行器两大模块。测试在渲染进程中直接执行,无需 Node.js 子进程。

断言库:Assertion 构造函数接收被测值和取反标志,18 种断言方法挂载在原型链上。not 取反通过 Object.defineProperty 定义 getter,返回一个新的取反 Assertion 实例,不产生额外断言记录。

js 复制代码
// 断言库核心
function Assertion(value, isNot) {
  this._value = value;
  this._not = isNot || false;
}

// not 取反(不产生额外断言记录)
Object.defineProperty(Assertion.prototype, 'not', {
  get: function() {
    return new Assertion(this._value, true);
  }
});

// 通用检查方法
Assertion.prototype._check = function(passed, msg) {
  var result = this._not ? !passed : passed;
  if (!result) {
    var prefix = this._not ? 'not ' : '';
    throw new Error('Expected ' + prefix + msg);
  }
  return { passed: true, message: (this._not ? 'NOT ' : '') + msg };
};

// 示例断言方法
Assertion.prototype.toEqual = function(expected) {
  var a = JSON.stringify(this._value);
  var b = JSON.stringify(expected);
  return this._check(a === b, this._fmt(this._value) + ' to equal ' + this._fmt(expected));
};

Assertion.prototype.toThrow = function(expectedMsg) {
  var threw = false;
  var errorMsg = '';
  try { this._value(); } catch (e) { threw = true; errorMsg = e.message || String(e); }
  if (expectedMsg) {
    var matched = errorMsg.indexOf(expectedMsg) >= 0;
    return this._check(threw && matched,
      'function to throw "' + expectedMsg + '" (got: "' + errorMsg + '")');
  }
  return this._check(threw, 'function to throw');
};

测试执行器:通过 _suites 数组存储注册的测试套件,_suiteStack 追踪嵌套层级。describe 负责注册套件并立即执行回调以收集 it 测试用例,run 方法负责将用户代码通过 eval 注入沙箱后统一执行。核心逻辑如下:

js 复制代码
// ========== 公共 API ==========

window.CypressEngine = {
  // 测试注册 API(在 eval 代码中调用)
  describe: function(name, fn) {
    var suite = {
      name: name, type: 'suite', children: [],
      before: null, after: null, beforeEach: null, afterEach: null,
      parent: _currentSuite()
    };
    if (_currentSuite()) {
      _currentSuite().children.push(suite);
    } else {
      _suites.push(suite);
    }
    _suiteStack.push(suite);
    fn();
    _suiteStack.pop();
  },

  it: function(name, fn) {
    var suite = _currentSuite();
    if (!suite) { throw new Error('it() must be called inside describe()'); }
    suite.children.push({ name: name, type: 'test', fn: fn });
  },

  context: function(name, fn) {
    // context 是 describe 的别名
    window.CypressEngine.describe(name, fn);
  },

  expect: function(value) {
    var a = new Assertion(value, false);
    // 记录断言
    _assertions.push({ value: value, time: Date.now() });
    return a;
  },

  before: function(fn) {
    var s = _currentSuite();
    if (s) s.before = fn;
  },

  after: function(fn) {
    var s = _currentSuite();
    if (s) s.after = fn;
  },

  beforeEach: function(fn) {
    var s = _currentSuite();
    if (s) s.beforeEach = fn;
  },

  afterEach: function(fn) {
    var s = _currentSuite();
    if (s) s.afterEach = fn;
  },

  // 执行测试
  run: function(code) {
    _suites = [];
    _suiteStack = [];
    _logs = [];
    _assertions = [];

    var results = {
      total: 0, passed: 0, failed: 0,
      duration: 0, tests: [], suites: []
    };

    // 注册阶段:eval 代码会调用 describe/it 注册测试
    try {
      var wrappedCode = '(function(describe,it,context,expect,before,after,beforeEach,afterEach){' +
        code + '\n})';
      var fn = eval(wrappedCode);
      fn(
        window.CypressEngine.describe,
        window.CypressEngine.it,
        window.CypressEngine.context,
        window.CypressEngine.expect,
        window.CypressEngine.before,
        window.CypressEngine.after,
        window.CypressEngine.beforeEach,
        window.CypressEngine.afterEach
      );
    } catch (e) {
      _log('代码解析错误: ' + e.message, 'error');
      results.error = e.message;
      results.duration = 0;
      return results;
    }

    // 执行阶段
    var startTime = Date.now();
    _suiteStack = [];
    for (var i = 0; i < _suites.length; i++) {
      results.suites.push(_suites[i].name);
      _suiteStack.push(_suites[i]);
      _runSuite(_suites[i], results);
      _suiteStack.pop();
    }
    results.duration = Date.now() - startTime;
    results.total = results.passed + results.failed;

    return results;
  },

  // 获取日志
  getLogs: function() { return _logs.slice(); },

  // 重置
  reset: function() {
    _suites = [];
    _suiteStack = [];
    _logs = [];
    _assertions = [];
  },

  ready: true
};

eval 沙箱注入是核心设计:将用户测试代码包装在一个函数表达式中,通过参数传递 describe/it/expect 等 API,避免用户代码直接访问全局作用域。

五、渲染进程(renderer.js)

渲染进程负责编辑器交互、测试执行、结果展示和语法高亮,全部逻辑集中在 renderer.js 中。

5.1 JavaScript 语法高亮

高亮引擎采用 Tokenizer 逐行扫描方式,支持 8 种语法元素着色。完整实现如下:

js 复制代码
function highlightJavaScript(code) {
  if (!code) return '';
  var keywords = ['var','let','const','function','return','if','else','for','while','do',
    'switch','case','break','continue','new','this','typeof','instanceof',
    'try','catch','finally','throw','class','extends','import','export',
    'default','yield','async','await','delete','in','of','void','super'];
  var builtins = ['describe','it','context','expect','before','after','beforeEach','afterEach',
    'console','window','document','Math','JSON','Array','Object','Error',
    'Date','RegExp','Promise','require','module','exports','setTimeout',
    'setInterval','clearTimeout','clearInterval','parseInt','parseFloat',
    'isNaN','String','Number','Boolean','Symbol','Map','Set','WeakMap',
    'WeakSet','Proxy','Reflect','Infinity','NaN'];
  var boolNull = ['true','false','null','undefined'];
  var kwSet = {};
  keywords.forEach(function(k) { kwSet[k] = true; });
  var biSet = {};
  builtins.forEach(function(b) { biSet[b] = true; });
  var bnSet = {};
  boolNull.forEach(function(b) { bnSet[b] = true; });

  var lines = code.split('\n');
  var inBlockComment = false;

  return lines.map(function(line) {
    var tokens = [];
    var i = 0;

    // 块注释续行
    if (inBlockComment) {
      var endIdx = line.indexOf('*/');
      if (endIdx === -1) {
        return '<span class="hl-comment">' + escapeHtml(line) + '</span>';
      }
      tokens.push('<span class="hl-comment">' + escapeHtml(line.substring(0, endIdx + 2)) + '</span>');
      i = endIdx + 2;
      inBlockComment = false;
    }

    while (i < line.length) {
      // 单行注释
      if (line[i] === '/' && i + 1 < line.length && line[i + 1] === '/') {
        tokens.push('<span class="hl-comment">' + escapeHtml(line.substring(i)) + '</span>');
        i = line.length;
      }
      // 块注释开始
      else if (line[i] === '/' && i + 1 < line.length && line[i + 1] === '*') {
        var endC = line.indexOf('*/', i + 2);
        if (endC === -1) {
          tokens.push('<span class="hl-comment">' + escapeHtml(line.substring(i)) + '</span>');
          i = line.length;
          inBlockComment = true;
        } else {
          tokens.push('<span class="hl-comment">' + escapeHtml(line.substring(i, endC + 2)) + '</span>');
          i = endC + 2;
        }
      }
      // 字符串(单引号/双引号/模板字符串)
      else if (line[i] === '"' || line[i] === "'" || line[i] === '`') {
        var q = line[i];
        var end = -1;
        for (var j = i + 1; j < line.length; j++) {
          if (line[j] === '\\') { j++; continue; }
          if (line[j] === q) { end = j + 1; break; }
        }
        if (end === -1) end = line.length;
        tokens.push('<span class="hl-string">' + escapeHtml(line.substring(i, end)) + '</span>');
        i = end;
      }
      // 正则表达式
      else if (line[i] === '/' && i > 0) {
        var prev = line.substring(0, i).trim();
        var lastChar = prev.charAt(prev.length - 1);
        if (lastChar === '=' || lastChar === '(' || lastChar === ',' || lastChar === '!' ||
            lastChar === '&' || lastChar === '|' || lastChar === '?' || lastChar === ':' ||
            lastChar === '{' || lastChar === '[' || lastChar === ';' || prev === '' ||
            prev.endsWith('return') || prev.endsWith('typeof')) {
          var reEnd = -1;
          var escaped = false;
          var inCharClass = false;
          for (var k = i + 1; k < line.length; k++) {
            if (escaped) { escaped = false; continue; }
            if (line[k] === '\\') { escaped = true; continue; }
            if (line[k] === '[') { inCharClass = true; continue; }
            if (line[k] === ']') { inCharClass = false; continue; }
            if (line[k] === '/' && !inCharClass) { reEnd = k + 1; break; }
          }
          if (reEnd !== -1) {
            // 包含 flags
            while (reEnd < line.length && /[gimsuy]/.test(line[reEnd])) reEnd++;
            tokens.push('<span class="hl-regex">' + escapeHtml(line.substring(i, reEnd)) + '</span>');
            i = reEnd;
          } else {
            tokens.push(escapeHtml(line[i]));
            i++;
          }
        } else {
          tokens.push(escapeHtml(line[i]));
          i++;
        }
      }
      // 数字
      else if (/\d/.test(line[i])) {
        var nm = line.substring(i).match(/^(0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+|\d+\.?\d*(?:[eE][+-]?\d+)?)/);
        if (nm) {
          tokens.push('<span class="hl-number">' + nm[0] + '</span>');
          i += nm[0].length;
        } else {
          tokens.push(escapeHtml(line[i]));
          i++;
        }
      }
      // 标识符(关键字/内置/函数调用)
      else if (/[a-zA-Z_$]/.test(line[i])) {
        var wm = line.substring(i).match(/^[a-zA-Z_$][\w$]*/);
        if (wm) {
          var w = wm[0];
          // 检查后面是否是 ( → 函数调用
          var afterW = line.substring(i + w.length).trimLeft();
          var isCall = afterW.charAt(0) === '(';
          if (bnSet[w]) {
            tokens.push('<span class="hl-bool-null">' + escapeHtml(w) + '</span>');
          } else if (kwSet[w]) {
            tokens.push('<span class="hl-keyword">' + escapeHtml(w) + '</span>');
          } else if (biSet[w]) {
            tokens.push('<span class="hl-builtin">' + escapeHtml(w) + '</span>');
          } else if (isCall) {
            tokens.push('<span class="hl-fn-call">' + escapeHtml(w) + '</span>');
          } else {
            tokens.push(escapeHtml(w));
          }
          i += w.length;
        } else {
          tokens.push(escapeHtml(line[i]));
          i++;
        }
      }
      // 其他字符
      else {
        tokens.push(escapeHtml(line[i]));
        i++;
      }
    }
    return tokens.join('');
  }).join('\n');
}

高亮颜色采用 Catppuccin Mocha 配色方案,与整体深色主题保持一致:

语法元素 CSS 类名 颜色 示例
关键字 hl-keyword Mauve 紫 var, function, return, if
内置对象 hl-builtin Blue 蓝 describe, it, expect, Math
函数调用 hl-fn-call Teal 青 toBe(), map(), toEqual()
字符串 hl-string Green 绿 "hello", 'world'
注释 hl-comment Overlay 灰 // comment, /* block */
数字 hl-number Peach 桃 42, 3.14, 0xFF
布尔/空值 hl-bool-null Red 红 true, false, null
正则表达式 hl-regex Yellow 黄 /test/gi, /0-9+/

六、预置测试用例

6.1 基础断言测试

验证 toEqual/toBe/toBeNull/toBeTruthy/not 等核心断言方法的正确性,包含 5 个测试用例。

js 复制代码
describe("基础断言测试", function() {
  it("toEqual 深度相等", function() {
    expect([1, 2, 3]).toEqual([1, 2, 3]);
    expect({a: 1}).toEqual({a: 1});
  });

  it("toBe 严格相等", function() {
    expect(42).toBe(42);
    expect("hello").toBe("hello");
    expect(true).toBe(true);
  });

  it("空值判断", function() {
    expect(null).toBeNull();
    expect(undefined).toBeUndefined();
    expect(0).toBeDefined();
    expect("").toBeDefined();
  });

  it("真假值判断", function() {
    expect(1).toBeTruthy();
    expect("non-empty").toBeTruthy();
    expect(0).toBeFalsy();
    expect("").toBeFalsy();
    expect(null).toBeFalsy();
  });

  it("not 取反断言", function() {
    expect(1).not.toBe(2);
    expect("abc").not.toContain("xyz");
    expect([]).not.toBeNull();
  });
});

6.2 数组方法测试

验证 map/filter/reduce/sort/indexOf 等数组方法,使用 beforeEach 钩子初始化测试数据,包含 5 个测试用例。

js 复制代码
describe("数组方法测试", function() {
  var arr;

  beforeEach(function() {
    arr = [3, 1, 4, 1, 5, 9, 2, 6];
  });

  it("map 映射", function() {
    var doubled = arr.map(function(x) { return x * 2; });
    expect(doubled[0]).toBe(6);
    expect(doubled[3]).toBe(2);
    expect(doubled.length).toBe(8);
  });

  it("filter 过滤", function() {
    var evens = arr.filter(function(x) { return x % 2 === 0; });
    expect(evens).toEqual([4, 2, 6]);
    expect(evens.length).toBe(3);
  });

  it("reduce 归约", function() {
    var sum = arr.reduce(function(a, b) { return a + b; }, 0);
    expect(sum).toBe(31);
    expect(sum).toBeGreaterThan(30);
    expect(sum).toBeLessThan(32);
  });

  it("sort 排序", function() {
    var sorted = arr.slice().sort(function(a, b) { return a - b; });
    expect(sorted[0]).toBe(1);
    expect(sorted[sorted.length - 1]).toBe(9);
    expect(sorted).toContain(5);
  });

  it("indexOf 查找", function() {
    expect(arr.indexOf(4)).toBe(2);
    expect(arr.indexOf(99)).toBe(-1);
  });
});

6.3 字符串方法测试

验证 split/trim/replace/slice/toUpperCase 等字符串方法,包含 6 个测试用例,覆盖正则匹配和 not 取反。

js 复制代码
describe("字符串方法测试", function() {
  it("split 分割", function() {
    var parts = "a,b,c,d".split(",");
    expect(parts).toEqual(["a", "b", "c", "d"]);
    expect(parts.length).toBe(4);
  });

  it("trim 去空白", function() {
    expect("  hello  ".trim()).toBe("hello");
    expect("\t\n test \n".trim()).toBe("test");
  });

  it("replace 替换", function() {
    expect("foo bar".replace("foo", "baz")).toBe("baz bar");
    expect("aaa".replace(/a/g, "b")).toBe("bbb");
  });

  it("toMatch 正则匹配", function() {
    expect("hello world").toMatch(/world/);
    expect("test123").toMatch(/[0-9]+/);
    expect("abc").not.toMatch(/[0-9]/);
  });

  it("slice 截取", function() {
    expect("hello".slice(1, 3)).toBe("el");
    expect("hello".slice(-2)).toBe("lo");
  });

  it("toUpperCase/toLowerCase", function() {
    expect("hello".toUpperCase()).toBe("HELLO");
    expect("HELLO".toLowerCase()).toBe("hello");
  });
});

6.4 对象操作测试

验证属性访问/Object.keys/JSON 序列化/Object.assign 等对象方法,使用 beforeEach 初始化测试数据,包含 4 个测试用例。

js 复制代码
describe("对象操作测试", function() {
  var obj;

  beforeEach(function() {
    obj = { name: "Cypress", version: 12, features: ["runner", "assertions"] };
  });

  it("属性访问", function() {
    expect(obj.name).toBe("Cypress");
    expect(obj.version).toBe(12);
    expect(obj).toHaveProperty("name");
    expect(obj).toHaveProperty("features");
  });

  it("Object.keys", function() {
    var keys = Object.keys(obj);
    expect(keys.length).toBe(3);
    expect(keys).toContain("name");
    expect(keys).toContain("version");
  });

  it("JSON 序列化", function() {
    var json = JSON.stringify(obj);
    expect(json).toMatch(/Cypress/);
    var parsed = JSON.parse(json);
    expect(parsed.name).toBe("Cypress");
    expect(parsed.features.length).toBe(2);
  });

  it("Object.assign 合并", function() {
    var merged = Object.assign({}, obj, { version: 13, newProp: true });
    expect(merged.version).toBe(13);
    expect(merged.newProp).toBe(true);
    expect(merged.name).toBe("Cypress");
  });
});

6.5 异常与边界测试

验证 toThrow/toBeCloseTo/toBeInstanceOf 等高级断言方法,包含 5 个测试用例。

js 复制代码
describe("异常与边界测试", function() {
  it("toThrow 捕获异常", function() {
    expect(function() { throw new Error("boom"); }).toThrow();
    expect(function() { throw new Error("specific error"); }).toThrow("specific");
  });

  it("not.toThrow 不抛异常", function() {
    expect(function() { return 42; }).not.toThrow();
  });

  it("toBeCloseTo 浮点精度", function() {
    expect(0.1 + 0.2).toBeCloseTo(0.3);
    expect(3.14159).toBeCloseTo(3.14, 2);
  });

  it("toBeInstanceOf 类型检查", function() {
    expect(new Error("test")).toBeInstanceOf(Error);
    expect([1,2]).toBeInstanceOf(Array);
    expect(new Date()).toBeInstanceOf(Date);
  });

  it("数值比较边界", function() {
    expect(0).toBeGreaterThanOrEqual(0);
    expect(-1).toBeLessThan(0);
    expect(Infinity).toBeGreaterThan(999999);
  });
});

七、鸿蒙平台特殊适配

7.1 "三防"稳定性策略

鸿蒙 Electron 适配层存在已知限制,本应用的标准防护配置:

防护目标 措施 代码位置
防原生弹窗崩溃 自定义 div 通知替代 alert/confirm/prompt renderer.js notify 函数
防 select 崩溃 全程未使用原生 select 元素 index.html
防 XComponent 崩溃 frame: true + transparent: false + resizable: true main.js BrowserWindow 配置
防 GPU 白屏 app.disableHardwareAcceleration() main.js 启动时调用
防 API 不兼容 未使用 setWindowOpenHandler / will-navigate 全局

通知组件实现(替代 alert):

js 复制代码
function notify(title, msg, type) {
  var old = el('notification');
  if (old) old.remove();
  var div = document.createElement('div');
  div.id = 'notification';
  div.className = 'cyp-notification' + (type === 'error' ? ' error' : '');
  div.innerHTML = '<div class="notify-title">' + escapeHtml(title) + '</div>' +
    '<div class="notify-msg">' + escapeHtml(msg) + '</div>';
  document.body.appendChild(div);
  setTimeout(function() { if (div.parentNode) div.remove(); }, 4000);
}

八、构建与部署

8.1 同步到 web_engine 部署目录

js 复制代码
# 复制全部应用文件到部署目录
$appDir = "web_engine\src\main\resources\resfile\resources\app"
Copy-Item "electron-apps\Cypress\main.js" $appDir -Force
Copy-Item "electron-apps\Cypress\renderer.js" $appDir -Force
Copy-Item "electron-apps\Cypress\cypress-engine.js" $appDir -Force
Copy-Item "electron-apps\Cypress\index.html" $appDir -Force
Copy-Item "electron-apps\Cypress\package.json" $appDir -Force
Copy-Item "electron-apps\Cypress\styles\cypress.css" "$appDir\styles\" -Force

注意:cypress-engine.js 是测试执行引擎核心,务必确认它进入了部署目录,否则渲染进程加载会直接失败。

8.2 构建与真机运行

  1. DevEco Studio 打开工程 → Build → Build Hap(s)/APP(s) → Build Hap(s)
  2. 连接鸿蒙 PC → Run → Run(需配置 signingConfigs)

8.3 真机功能验证

验证步骤 操作 预期结果
1 打开应用,观察左侧套件列表 5 个预置套件显示,第一个套件默认选中,编辑器显示代码
2 点击工具栏"运行全部"按钮 底部测试结果面板显示 25 个测试全部通过,侧栏显示通过率
3 切换到"断言详情"标签 显示每个测试的断言数量和详情
4 点击其他套件标签,编辑代码后运行 套件切换正常,自定义代码可执行并显示结果
5 点击"新建测试"按钮 创建新套件,编辑器加载默认模板
6 编辑器中输入代码,观察语法高亮 关键字、字符串、注释、数字等 8 种语法元素正确着色

其中第 2、6 步是整个适配的核心验证点:测试引擎的 25 个断言全部通过证明引擎逻辑正确,语法高亮证明双层渲染方案工作正常。

九、常见问题与解决方案

Q1:not 取反断言失败怎么办?

问题现象:expect(1).not.toBe(2) 抛出 "Expected not 1 to be 2" 错误,断言未按取反逻辑执行。

根本原因:Assertion 原型链上缺少 not getter 属性,访问 .not 返回 undefined 而非取反的 Assertion 实例,后续 .toBe() 调用在 undefined 上执行直接报错。

错误做法:

js 复制代码
// ❌ 直接在已有实例上修改 _not,污染原始实例
Assertion.prototype.not = function() {
  this._not = true;
  return this;  // 链式调用看似合理,但原实例状态已被篡改
};

解决方案:通过 Object.defineProperty 在 Assertion.prototype 上定义 not getter,返回一个 _not=true 的新 Assertion 实例。getter 中不应产生额外的断言记录,否则会导致断言计数不一致:

js 复制代码
// ✅ 返回新的取反实例,不修改原实例(cypress-engine.js)
Object.defineProperty(Assertion.prototype, 'not', {
  get: function() {
    return new Assertion(this._value, true);
  }
});

Q2:eval 沙箱中测试代码无法访问 describe/it 怎么办?

问题现象:运行测试时报错 "describe is not defined" 或 "it is not defined"。

根本原因:用户测试代码通过 eval 执行时,处于独立作用域,默认无法访问挂载在 window.CypressEngine 上的 describe/it/expect 等方法。

错误做法:

js 复制代码
// ❌ 直接 eval 用户代码------describe/it 不在 eval 作用域内
eval(userCode);  // ReferenceError: describe is not defined

解决方案:将用户代码包装在一个函数表达式中,通过参数传递全部 8 个测试 API,调用时传入 window.CypressEngine 上对应的函数引用:

js 复制代码
// ✅ 函数表达式包装 + 参数注入(cypress-engine.js runCode)
var wrappedCode = '(function(describe,it,context,expect,before,after,beforeEach,afterEach){' +
  code + '\n})';
var fn = eval(wrappedCode);
fn(window.CypressEngine.describe, window.CypressEngine.it,
   window.CypressEngine.context, window.CypressEngine.expect,
   window.CypressEngine.before, window.CypressEngine.after,
   window.CypressEngine.beforeEach, window.CypressEngine.afterEach);

Q3:语法高亮层与编辑器滚动不同步怎么办?

问题现象:编辑器滚动时,高亮层保持不动,导致高亮文字与光标位置错位。

根本原因:highlight div 和 textarea 是两个独立的滚动容器,textarea 的 scroll 事件未同步到 highlight 层。

错误做法:

js 复制代码
// ❌ 只同步 scrollTop,忽略 scrollLeft------横向滚动时错位
function syncScroll() {
  el('codeHighlight').scrollTop = el('codeEditor').scrollTop;
}

解决方案:在 syncScroll 函数中同时同步 highlight 层的 scrollTop 和 scrollLeft。textarea 设置 z-index: 1 位于高亮层上方,高亮层设置 pointer-events: none 避免拦截鼠标事件:

js 复制代码
// ✅ 双向同步 + 行号同步(renderer.js syncScroll)
function syncScroll() {
  el('lineNumbers').scrollTop = el('codeEditor').scrollTop;
  var highlight = el('codeHighlight');
  if (highlight) {
    highlight.scrollTop = el('codeEditor').scrollTop;
    highlight.scrollLeft = el('codeEditor').scrollLeft;
  }
}

Q4:鸿蒙平台对话框崩溃怎么办?

问题现象:点击按钮后应用闪退或白屏。

根本原因:使用了原生 prompt/confirm/alert 对话框或原生 select 元素,鸿蒙 Electron 适配层的 ArkWeb 内核对这些原生 UI 组件支持不完整,调用后直接崩溃。

错误做法:

js 复制代码
// ❌ 使用原生对话框------鸿蒙平台直接崩溃
var name = prompt('请输入测试名称:');  // ArkWeb 内核不支持,闪退
if (confirm('确定删除此套件?')) { ... }  // 同样崩溃

解决方案:所有通知信息使用自定义 cyp-notification 类名的 div 元素实现,4 秒后自动消失。文件操作通过 Electron IPC 通道调用主进程的 dialog 模块,不触发渲染进程的原生对话框:

js 复制代码
// ✅ 自定义通知替代原生弹窗(renderer.js notify)
function notify(msg, type) {
  var div = document.createElement('div');
  div.className = 'cyp-notification';
  div.textContent = msg;
  document.body.appendChild(div);
  setTimeout(function() { div.remove(); }, 4000);
}
原生组件 替代方案 实现方式
alert/confirm 自定义 notification div div + CSS 动画 + 4 秒自动消失
prompt 不使用 测试代码直接在编辑器中输入
select 不使用 套件标签切换代替

十、总结

本文记录了将 Cypress 前端测试框架适配到鸿蒙 PC 平台的完整过程。核心方案是用纯 JavaScript 从零实现 BDD 测试执行引擎(describe/it/expect + 18 种断言 + 四钩子),通过 eval 沙箱注入执行用户测试代码,Tokenizer 逐行扫描实现语法高亮,透明 textarea 双层叠加渲染呈现 IDE 级编辑体验。全部代码约 2117 行,零外部依赖,无需 npm install 或 Node.js 运行时,遵循 electron-apps 开发 → web_engine 同步 → HAP 打包的标准部署流程,即可在鸿蒙 PC 上获得完整的前端测试框架体验。

相关推荐
wei_shuo3 小时前
鸿蒙平台 ES Agent REST API 测试工具适配实战:基于 Electron 壳方案的跨平台 HTTP 调试客户端开发
openharmony
wei_shuo5 小时前
鸿蒙平台 H2 SQL 工作台适配实战:基于 Electron 壳方案的 Navicat 风格数据库管理工具开发
openharmony
wei_shuo1 天前
Flutter 三方库 OpenHarmony 鸿蒙适配实战:in_app_update 应用内更新插件 ArkTS 原生适配全流程
openharmony·in_app_update
Java的搬运工3 天前
HarmonyOS ArkUI V2 实战:Schema 驱动表单、2in1 适配与实时通信
harmonyos·arkts·openharmony·harmonyos next·表单校验·arkui v2·2in1
熊猫钓鱼>_>4 天前
Flutter app_settings 鸿蒙适配实战:Intent 体系到 Want 的跨越
flutter·华为·harmonyos·openharmony·intent·want
哈__5 天前
Flutter 3.44.9 + OpenHarmony7:image_cropper三方库 图片裁剪的应用
flutter·openharmony
moyh-blog24 天前
OpenHarmony播放音乐start请求从media_service到audio_server的调用流程02
openharmony·audio
Industio_触觉智能1 个月前
在瑞芯微RK平台下的开源鸿蒙OpenHarmony分区镜像/散包固件烧录
嵌入式硬件·openharmony·烧录·开源鸿蒙·瑞芯微·鸿蒙开发板·鸿蒙嵌入式
nullregedit1 个月前
OpenHarmony 小鸿 AI 开发实战 10:CI1302 语音上行的 UART 帧、Opus 与 VAD
openharmony·语音交互·开源鸿蒙·ws63·ci1302