Skip to content

Commit 7edaeed

Browse files
authored
feat(computer): add Electron demo for testing Obsidian on headless Linux CI (#86)
Add a new computer/electron-demo that uses @midscene/computer to test Obsidian (Electron app) in a Xvfb headless Linux environment. Includes a workflow that downloads the AppImage, extracts it, and runs the demo.
1 parent d993d15 commit 7edaeed

File tree

5 files changed

+299
-0
lines changed

5 files changed

+299
-0
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
name: Computer Electron Demo
2+
3+
permissions:
4+
contents: read
5+
6+
on:
7+
workflow_dispatch:
8+
inputs:
9+
tag:
10+
description: 'The tag name of @midscene/computer'
11+
required: true
12+
default: 'beta'
13+
branch:
14+
description: 'The branch to checkout'
15+
required: false
16+
default: 'main'
17+
18+
env:
19+
TAG: ${{ github.event.inputs.tag || 'beta' }}
20+
21+
jobs:
22+
electron-demo:
23+
runs-on: ubuntu-22.04
24+
env:
25+
OPENAI_BASE_URL: ${{ secrets.QWEN_OPENAI_BASE_URL }}
26+
OPENAI_API_KEY: ${{ secrets.QWEN_OPENAI_API_KEY }}
27+
MIDSCENE_MODEL_NAME: 'qwen-vl-max-latest'
28+
MIDSCENE_USE_QWEN_VL: 1
29+
MIDSCENE_COMPUTER_HEADLESS_LINUX: 'true'
30+
DEBUG: 'midscene:ai:profile:*'
31+
32+
steps:
33+
- name: Checkout repository
34+
uses: actions/checkout@v4
35+
with:
36+
ref: ${{ github.event.inputs.branch || 'main' }}
37+
38+
- name: Install system dependencies
39+
run: |
40+
sudo apt-get update
41+
sudo apt-get install -y \
42+
xvfb \
43+
x11-xserver-utils \
44+
imagemagick \
45+
libxtst6 \
46+
libxinerama1 \
47+
libx11-6 \
48+
libxkbcommon-x11-0 \
49+
libpng16-16 \
50+
libnss3 \
51+
libatk-bridge2.0-0 \
52+
libdrm2 \
53+
libgbm1 \
54+
libasound2 \
55+
libgtk-3-0 \
56+
libnotify4 \
57+
libsecret-1-0 \
58+
libxss1 \
59+
xdg-utils
60+
61+
- name: Download Obsidian AppImage
62+
working-directory: computer/electron-demo
63+
run: |
64+
OBSIDIAN_VERSION="1.8.9"
65+
OBSIDIAN_URL="https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/Obsidian-${OBSIDIAN_VERSION}.AppImage"
66+
echo "Downloading Obsidian ${OBSIDIAN_VERSION}..."
67+
wget -q "${OBSIDIAN_URL}" -O "Obsidian-${OBSIDIAN_VERSION}.AppImage"
68+
chmod +x "Obsidian-${OBSIDIAN_VERSION}.AppImage"
69+
70+
- name: Install dependencies and run demo
71+
id: run-demo
72+
working-directory: computer/electron-demo
73+
run: |
74+
npm i pnpm -g
75+
pnpm i @midscene/computer@${{ env.TAG }} --save-dev
76+
pnpm install
77+
pnpm run test
78+
continue-on-error: true
79+
80+
- name: Upload report
81+
if: always()
82+
uses: actions/upload-artifact@v4
83+
with:
84+
if-no-files-found: ignore
85+
name: Electron Demo Report
86+
path: computer/electron-demo/midscene_run/report
87+
88+
- name: Check if demo failed
89+
if: steps.run-demo.outcome == 'failure'
90+
run: exit 1

computer/electron-demo/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules
2+
.env
3+
midscene_run
4+
obsidian-vault/
5+
*.AppImage
6+
squashfs-root/

computer/electron-demo/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Electron Demo — Testing Obsidian with @midscene/computer
2+
3+
This demo shows how to use `@midscene/computer` to test an Electron application (Obsidian) in a headless Linux CI environment using Xvfb.
4+
5+
## How it works
6+
7+
1. Finds or extracts the Obsidian AppImage (`--appimage-extract` to avoid FUSE)
8+
2. Pre-seeds the vault config to skip the vault picker dialog
9+
3. Launches Obsidian with Electron-friendly flags (`--no-sandbox`, etc.)
10+
4. Uses `agentFromComputer()` to interact with the running app via screenshots
11+
12+
## Local usage
13+
14+
```bash
15+
# 1. Download Obsidian AppImage into this directory
16+
# 2. Create .env with your AI model credentials:
17+
# OPENAI_API_KEY=sk-xxx
18+
# MIDSCENE_MODEL_NAME=qwen3-vl-plus
19+
# 3. Run
20+
npm install
21+
npm run test
22+
```
23+
24+
## CI usage
25+
26+
The workflow `.github/workflows/computer-electron-demo.yaml` runs this demo on `ubuntu-22.04` with Xvfb. Trigger it manually via `workflow_dispatch`.

computer/electron-demo/demo.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { execSync, spawn } from 'node:child_process';
2+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
3+
import { homedir } from 'node:os';
4+
import { join, resolve } from 'node:path';
5+
import { agentFromComputer } from '@midscene/computer';
6+
import 'dotenv/config';
7+
8+
const sleep = (ms: number) =>
9+
new Promise((resolve) => setTimeout(resolve, ms));
10+
11+
const DEMO_DIR = resolve(import.meta.dirname);
12+
const VAULT_DIR = join(DEMO_DIR, 'obsidian-vault');
13+
14+
/**
15+
* Locate the Obsidian binary. If only an AppImage is found, extract it first.
16+
* Returns the path to the executable.
17+
*/
18+
function findOrPrepareApp(): string {
19+
// Already extracted
20+
const extractedBin = join(DEMO_DIR, 'squashfs-root', 'obsidian');
21+
if (existsSync(extractedBin)) {
22+
console.log('Using previously extracted Obsidian binary');
23+
return extractedBin;
24+
}
25+
26+
// Find AppImage in the demo directory
27+
const appImages = readdirSync(DEMO_DIR).filter((f) =>
28+
f.endsWith('.AppImage'),
29+
);
30+
if (appImages.length === 0) {
31+
throw new Error(
32+
'No Obsidian AppImage found. Download it to the electron-demo directory first.',
33+
);
34+
}
35+
36+
const appImagePath = join(DEMO_DIR, appImages[0]);
37+
console.log(`Extracting ${appImages[0]} ...`);
38+
execSync(`chmod +x "${appImagePath}"`);
39+
execSync(`"${appImagePath}" --appimage-extract`, { cwd: DEMO_DIR });
40+
console.log('Extraction complete');
41+
42+
if (!existsSync(extractedBin)) {
43+
throw new Error('Extraction succeeded but obsidian binary not found');
44+
}
45+
return extractedBin;
46+
}
47+
48+
/**
49+
* Pre-seed Obsidian config so it opens our vault directly (skipping the vault picker).
50+
*/
51+
function preseedVault(vaultDir: string): void {
52+
mkdirSync(vaultDir, { recursive: true });
53+
54+
const configDir = join(homedir(), '.config', 'obsidian');
55+
mkdirSync(configDir, { recursive: true });
56+
57+
const obsidianJson = join(configDir, 'obsidian.json');
58+
const vaultId = 'ci-test-vault';
59+
const config = {
60+
vaults: {
61+
[vaultId]: {
62+
path: vaultDir,
63+
ts: Date.now(),
64+
open: true,
65+
},
66+
},
67+
};
68+
writeFileSync(obsidianJson, JSON.stringify(config, null, 2));
69+
console.log(`Vault config written to ${obsidianJson}`);
70+
}
71+
72+
/**
73+
* Launch Obsidian as a detached child process with Electron-friendly flags.
74+
*/
75+
function launchApp(
76+
binaryPath: string,
77+
vaultDir: string,
78+
): ReturnType<typeof spawn> {
79+
const args = [
80+
`--vault=${vaultDir}`,
81+
'--no-sandbox',
82+
'--disable-gpu',
83+
'--disable-dev-shm-usage',
84+
];
85+
console.log(`Launching: ${binaryPath} ${args.join(' ')}`);
86+
87+
const child = spawn(binaryPath, args, {
88+
detached: true,
89+
stdio: 'ignore',
90+
env: { ...process.env, ELECTRON_DISABLE_SECURITY_WARNINGS: 'true' },
91+
});
92+
child.unref();
93+
return child;
94+
}
95+
96+
(async () => {
97+
// --- Prepare & launch Obsidian ---
98+
const binaryPath = findOrPrepareApp();
99+
preseedVault(VAULT_DIR);
100+
101+
const child = launchApp(binaryPath, VAULT_DIR);
102+
console.log(`Obsidian launched (pid: ${child.pid})`);
103+
104+
// Give Obsidian time to start up
105+
await sleep(8000);
106+
107+
// --- Connect Midscene agent ---
108+
const agent = await agentFromComputer({
109+
aiActionContext:
110+
'You are interacting with Obsidian, a note-taking desktop application. ' +
111+
'If any dialog or popup appears, dismiss it by clicking the close button or pressing Escape.',
112+
});
113+
114+
try {
115+
// Wait for the main UI to appear
116+
await agent.aiWaitFor(
117+
'Obsidian main window is visible with the editor area or vault view',
118+
{ timeoutMs: 30000 },
119+
);
120+
console.log('Obsidian UI is ready');
121+
122+
// Dismiss any welcome dialogs / popups
123+
await agent.aiAct(
124+
'If there is any popup, modal dialog, or "Trust author" prompt, close or dismiss it',
125+
);
126+
await sleep(1000);
127+
128+
// Create a new note
129+
await agent.aiAct('press Ctrl+N to create a new note');
130+
await sleep(1500);
131+
132+
// Type note content
133+
await agent.aiAct('type "Hello from Midscene CI" in the editor');
134+
await sleep(1000);
135+
136+
// Verify the note content
137+
const result = await agent.aiQuery(
138+
'{ content: string } — read the text content currently visible in the editor area',
139+
);
140+
console.log('Editor content:', JSON.stringify(result));
141+
142+
if (
143+
typeof result?.content === 'string' &&
144+
result.content.includes('Midscene')
145+
) {
146+
console.log('Content verification passed!');
147+
} else {
148+
console.warn('Content verification: text may not match expected value');
149+
}
150+
151+
console.log('Electron demo completed successfully!');
152+
} finally {
153+
// Cleanup: kill the Obsidian process
154+
if (child.pid) {
155+
try {
156+
process.kill(-child.pid, 'SIGTERM');
157+
} catch {
158+
// Process may have already exited
159+
}
160+
}
161+
}
162+
})();
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "computer-electron-demo",
3+
"private": true,
4+
"version": "1.0.0",
5+
"description": "Demo for testing Electron apps (e.g. Obsidian) with @midscene/computer",
6+
"type": "module",
7+
"scripts": {
8+
"test": "tsx demo.ts"
9+
},
10+
"devDependencies": {
11+
"@midscene/computer": "latest",
12+
"dotenv": "^16.4.5",
13+
"tsx": "4.20.1"
14+
}
15+
}

0 commit comments

Comments
 (0)