@lengmoxxl/wangcai-testkit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -2
- package/harness.cjs +160 -0
- package/package.json +28 -4
package/README.md
CHANGED
|
@@ -1,3 +1,52 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lengmoxxl/wangcai-testkit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
插件测试用:替插件起一个真实的旺财窗口(独立的 home、指向被测插件构建的 `init.ts`、已经装好的 Core),
|
|
4
|
+
面板的 DOM 和插件自己的约定留在插件仓库里。
|
|
5
|
+
|
|
6
|
+
## 前提
|
|
7
|
+
|
|
8
|
+
跑测试的仓库旁边要有两个已构建的 checkout,默认从 `process.cwd()` 解析:
|
|
9
|
+
|
|
10
|
+
- `../WangCai`:App(`desktop/dist`、`desktop/node/bin/node`、`wangcaicli/dist/debug/wangcai`)
|
|
11
|
+
- `../WangCai-Core`:Core(`dist/main.cjs`)
|
|
12
|
+
|
|
13
|
+
`WANGCAI_CHECKOUT` 与 `WANGCAI_CORE_SOURCE` 换成别的 checkout,比如某个 worktree。
|
|
14
|
+
|
|
15
|
+
被测插件要先有自己的 `main.cjs`:条目只写 `id` 时,`withWindow` 到旁边的 `../WangCai-<id>` 取它,缺了构建就抛错;写了 `directory`(如示例)时直接读那个目录,有没有构建好由调用方负责。
|
|
16
|
+
|
|
17
|
+
`harness.cjs` 直接 `require('playwright-core')`,它是本包唯一的运行时依赖(peerDependency):从 registry 装会自动带上,以 `file:` 链接时用 `../WangCai` 里装好的那份,所以那个 checkout 要 `npm install` 过。
|
|
18
|
+
|
|
19
|
+
## 用法
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
const { test } = require('node:test');
|
|
23
|
+
const assert = require('node:assert/strict');
|
|
24
|
+
const { withWindow, openView } = require('@lengmoxxl/wangcai-testkit');
|
|
25
|
+
|
|
26
|
+
test('the Git tab follows the terminal', { timeout: 180000 }, () => withWindow('wangcai-git', {
|
|
27
|
+
workspaces: ['terminal-agent'],
|
|
28
|
+
tabs: ['files', { id: 'git', directory: __dirname + '/..' }],
|
|
29
|
+
}, async ({ page, errors }) => {
|
|
30
|
+
await openView(page, 'Git');
|
|
31
|
+
// …驱动面板…
|
|
32
|
+
assert.deepEqual(errors, []);
|
|
33
|
+
}));
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
body 收到 `{ home, env, page, errors }`:这一例的临时 home 与它的环境变量、窗口页面,以及页面自己报的错(`pageerror` 与 `console.error`,插件抛错就会让它非空)。
|
|
37
|
+
|
|
38
|
+
用例的窗口不抢焦点,跑完或中断都不会留下 Electron 与 agent 进程。
|
|
39
|
+
|
|
40
|
+
每条用例都起一个 Electron,所以用 `node --test --test-concurrency=1` 串行跑。
|
|
41
|
+
|
|
42
|
+
## 导出
|
|
43
|
+
|
|
44
|
+
| 成员 | 作用 |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `withWindow(prefix, lists, body)` | 一个用例:临时 home、按 `lists` 写的插件表、窗口、收尾 |
|
|
47
|
+
| `openView(page, name)` | 在右侧栏里新开一个插件视图,需要时先把侧栏显示出来 |
|
|
48
|
+
| `viewTabs(page)` | 侧栏上显示着的那些标签页,可用来计数 |
|
|
49
|
+
| `closeView(page)` | 关掉当前选中的那个侧栏标签页 |
|
|
50
|
+
| `createWorkspace(page)` | 开一个本机工作区;返回时它的终端已经画出 shell |
|
|
51
|
+
| `terminalShows(page, text)` | 等当前终端把那段文字画出来 |
|
|
52
|
+
| `run(page, line)` | 往当前终端敲一行并回车 |
|
package/harness.cjs
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const { execFileSync } = require('node:child_process');
|
|
2
|
+
const { cpSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } = require('node:fs');
|
|
3
|
+
const { tmpdir } = require('node:os');
|
|
4
|
+
const { join, resolve } = require('node:path');
|
|
5
|
+
const { _electron: electron } = require('playwright-core');
|
|
6
|
+
|
|
7
|
+
// Where the app keeps the agent it would download, relative to its own checkout.
|
|
8
|
+
const AGENT = 'wangcaicli/dist/debug/wangcai';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The built app checkout, which runs in place and loads a plugin's files from where the case points init.ts.
|
|
12
|
+
* WANGCAI_CHECKOUT names another, for a worktree of the app.
|
|
13
|
+
*/
|
|
14
|
+
const wangcaiApp = () => {
|
|
15
|
+
const root = resolve(process.cwd(), process.env.WANGCAI_CHECKOUT ?? '../WangCai');
|
|
16
|
+
const built = ['desktop/dist/main/index.js', 'desktop/node/bin/node', AGENT];
|
|
17
|
+
if (!built.every((name) => existsSync(join(root, name)))) throw new Error(`build the WangCai checkout at ${root}`);
|
|
18
|
+
return root;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** A case gets its own home and none of the variables a dev run exports. */
|
|
22
|
+
const testEnv = (home) => {
|
|
23
|
+
const env = { ...process.env, HOME: home, WANGCAI_HOME: '', SHELL: '/bin/bash', ELECTRON_RENDERER_URL: '' };
|
|
24
|
+
delete env.ELECTRON_RUN_AS_NODE;
|
|
25
|
+
// The agent Core would download is this checkout's, so no case needs a network for one.
|
|
26
|
+
env.WANGCAI_AGENT_SOURCE = join(wangcaiApp(), AGENT);
|
|
27
|
+
return env;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Puts a Core where the app would have installed one, under `plugins` beside the clone it expects: a home without
|
|
32
|
+
* one opens the window that installs a Core first, which is a clone and a build no case wants to wait for.
|
|
33
|
+
* WANGCAI_CORE_SOURCE names another build.
|
|
34
|
+
*/
|
|
35
|
+
const installCore = (home) => {
|
|
36
|
+
const storage = join(home, '.local/share/wangcai');
|
|
37
|
+
const core = join(storage, 'plugins/core');
|
|
38
|
+
const source = resolve(process.cwd(), process.env.WANGCAI_CORE_SOURCE ?? '../WangCai-Core');
|
|
39
|
+
if (!existsSync(join(source, 'dist/main.cjs'))) throw new Error(`build the Core checkout at ${source}`);
|
|
40
|
+
mkdirSync(core, { recursive: true });
|
|
41
|
+
cpSync(join(source, 'dist'), join(core, 'dist'), { recursive: true });
|
|
42
|
+
// What a Core reads of its own sources: the snapshot an agent is set up from.
|
|
43
|
+
for (const entry of ['main', 'ui']) cpSync(join(source, entry), join(core, entry), { recursive: true });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The homes a case has launched and not yet cleaned up. */
|
|
47
|
+
const running = new Set();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The app the tests drive: the checkout's own Electron, on a home that already holds a Core. The window never
|
|
51
|
+
* comes to the front, so a run does not take the focus of whoever works at the machine.
|
|
52
|
+
*/
|
|
53
|
+
const launchApp = async (home, env) => {
|
|
54
|
+
const app = wangcaiApp();
|
|
55
|
+
installCore(home);
|
|
56
|
+
const desktop = await electron.launch({
|
|
57
|
+
executablePath: require(join(app, 'node_modules/electron')),
|
|
58
|
+
args: [join(app, 'desktop'), '--unfocused', `--user-data-dir=${join(home, 'electron')}`],
|
|
59
|
+
cwd: app,
|
|
60
|
+
env,
|
|
61
|
+
});
|
|
62
|
+
// Steps here take seconds; a shorter default keeps a stuck run from holding the window on screen.
|
|
63
|
+
desktop.context().setDefaultTimeout(15000);
|
|
64
|
+
running.add(home);
|
|
65
|
+
return desktop;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The app loads only what init.ts lists, so a case names the plugins and where to read them from: a sibling checkout
|
|
70
|
+
* for an id alone, or whatever `directory` says. `repo` is left as written, for what installs from git.
|
|
71
|
+
*/
|
|
72
|
+
const writeInit = (home, lists) => {
|
|
73
|
+
const { workspaces = [], tabs = [] } = lists ?? {};
|
|
74
|
+
const entry = (item) => {
|
|
75
|
+
const spec = typeof item === 'string' ? { id: item } : { ...item };
|
|
76
|
+
if (!spec.directory && !spec.repo) {
|
|
77
|
+
spec.directory = resolve(process.cwd(), `../WangCai-${spec.id}`);
|
|
78
|
+
if (!existsSync(join(spec.directory, 'main.cjs'))) throw new Error(`build the WangCai-${spec.id} checkout at ${spec.directory}`);
|
|
79
|
+
}
|
|
80
|
+
return JSON.stringify(spec);
|
|
81
|
+
};
|
|
82
|
+
const list = (entries) => entries.map(entry).join(', ');
|
|
83
|
+
mkdirSync(join(home, '.config/wangcai'), { recursive: true });
|
|
84
|
+
writeFileSync(join(home, '.config/wangcai/init.ts'), `export default { workspaces: [${list(workspaces)}], tabs: [${list(tabs)}] };\n`);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Tears a home down: the agent it started, the Electron on it, and the files themselves. */
|
|
88
|
+
const cleanup = (home) => {
|
|
89
|
+
// The agent outlives the window that asked for it, so the home it ran on is what stops it here.
|
|
90
|
+
const agent = join(wangcaiApp(), AGENT);
|
|
91
|
+
try { execFileSync(agent, ['server', 'stop'], { env: testEnv(home), stdio: 'ignore', timeout: 15000 }); } catch {}
|
|
92
|
+
try { execFileSync('pkill', ['-f', join(home, 'electron')]); } catch {}
|
|
93
|
+
rmSync(home, { recursive: true, force: true });
|
|
94
|
+
running.delete(home);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// A run that is interrupted never reaches its own teardown, so the windows it left are stopped here instead of being
|
|
98
|
+
// left for the next run to meet.
|
|
99
|
+
process.once('exit', () => { for (const home of running) cleanup(home); });
|
|
100
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
101
|
+
process.once(signal, () => { for (const home of running) cleanup(home); process.exit(signal === 'SIGINT' ? 130 : 143); });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The terminal of the workspace in front. */
|
|
105
|
+
const activeTerminal = (page) => page.locator('.terminal-pane.active');
|
|
106
|
+
|
|
107
|
+
/** Types a line into the pane in front and runs it, the way a person does. */
|
|
108
|
+
const run = async (page, line) => {
|
|
109
|
+
await activeTerminal(page).locator('.xterm-helper-textarea').click();
|
|
110
|
+
await page.keyboard.type(line);
|
|
111
|
+
await page.keyboard.press('Enter');
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Waits until the terminal in front draws that text, which it does once the shell has written it. */
|
|
115
|
+
const terminalShows = (page, text) => activeTerminal(page).locator('.xterm-screen').filter({ hasText: text }).waitFor();
|
|
116
|
+
|
|
117
|
+
/** Opens a local workspace, and waits for the shell its pane draws a moment after the workspace appears. */
|
|
118
|
+
const createWorkspace = async (page) => {
|
|
119
|
+
await page.locator('.workspace-header').click({ button: 'right' });
|
|
120
|
+
await page.locator('#workspace-row-menu').getByRole('menuitem', { name: '新建 本机', exact: true }).click();
|
|
121
|
+
await terminalShows(page, /\S/);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** Opens a view in the sidebar, showing the sidebar first when it is off screen. */
|
|
125
|
+
const openView = async (page, name) => {
|
|
126
|
+
const newTab = page.getByRole('button', { name: '新建侧栏标签页' });
|
|
127
|
+
if (!(await newTab.isVisible())) await page.getByRole('button', { name: '切换右侧栏' }).click();
|
|
128
|
+
await newTab.click();
|
|
129
|
+
await page.locator('#view-menu').getByRole('button', { name, exact: true }).click();
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** The sidebar's tabs as the window draws them. */
|
|
133
|
+
const viewTabs = (page) => page.locator('.sidebar-tab:visible');
|
|
134
|
+
|
|
135
|
+
/** Closes the sidebar tab that is selected. */
|
|
136
|
+
const closeView = async (page) => page.locator('.sidebar-tab:has([aria-selected=true]) .close-tab').click();
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One case: a home of its own, the plugins it lists, the window on them, and the teardown that follows whatever the
|
|
140
|
+
* body did.
|
|
141
|
+
*/
|
|
142
|
+
const withWindow = async (prefix, lists, body) => {
|
|
143
|
+
const home = realpathSync(mkdtempSync(join(tmpdir(), `${prefix}-`)));
|
|
144
|
+
const env = testEnv(home);
|
|
145
|
+
writeInit(home, lists);
|
|
146
|
+
let desktop;
|
|
147
|
+
try {
|
|
148
|
+
desktop = await launchApp(home, env);
|
|
149
|
+
const page = await desktop.firstWindow();
|
|
150
|
+
const errors = [];
|
|
151
|
+
page.on('pageerror', (error) => errors.push(error.message));
|
|
152
|
+
page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); });
|
|
153
|
+
return await body({ home, env, page, errors });
|
|
154
|
+
} finally {
|
|
155
|
+
await desktop?.close();
|
|
156
|
+
cleanup(home);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
module.exports = { withWindow, openView, viewTabs, closeView, createWorkspace, terminalShows, run };
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lengmoxxl/wangcai-testkit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Launch the WangCai app around a plugin under test: a home of its own, init.ts, a prepared Core, and Playwright",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/lengmoXXL/WangCai.git",
|
|
9
|
+
"directory": "testkit"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"wangcai",
|
|
13
|
+
"plugin",
|
|
14
|
+
"e2e",
|
|
15
|
+
"playwright"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22"
|
|
19
|
+
},
|
|
20
|
+
"main": "harness.cjs",
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"playwright-core": "^1.63.0"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"harness.cjs"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
}
|
|
30
|
+
}
|