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 构建与真机运行
- DevEco Studio 打开工程 → Build → Build Hap(s)/APP(s) → Build Hap(s)
- 连接鸿蒙 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 上获得完整的前端测试框架体验。