feat: add static-server into cli (#153)

This commit is contained in:
yuyutaotao 2024-11-12 11:54:40 +08:00 committed by GitHub
parent 3117584e57
commit 324a337bac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 250 additions and 146 deletions

View File

@ -1,6 +1,6 @@
# Command Line Tools
`@midscene/cli` is the command line version of Midscene. It is suitable for executing very simple tasks or experiencing the basics of Midscene.
`@midscene/cli` is the command line version of Midscene. It is suitable for executing very simple tasks. For example, you can write a one-line npm script to check if the build result can launch normally, or extract some data from the web page and write the result into a JSON file.
:::info Demo Project
you can check the demo project of command line tools here: [https://github.com/web-infra-dev/midscene-example/blob/main/command-line](https://github.com/web-infra-dev/midscene-example/blob/main/command-line)
@ -21,13 +21,7 @@ export OPENAI_API_KEY="sk-abcdefghijklmnopqrstuvwxyz"
## Examples
Use headed mode (i.e. visible browser) to visit bing.com and search for 'weather today'
```bash
npx @midscene/cli --headed --url https://wwww.bing.com --action "type 'weather today', hit enter" --sleep 3000
```
visit github status page and save the status to ./status.json
Visit github status page and save the status to `./status.json`
```bash
npx @midscene/cli --url https://www.githubstatus.com/ \
@ -35,6 +29,18 @@ npx @midscene/cli --url https://www.githubstatus.com/ \
--query '{name: string, status: string}[], service status of github page'
```
Serve the `./dist` path statically and check if the `index.html` can launch normally
```bash
npx @midscene/cli --serve ./dist --url index.html --assert 'page title is "My App"'
```
Use headed mode (i.e. visible browser) to visit bing.com and search for 'weather today'
```bash
npx @midscene/cli --headed --url https://wwww.bing.com --action "type 'weather today', hit enter" --sleep 3000
```
Or you may install @midscene/cli globally before calling
```bash
@ -77,3 +83,4 @@ Actions (the order matters, can be used multiple times):
1. Always put options before any action param.
2. The order of action parameters matters. For example, `--action "some action" --query "some data"` means that the action is taken first, followed by a query.
3. If you have some more complex requirements, such as loop operations, using the SDK version (instead of this cli) is an easier way to achieve them.
4. Midscene CLI reads the `.env` file by dotenv in the current working directory, allowing you to place some configuration in it.

View File

@ -1,6 +1,6 @@
# 命令行工具
`@midscene/cli` 是 Midscene 的命令行版本。它适合执行简单的任务或体验 Midscene 的基础功能
`@midscene/cli` 是 Midscene 的命令行版本。它非常适合执行简单的任务。比如你可以用它校验编译后的产物是否能正常启动,或者是从某些页面中提取信息并写入一个 JSON 文件
:::info 样例项目
你可以在这里看到使用命令行工具的样例项目:[https://github.com/web-infra-dev/midscene-example/blob/main/command-line](https://github.com/web-infra-dev/midscene-example/blob/main/command-line)
@ -25,16 +25,26 @@ export OPENAI_API_KEY="sk-abcdefghijklmnopqrstuvwxyz"
## 示例
```bash
# headed 模式(即可见浏览器)访问 baidu.com 并搜索“天气”
npx @midscene/cli --headed --url https://www.baidu.com --action "输入 '天气', 敲回车" --sleep 3000
访问 Github 状态页面并将状态保存到 `./status.json`
# 访问 Github 状态页面并将状态保存到 ./status.json
```bash
npx @midscene/cli --url https://www.githubstatus.com/ \
--query-output status.json \
--query '{serviceName: string, status: string}[], github 页面的服务状态,返回服务名称'
```
`./dist` 目录启动静态服务,并检查 `index.html` 能否正常启动
```bash
npx @midscene/cli --serve ./dist --url index.html --assert '页面标题是 "My App"'
```
用 headed 模式(即可见浏览器)访问 baidu.com 并搜索“天气”
```bash
npx @midscene/cli --headed --url https://www.baidu.com --action "输入 '天气', 敲回车" --sleep 3000
```
你也可以先全局安装 @midscene/cli 再调用
```bash
@ -76,3 +86,4 @@ Actions (参数顺序很重要,可以支持多次使用):
1. Options 参数(任务信息)应始终放在 Actions 参数之前。
2. Actions 参数的顺序很重要。例如,`--action "某操作" --query "某数据"` 表示先执行操作,然后再查询。
3. 如果有更复杂的需求,比如循环操作,使用 SDK 版本(而不是这个命令行工具)会更合适。
4. Midscene Cli 会用 dotenv 读取当前路径下的 `.env` 配置文件,你可以将环境配置放在其中

View File

@ -9,7 +9,8 @@
"e2e": "nx run @midscene/web:e2e --verbose",
"e2e:cache": "nx run @midscene/web:e2e:cache --verbose",
"e2e:report": "nx run @midscene/web:e2e:report --verbose",
"test:ai:all": "npm run e2e && npm run e2e:cache && npm run e2e:report && npm run test:ai",
"e2e:visualizer": "nx run @midscene/visualizer:e2e --verbose",
"test:ai:all": "npm run e2e && npm run e2e:cache && npm run e2e:report && npm run test:ai && npm run e2e:visualizer",
"prepare": "pnpm run build && simple-git-hooks",
"check-dependency-version": "check-dependency-version-consistency .",
"lint": "npx biome check . --diagnostic-level=warn --no-errors-on-unmatched --fix",

View File

@ -2,8 +2,4 @@
require('../dist/lib/help.js');
if (process.argv.indexOf('playground') !== -1) {
require('../dist/lib/playground.js');
} else {
require('../dist/lib/index.js');
}
require('../dist/lib/index.js');

View File

@ -8,9 +8,7 @@ export default defineConfig({
input: {
index: 'src/index.ts',
help: 'src/help.ts',
playground: 'src/playground.ts',
},
// input: ['src/utils.ts', 'src/index.ts', 'src/image/index.ts'],
externals: ['node:buffer'],
target: 'es6',
},

View File

@ -22,12 +22,16 @@
},
"dependencies": {
"@midscene/web": "workspace:*",
"dotenv": "16.4.5",
"http-server": "14.1.1",
"minimist": "1.2.5",
"ora-classic": "5.4.2",
"puppeteer": "23.0.2",
"yargs": "17.7.2"
},
"devDependencies": {
"@modern-js/module-tools": "2.60.6",
"@types/minimist": "1.2.5",
"@types/node": "^18.0.0",
"@types/yargs": "17.0.32",
"execa": "9.3.0",

View File

@ -1,12 +1,29 @@
export type ArgumentValueType = string | boolean | number;
import type minimist from 'minimist';
export interface Argument {
export type ArgumentValueType = string | boolean | number;
export function findOnlyItemInArgs(
args: minimist.ParsedArgs,
name: string,
): string | boolean | number | undefined {
const found = args[name];
if (found === undefined) {
return false;
}
if (Array.isArray(found) && found.length > 1) {
throw new Error(`Multiple values found for ${name}`);
}
return found;
}
export interface OrderedArgumentItem {
name: string;
value: ArgumentValueType;
}
export function parse(args: string[]): Argument[] {
const orderedArgs: Argument[] = [];
export function orderMattersParse(args: string[]): OrderedArgumentItem[] {
const orderedArgs: OrderedArgumentItem[] = [];
args.forEach((arg, index) => {
if (arg.startsWith('--')) {
const key = arg.substring(2);
@ -24,19 +41,3 @@ export function parse(args: string[]): Argument[] {
return orderedArgs;
}
export function findOnlyItemInArgs(
args: Argument[],
name: string,
): ArgumentValueType {
const found = args.filter((arg) => arg.name === name);
if (found.length === 0) {
return false;
}
if (found.length > 1) {
throw new Error(`Multiple values found for ${name}`);
}
return found[0].value;
}

View File

@ -8,7 +8,8 @@ if (process.argv.indexOf('--help') !== -1) {
Usage: midscene [options] [actions]
Options:
--url <url> The URL to visit, required
--serve <root-directory> Serve the local path as a static server, optional
--url <url> The URL to visit, required. If --serve is provided, provide the path to the file to visit
--user-agent <ua> The user agent to use, optional
--viewport-width <width> The width of the viewport, optional
--viewport-height <height> The height of the viewport, optional
@ -34,6 +35,9 @@ if (process.argv.indexOf('--help') !== -1) {
--query-output status.json \\
--query '{name: string, status: string}[], service status of github page'
# serve the current directory and visit the index.html file
midscene --serve . --url "index.html" --assert "the content title is 'My App'"
Examples with Chinese Prompts
# headed 访 baidu.com
midscene --headed --url "https://www.baidu.com" --action "在搜索框输入 '天气', 敲回车" --wait-for
@ -42,10 +46,6 @@ if (process.argv.indexOf('--help') !== -1) {
midscene --url "https://www.githubstatus.com/" \\
--query-output status.json \\
--query '{serviceName: string, status: string}[], github 页面的服务状态,返回服务名称'
To launch a playground server, run the following command:
midscene playground
`);
process.exit(0);
} else if (process.argv.indexOf('--version') !== -1) {

6
packages/cli/src/http-server.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare module 'http-server' {
export function createServer(options: http.ServerOptions): {
server: http.Server;
listen: (port: number, host: string, callback: () => void) => void;
};
}

View File

@ -1,28 +1,38 @@
import assert from 'node:assert';
import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { PuppeteerAgent } from '@midscene/web/puppeteer';
import { createServer } from 'http-server';
import minimist from 'minimist';
import ora from 'ora-classic';
import puppeteer from 'puppeteer';
import { type ArgumentValueType, findOnlyItemInArgs, parse } from './args';
import { findOnlyItemInArgs, orderMattersParse } from './args';
import 'dotenv/config';
let spinner: ora.Ora | undefined;
const stepString = (name: string, param?: any) => {
let paramStr;
if (typeof param === 'object') {
paramStr = JSON.stringify(param, null, 2);
} else if (name === 'sleep') {
paramStr = `${param}ms`;
} else {
paramStr = param;
const stepString = (name: string, param?: any, line2?: string) => {
const paramToString = (data: any) => {
if (name === 'sleep') {
return `${data}ms`;
}
if (typeof data === 'object') {
return JSON.stringify(data, null, 2);
}
return String(data);
};
let paramStr = paramToString(param);
if (line2) {
paramStr = `${paramStr}\n ${line2}`;
}
return `${name}\n ${paramStr ? `${paramStr}` : ''}`;
};
const printStep = (name: string, param?: any) => {
const printStep = (name: string, param?: any, line2?: string) => {
if (spinner) {
spinner.stop();
}
console.log(`- ${stepString(name, param)}`);
console.log(`- ${stepString(name, param, line2)}`);
};
const updateSpin = (text: string) => {
@ -35,8 +45,23 @@ const updateSpin = (text: string) => {
}
};
const launchServer = async (
dir: string,
): Promise<ReturnType<typeof createServer>> => {
// https://github.com/http-party/http-server/blob/master/bin/http-server
return new Promise((resolve, reject) => {
const server = createServer({
root: dir,
});
server.listen(0, '127.0.0.1', () => {
resolve(server);
});
});
};
const preferenceArgs = {
url: 'url',
serve: 'serve',
headed: 'headed',
viewportWidth: 'viewport-width',
viewportHeight: 'viewport-height',
@ -61,8 +86,7 @@ const defaultUA =
const welcome = '\nWelcome to @midscene/cli\n';
console.log(welcome);
const args = parse(process.argv);
const args = minimist(process.argv);
if (findOnlyItemInArgs(args, 'version')) {
const versionFromPkgJson = require('../package.json').version;
console.log(`@midscene/cli version ${versionFromPkgJson}`);
@ -70,45 +94,84 @@ if (findOnlyItemInArgs(args, 'version')) {
}
// check each arg is either in the preferenceArgs or actionArgs
args.forEach((arg) => {
Object.keys(args).forEach((arg) => {
if (arg === '_') return;
assert(
Object.values(preferenceArgs).includes(arg.name) ||
Object.values(actionArgs).includes(arg.name),
`Unknown argument: ${arg.name}`,
Object.values(preferenceArgs).includes(arg) ||
Object.values(actionArgs).includes(arg),
`Unknown argument: ${arg}`,
);
});
// prepare the viewport config
const preferHeaded = findOnlyItemInArgs(args, preferenceArgs.headed);
const userExpectWidth = findOnlyItemInArgs(args, preferenceArgs.viewportWidth);
const userExpectHeight = findOnlyItemInArgs(
args,
preferenceArgs.viewportHeight,
);
const userExpectDpr = findOnlyItemInArgs(args, preferenceArgs.viewportScale);
const defaultDpr = process.platform === 'darwin' ? 2 : 1;
const viewportConfig = {
width: typeof userExpectWidth === 'number' ? userExpectWidth : 1280,
height: typeof userExpectHeight === 'number' ? userExpectHeight : 1280,
deviceScaleFactor:
typeof userExpectDpr === 'number' ? userExpectDpr : defaultDpr,
};
const url = findOnlyItemInArgs(args, preferenceArgs.url);
assert(url, 'URL is required');
assert(typeof url === 'string', 'URL must be a string');
const preferredUA = findOnlyItemInArgs(args, preferenceArgs.useragent);
const ua = typeof preferredUA === 'string' ? preferredUA : defaultUA;
printStep(preferenceArgs.url, url);
printStep(preferenceArgs.useragent, ua);
printStep('viewport', JSON.stringify(viewportConfig));
if (preferHeaded) {
printStep(preferenceArgs.headed, 'true');
}
Promise.resolve(
(async () => {
// prepare the static server
const staticServerConfig = findOnlyItemInArgs(args, 'serve');
let staticServerUrl: string | undefined;
if (staticServerConfig) {
const serverDir =
typeof staticServerConfig === 'string'
? staticServerConfig
: process.cwd();
const finalServerDir = resolve(process.cwd(), serverDir);
const staticServerResult = await launchServer(finalServerDir);
const server = staticServerResult.server;
const serverAddress = server.address();
staticServerUrl = `http://${serverAddress?.address}:${serverAddress?.port}`;
printStep('static server', finalServerDir, staticServerUrl);
}
// prepare the viewport config
const preferHeaded = findOnlyItemInArgs(args, preferenceArgs.headed);
const userExpectWidth = findOnlyItemInArgs(
args,
preferenceArgs.viewportWidth,
);
const userExpectHeight = findOnlyItemInArgs(
args,
preferenceArgs.viewportHeight,
);
const userExpectDpr = findOnlyItemInArgs(
args,
preferenceArgs.viewportScale,
);
const defaultDpr = process.platform === 'darwin' ? 2 : 1;
const viewportConfig = {
width: typeof userExpectWidth === 'number' ? userExpectWidth : 1280,
height: typeof userExpectHeight === 'number' ? userExpectHeight : 1280,
deviceScaleFactor:
typeof userExpectDpr === 'number' ? userExpectDpr : defaultDpr,
};
const url = findOnlyItemInArgs(args, preferenceArgs.url) as
| string
| undefined;
let urlToVisit: string | undefined;
if (staticServerUrl) {
if (typeof url !== 'undefined') {
if (url.startsWith('/')) {
urlToVisit = `${staticServerUrl}${url}`;
} else {
urlToVisit = `${staticServerUrl}/${url}`;
}
}
} else {
urlToVisit = url;
}
assert(urlToVisit, 'URL is required');
assert(typeof urlToVisit === 'string', 'URL must be a string');
const preferredUA = findOnlyItemInArgs(args, preferenceArgs.useragent);
const ua = typeof preferredUA === 'string' ? preferredUA : defaultUA;
printStep(preferenceArgs.url, urlToVisit);
printStep(preferenceArgs.useragent, ua);
printStep('viewport', JSON.stringify(viewportConfig));
if (preferHeaded) {
printStep(preferenceArgs.headed, 'true');
}
// launch the browser
updateSpin(stepString('launch', 'puppeteer'));
const browser = await puppeteer.launch({
headless: !preferHeaded,
@ -120,24 +183,27 @@ Promise.resolve(
let errorWhenRunning: Error | undefined;
let argName: string;
let argValue: ArgumentValueType;
let argValue: string | boolean | number | undefined;
let agent: PuppeteerAgent | undefined;
try {
updateSpin(stepString('launch', url));
await page.goto(url);
updateSpin(stepString('waitForNetworkIdle', url));
updateSpin(stepString('launch', urlToVisit));
await page.goto(urlToVisit);
updateSpin(stepString('waitForNetworkIdle', urlToVisit));
await page.waitForNetworkIdle();
printStep('launched', url);
printStep('launched', urlToVisit);
agent = new PuppeteerAgent(page, {
autoPrintReportMsg: false,
});
const orderedArgs = orderMattersParse(process.argv);
let index = 0;
let outputPath: string | undefined;
let actionStarted = false;
while (index <= args.length - 1) {
const arg = args[index];
while (index <= orderedArgs.length - 1) {
const arg = orderedArgs[index];
argName = arg.name;
argValue = arg.value;
updateSpin(stepString(argName, String(argValue)));

View File

@ -1,10 +0,0 @@
// import { PlaygroundServer } from '@midscene/web/playground';
// const server = new PlaygroundServer();
// Promise.resolve()
// .then(() => server.launch())
// .then(() => {
// console.log(
// `Midscene playground server is running on http://localhost:${server.port}`,
// );
// });

View File

@ -1,34 +1,16 @@
import { findOnlyItemInArgs, parse } from '@/args';
import { findOnlyItemInArgs, orderMattersParse } from '@/args';
import { describe, expect, test } from 'vitest';
describe('args', () => {
test('should parse arguments', async () => {
const input = [
'--url',
'https://example.com',
'--width',
'500',
'--action',
'click',
'--assert',
'this is an assertion',
'--query-output',
'output.json',
'--query',
'title',
'--query',
'content',
'--prefer-cache',
];
const result = parse(input);
expect(result).toMatchSnapshot();
expect(findOnlyItemInArgs(result, 'url')).toBe('https://example.com');
expect(findOnlyItemInArgs(result, 'prefer-cache')).toBe(true);
expect(
findOnlyItemInArgs({ url: 'https://example.com', _: [] }, 'url'),
).toBe('https://example.com');
expect(() => {
findOnlyItemInArgs(result, 'query');
findOnlyItemInArgs(
{ url: 'https://example.com', _: [], query: [1, 2] },
'query',
);
}).toThrowError('Multiple values found for query');
});
@ -39,9 +21,13 @@ describe('args', () => {
'--url',
'https://example.com',
'--action',
'--sleep',
'20',
'--sleep',
'10',
];
const result = parse(input);
const result = orderMattersParse(input);
expect(result).toEqual([
{
@ -52,6 +38,14 @@ describe('args', () => {
name: 'action',
value: true,
},
{
name: 'sleep',
value: 20,
},
{
name: 'sleep',
value: 10,
},
]);
});
});

View File

@ -36,4 +36,17 @@ describe.skipIf(process.platform !== 'darwin')('bin', () => {
expect(existsSync(randomFileName)).toBeTruthy();
unlinkSync(randomFileName);
});
test('serve', async () => {
const params = [
'--serve',
'./tests/server_root',
'--url',
'index.html',
'--assert',
'the content title is "My App"',
];
const { failed } = await execa(cliBin, params);
expect(failed).toBe(false);
});
});

View File

@ -0,0 +1,2 @@
<h1>My App</h1>
<p>This is a test page</p>

View File

@ -2,3 +2,7 @@ unpacked-extension/lib/
unpacked-extension/scripts/
unpacked-extension/pages/
# Midscene.js dump files
midscene_run/report
midscene_run/dump

View File

@ -20,7 +20,8 @@
"build:watch": "modern build -w",
"serve": "http-server ./dist/ -p 3000",
"new": "modern new",
"upgrade": "modern upgrade"
"upgrade": "modern upgrade",
"e2e": "./scripts/check-html.sh"
},
"devDependencies": {
"@ant-design/icons": "5.3.7",

View File

@ -0,0 +1,6 @@
#! /bin/bash
node ../cli/bin/midscene --serve ./dist/report --url demo.html \
--action "Click the 'Insight / Locate' on Left" \
--sleep 300 \
--assert "There is a 'Open in Playground' button on the page"

22
pnpm-lock.yaml generated
View File

@ -84,6 +84,15 @@ importers:
'@midscene/web':
specifier: workspace:*
version: link:../web-integration
dotenv:
specifier: 16.4.5
version: 16.4.5
http-server:
specifier: 14.1.1
version: 14.1.1
minimist:
specifier: 1.2.5
version: 1.2.5
ora-classic:
specifier: 5.4.2
version: 5.4.2
@ -97,6 +106,9 @@ importers:
'@modern-js/module-tools':
specifier: 2.60.6
version: 2.60.6(typescript@5.0.4)
'@types/minimist':
specifier: 1.2.5
version: 1.2.5
'@types/node':
specifier: ^18.0.0
version: 18.19.62
@ -3498,9 +3510,6 @@ packages:
'@types/node@22.8.5':
resolution: {integrity: sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==}
'@types/node@22.9.0':
resolution: {integrity: sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==}
'@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
@ -13992,7 +14001,7 @@ snapshots:
'@types/conventional-commits-parser@5.0.0':
dependencies:
'@types/node': 22.9.0
'@types/node': 18.19.62
optional: true
'@types/cors@2.8.12': {}
@ -14142,11 +14151,6 @@ snapshots:
dependencies:
undici-types: 6.19.8
'@types/node@22.9.0':
dependencies:
undici-types: 6.19.8
optional: true
'@types/normalize-package-data@2.4.4': {}
'@types/parse-json@4.0.2': {}