Skip to content

Commit d91da44

Browse files
committed
feat(cli): add TypeScript runner with AgentProxy for browser automation
Add a new ts-runner module that enables running TypeScript automation scripts directly from the CLI. The AgentProxy class provides a simplified interface for browser automation with CDP connection or browser launch support.
1 parent 2239d86 commit d91da44

File tree

18 files changed

+1212
-17
lines changed

18 files changed

+1212
-17
lines changed

packages/cli/bin/midscene

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,29 @@
11
#!/usr/bin/env node
22

3-
require('../dist/lib/index.js')
3+
const path = require('node:path');
4+
const { spawn } = require('node:child_process');
5+
6+
const args = process.argv.slice(2);
7+
const scriptArg = args.find((arg) => !arg.startsWith('-'));
8+
9+
const isTypeScriptFile =
10+
scriptArg && (scriptArg.endsWith('.ts') || scriptArg.endsWith('.mts'));
11+
12+
if (isTypeScriptFile) {
13+
const runnerPath = path.join(__dirname, '../dist/es/ts-runner/runner.mjs');
14+
const tsxPath = require.resolve('tsx/cli');
15+
16+
const child = spawn(
17+
process.execPath,
18+
[tsxPath, runnerPath, ...args],
19+
{ stdio: 'inherit', cwd: process.cwd() },
20+
);
21+
22+
child.on('exit', (code) => process.exit(code || 0));
23+
child.on('error', (error) => {
24+
console.error('Failed to start tsx:', error.message);
25+
process.exit(1);
26+
});
27+
} else {
28+
require('../dist/lib/index.js');
29+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/// <reference path="../src/ts-runner/global.d.ts" />
2+
3+
// TodoMVC automation test script (declarative style)
4+
// Usage: midscene examples/ai-todo-declarative.mts
5+
6+
import type { AgentProxy } from '../src/ts-runner/agent-proxy';
7+
8+
export const launch = {
9+
headed: true,
10+
url: 'https://todomvc.com/examples/react/dist/',
11+
};
12+
13+
export async function run(agent: AgentProxy) {
14+
console.log('Starting TodoMVC test (declarative style)...');
15+
16+
// Create a task
17+
await agent.aiAct(
18+
'Enter "Learn TypeScript" in the task box, then press Enter to create',
19+
);
20+
21+
// Query all tasks
22+
const tasks = await agent.aiQuery<string[]>('string[], tasks in the list');
23+
console.log('Tasks:', tasks);
24+
25+
// Verify task created
26+
if (!tasks.includes('Learn TypeScript')) {
27+
throw new Error('Task "Learn TypeScript" not found');
28+
}
29+
console.log('Task created successfully');
30+
31+
// Complete the task
32+
await agent.aiAct('Click the checkbox next to the first task');
33+
console.log('Task completed');
34+
35+
// Query completed tasks
36+
await agent.aiAct('Click the "Completed" status button below the task list');
37+
const completedTasks = await agent.aiQuery<string[]>(
38+
'string[], Extract all task names from the list',
39+
);
40+
console.log('Completed tasks:', completedTasks);
41+
42+
if (completedTasks.length !== 1) {
43+
throw new Error(`Expected 1 completed task, got ${completedTasks.length}`);
44+
}
45+
console.log('Completed tasks verified');
46+
47+
console.log('\nAll tests passed!');
48+
}

packages/cli/examples/ai-todo.mts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/// <reference path="../src/ts-runner/global.d.ts" />
2+
3+
// TodoMVC automation test script
4+
// Usage: midscene examples/ai-todo.mts
5+
6+
// Launch browser and open TodoMVC
7+
await agent.launch({
8+
headed: true,
9+
url: 'https://todomvc.com/examples/react/dist/',
10+
});
11+
12+
console.log('Starting TodoMVC test...');
13+
14+
// Create tasks
15+
await agent.aiAct(
16+
'Enter "Learn JS today" in the task box, then press Enter to create',
17+
);
18+
await agent.aiAct(
19+
'Enter "Learn Rust tomorrow" in the task box, then press Enter to create',
20+
);
21+
await agent.aiAct(
22+
'Enter "Learning AI the day after tomorrow" in the task box, then press Enter to create',
23+
);
24+
25+
// Query all tasks
26+
const allTaskList = await agent.aiQuery<string[]>(
27+
'string[], tasks in the list',
28+
);
29+
console.log('All tasks:', allTaskList);
30+
31+
// Verify tasks created successfully
32+
const expectedTasks = [
33+
'Learn JS today',
34+
'Learn Rust tomorrow',
35+
'Learning AI the day after tomorrow',
36+
];
37+
for (const task of expectedTasks) {
38+
if (!allTaskList.includes(task)) {
39+
throw new Error(`Task "${task}" not found`);
40+
}
41+
}
42+
console.log('All tasks created successfully');
43+
44+
// Delete the second task
45+
await agent.aiAct('Move your mouse over the second item in the task list');
46+
await agent.aiAct('Click the delete button to the right of the second task');
47+
console.log('Second task deleted');
48+
49+
// Complete the second task (now the original third one)
50+
await agent.aiAct('Click the checkbox next to the second task');
51+
console.log('Second task completed');
52+
53+
// View completed tasks
54+
await agent.aiAct('Click the "Completed" status button below the task list');
55+
56+
// Query completed tasks list
57+
const completedTasks = await agent.aiQuery<string[]>(
58+
'string[], Extract all task names from the list',
59+
);
60+
console.log('Completed tasks:', completedTasks);
61+
62+
if (completedTasks.length !== 1) {
63+
throw new Error(`Expected 1 completed task, got ${completedTasks.length}`);
64+
}
65+
if (completedTasks[0] !== 'Learning AI the day after tomorrow') {
66+
throw new Error(
67+
`Expected task name "Learning AI the day after tomorrow", got "${completedTasks[0]}"`,
68+
);
69+
}
70+
console.log('Completed tasks verified');
71+
72+
// Query input placeholder
73+
const placeholder = await agent.aiQuery<string>(
74+
'string, return the placeholder text in the input box',
75+
);
76+
console.log('Input placeholder:', placeholder);
77+
78+
if (placeholder !== 'What needs to be done?') {
79+
throw new Error(
80+
`Expected placeholder "What needs to be done?", got "${placeholder}"`,
81+
);
82+
}
83+
console.log('Placeholder verified');
84+
85+
console.log('\nAll tests passed!');

packages/cli/package.json

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,26 @@
66
"homepage": "https://midscenejs.com/",
77
"main": "./dist/lib/index.js",
88
"module": "./dist/es/index.mjs",
9+
"types": "./dist/types/index.d.ts",
910
"bin": {
1011
"midscene": "./bin/midscene"
1112
},
12-
"files": ["dist", "README.md", "bin"],
13+
"exports": {
14+
".": {
15+
"import": "./dist/es/index.mjs",
16+
"require": "./dist/lib/index.js",
17+
"types": "./dist/types/index.d.ts"
18+
},
19+
"./global": {
20+
"types": "./src/ts-runner/global.d.ts"
21+
}
22+
},
23+
"typesVersions": {
24+
"*": {
25+
"global": ["./src/ts-runner/global.d.ts"]
26+
}
27+
},
28+
"files": ["dist", "README.md", "bin", "src/ts-runner/global.d.ts"],
1329
"scripts": {
1430
"dev": "npm run build:watch",
1531
"build": "rslib build",
@@ -24,9 +40,11 @@
2440
"@midscene/ios": "workspace:*",
2541
"@midscene/shared": "workspace:*",
2642
"@midscene/web": "workspace:*",
43+
"dotenv": "^16.4.5",
2744
"http-server": "14.1.1",
2845
"lodash.merge": "4.6.2",
29-
"puppeteer": "24.6.0"
46+
"puppeteer": "24.6.0",
47+
"tsx": "^4.19.2"
3048
},
3149
"devDependencies": {
3250
"@rslib/core": "^0.18.3",
@@ -37,7 +55,6 @@
3755
"@types/yargs": "17.0.32",
3856
"chalk": "4.1.2",
3957
"cli-spinners": "3.2.0",
40-
"dotenv": "^16.4.5",
4158
"execa": "9.3.0",
4259
"glob": "11.0.0",
4360
"js-yaml": "4.1.0",

packages/cli/rslib.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export default defineConfig({
4141
source: {
4242
entry: {
4343
index: 'src/index.ts',
44+
'ts-runner/runner': 'src/ts-runner/runner.ts',
45+
'ts-runner/index': 'src/ts-runner/index.ts',
4446
},
4547
define: {
4648
__VERSION__: JSON.stringify(version),
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import type { Browser, Page } from 'puppeteer';
2+
import type { CdpConfig, LaunchConfig } from './types';
3+
4+
export class AgentProxy {
5+
private browser: Browser | null = null;
6+
private page: Page | null = null;
7+
private innerAgent: any = null;
8+
private isOwned = false;
9+
10+
async connect(config?: CdpConfig): Promise<void> {
11+
if (!config) {
12+
const endpoint = await this.discoverLocal();
13+
await this.connectToEndpoint(endpoint);
14+
return;
15+
}
16+
17+
if (typeof config === 'string') {
18+
await this.connectToEndpoint(config);
19+
return;
20+
}
21+
22+
let endpoint = config.endpoint;
23+
if (config.apiKey) {
24+
const url = new URL(endpoint);
25+
url.searchParams.set('apiKey', config.apiKey);
26+
endpoint = url.toString();
27+
}
28+
29+
await this.connectToEndpoint(endpoint);
30+
31+
if (config.tabUrl || typeof config.tabIndex === 'number') {
32+
await this.selectTab(config);
33+
}
34+
}
35+
36+
async launch(config: LaunchConfig = {}): Promise<void> {
37+
const puppeteer = await import('puppeteer');
38+
39+
this.browser = await puppeteer.default.launch({
40+
headless: !config.headed,
41+
});
42+
this.isOwned = true;
43+
44+
this.page = await this.browser.newPage();
45+
46+
if (config.viewport) {
47+
await this.page.setViewport(config.viewport);
48+
}
49+
50+
if (config.url) {
51+
await this.page.goto(config.url, { waitUntil: 'domcontentloaded' });
52+
}
53+
54+
await this.createAgent();
55+
}
56+
57+
async aiAct(prompt: string, options?: any): Promise<any> {
58+
this.ensureConnected();
59+
return this.innerAgent.aiAct(prompt, options);
60+
}
61+
62+
async aiAction(prompt: string, options?: any): Promise<any> {
63+
this.ensureConnected();
64+
return this.innerAgent.aiAction(prompt, options);
65+
}
66+
67+
async aiQuery<T = any>(prompt: string, options?: any): Promise<T> {
68+
this.ensureConnected();
69+
return this.innerAgent.aiQuery(prompt, options);
70+
}
71+
72+
async aiAssert(assertion: string, options?: any): Promise<void> {
73+
this.ensureConnected();
74+
return this.innerAgent.aiAssert(assertion, options);
75+
}
76+
77+
async aiLocate(prompt: string, options?: any): Promise<any> {
78+
this.ensureConnected();
79+
return this.innerAgent.aiLocate(prompt, options);
80+
}
81+
82+
async aiWaitFor(assertion: string, options?: any): Promise<void> {
83+
this.ensureConnected();
84+
return this.innerAgent.aiWaitFor(assertion, options);
85+
}
86+
87+
async destroy(): Promise<void> {
88+
if (this.innerAgent) {
89+
await this.innerAgent.destroy();
90+
this.innerAgent = null;
91+
}
92+
93+
if (this.browser) {
94+
if (this.isOwned) {
95+
await this.browser.close();
96+
} else {
97+
this.browser.disconnect();
98+
}
99+
this.browser = null;
100+
}
101+
102+
this.page = null;
103+
}
104+
105+
private async discoverLocal(port = 9222): Promise<string> {
106+
const response = await fetch(`http://localhost:${port}/json/version`);
107+
if (!response.ok) {
108+
throw new Error(
109+
`Cannot connect to local Chrome (port ${port}).
110+
Please start Chrome with the following command:
111+
macOS: open -a "Google Chrome" --args --remote-debugging-port=${port}
112+
Linux: google-chrome --remote-debugging-port=${port}
113+
Windows: chrome.exe --remote-debugging-port=${port}`,
114+
);
115+
}
116+
const info = (await response.json()) as { webSocketDebuggerUrl: string };
117+
return info.webSocketDebuggerUrl;
118+
}
119+
120+
private async connectToEndpoint(endpoint: string): Promise<void> {
121+
const puppeteer = await import('puppeteer');
122+
123+
this.browser = await puppeteer.default.connect({
124+
browserWSEndpoint: endpoint,
125+
});
126+
this.isOwned = false;
127+
128+
const pages = await this.browser.pages();
129+
this.page = pages[0] || (await this.browser.newPage());
130+
await this.createAgent();
131+
}
132+
133+
private async selectTab(config: {
134+
tabUrl?: string;
135+
tabIndex?: number;
136+
}): Promise<void> {
137+
if (!this.browser) return;
138+
139+
const pages = await this.browser.pages();
140+
let targetPage: Page | undefined;
141+
142+
if (config.tabUrl) {
143+
targetPage = pages.find((p) => p.url().includes(config.tabUrl!));
144+
} else if (typeof config.tabIndex === 'number') {
145+
targetPage = pages[config.tabIndex];
146+
}
147+
148+
if (targetPage) {
149+
this.page = targetPage;
150+
await this.createAgent();
151+
}
152+
}
153+
154+
private async createAgent(): Promise<void> {
155+
const { PuppeteerAgent } = await import('@midscene/web/puppeteer');
156+
this.innerAgent = new PuppeteerAgent(this.page!);
157+
}
158+
159+
private ensureConnected(): void {
160+
if (!this.innerAgent) {
161+
throw new Error(
162+
'Please call agent.connect() or agent.launch() first to connect to a browser',
163+
);
164+
}
165+
}
166+
}

0 commit comments

Comments
 (0)