refactor(chrome-devtool): extract the chrome-devtool logic into an application, support local development debugging, and add contribution guidelines. (#476)

* chore: add chrome devtools app

* chore: resolve import error

* chore: support visualizer css

* add build logic

* chore: add build extension zip file script

* chore: migrate part of chrome extension content to app

* chore: delete unless file

* chore: optimize chrome devtool build script

* chore: optimize chrome devtool build script

* fix: resolve bridge mode test issues

* chore: optimize chrome devtool build script

* chore: optimize chrome devtool build script

* chore: optimize chrome devtool build script

* chore: update chrome devtools build process

* chore: optimize chrome devtool build script

* chore: optimize chrome devtool build script

* chore: optimize chrome devtool build script

* chore: optimize chrome devtool build script
This commit is contained in:
Zhou Xiao 2025-03-19 15:22:17 +08:00 committed by GitHub
parent 8e1ba565d0
commit 47cb015c90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 1828 additions and 509 deletions

View File

@ -61,4 +61,5 @@ jobs:
with:
if-no-files-found: error
name: chrome_extension
path: ${{ github.workspace }}/packages/visualizer/dist/extension
path: ${{ github.workspace }}/apps/chrome-extension/extension_output

2
.gitignore vendored
View File

@ -107,3 +107,5 @@ __ai_responses__/
midscene_run
midscene_run/report
midscene_run/dump
extension_output

View File

@ -228,3 +228,67 @@ Here are the steps to publish (we generally use CI for releases and avoid publis
1. [Run the release action](https://github.com/web-infra-dev/midscene/actions/workflows/release.yml).
2. [Generate the release notes](https://github.com/web-infra-dev/midscene/releases).
## Chrome DevTools Extension
### Directory Structure
```
midscene/
├── apps/
│ ├── chrome-extension/ # Chrome extension application
│ │ ├── dist/ # Build output directory
│ │ ├── extension/ # Packaged Chrome extension directory
│ │ ├── scripts/ # Build and utility scripts
│ │ ├── src/ # Source code
│ │ │ ├── extension/ # Chrome extension-specific code
│ │ │ └── ...
│ │ ├── static/ # Static resources
│ │ └── ...
│ └── ...
├── packages/
│ ├── core/ # Core functionality
│ ├── visualizer/ # Visualization components
│ ├── web-integration/ # Web integration
│ └── ...
└── ...
```
### Developing the Chrome DevTools Extension
The Chrome DevTools extension uses the Rsbuild build system. Development workflow is as follows:
1. **Build base packages**:
```sh
# First build the base packages
pnpm run build
```
2. **Development mode**:
```sh
# Navigate to chrome-extension directory
cd apps/chrome-extension
# Start the development server
pnpm run dev
```
3. **Build the extension**:
```sh
# Build the Chrome extension
cd apps/chrome-extension
pnpm run build
```
4. **Install the extension**:
The built `dist` directory can be directly installed as a Chrome extension. In Chrome browser:
- Open `chrome://extensions/`
- Enable "Developer mode" in the top-right corner
- Click "Load unpacked" in the top-left corner
- Select the `apps/chrome-extension/dist` directory
Alternatively, you can use the packaged extension:
- Select the `apps/chrome-extension/extension_output/midscene-extension-v{version}.zip` file
For more detailed information, please refer to [Chrome DevTools README](./apps/chrome-extension/README.md).

View File

@ -0,0 +1,150 @@
# Midscene Chrome DevTools
Chrome extension version of the Midscene tool, providing browser automation, Bridge mode, and a Playground testing environment.
## Development Guide
### Environment Setup
Make sure you have completed the basic environment setup according to the main project's [Contribution Guide](../../CONTRIBUTING.md).
### Directory Structure
```
chrome-extension/
├── dist/ # Build output directory, can be directly installed as a Chrome extension
├── extension/ # Packaged Chrome extension
│ └── midscene-extension-v{version}.zip # Compressed extension
├── scripts/ # Build and utility scripts
│ ├── build-report-template.js # Generate report template
│ └── pack-extension.js # Package Chrome extension
├── src/ # Source code
│ ├── extension/ # Chrome extension-related components
│ │ ├── bridge.tsx # Bridge mode UI
│ │ ├── popup.tsx # Extension popup homepage
│ │ ├── misc.tsx # Auxiliary components
│ │ ├── utils.ts # Utility functions
│ │ ├── common.less # Common style variables
│ │ ├── popup.less # Popup styles
│ │ └── bridge.less # Bridge mode styles
│ ├── blank_polyfill.ts # Browser polyfill for Node.js modules
│ ├── index.tsx # Main entry
│ └── App.tsx # Main application component
├── static/ # Static resources directory, will be copied to the dist directory
│ └── scripts/ # Script resources
│ └── report-template.js # Generated report template
├── package.json # Project configuration
├── rsbuild.config.ts # Rsbuild build configuration
└── ...
```
### Development Process
1. **Install Dependencies**
```bash
pnpm install
```
2. **Build Dependency Packages**
```bash
# Build all packages in the project root
pnpm run build
```
3. **Development Mode**
```bash
# Start the project in development mode
cd apps/chrome-extension
pnpm run dev
```
4. **Build Project**
```bash
# Build the Chrome extension
cd apps/chrome-extension
pnpm run build
```
The build process includes:
- Building the web application using rsbuild
- Generating the report template script (report-template.js)
- Packaging the build artifacts as a Chrome extension
### Installing the Extension
#### Method 1: Using the dist directory (for development and debugging)
The built `dist` directory can be directly installed as a Chrome extension:
1. Open Chrome browser, navigate to `chrome://extensions/`
2. Enable "Developer mode" in the top-right corner
3. Click "Load unpacked" in the top-left corner
4. Select the `apps/chrome-extension/dist` directory
This method is suitable for quick testing during development.
#### Method 2: Using the packaged extension file
For publishing or sharing:
1. Use the `pnpm run build` command to build the project
2. Find the `midscene-extension-v{version}.zip` file in the `extension` directory
3. Upload this file to the Chrome Web Store developer console, or share it with others for installation
### Debugging Tips
#### Debugging the Extension Background
1. Find the Midscene extension on the Chrome extensions page (`chrome://extensions/`)
2. Click the "view: background page" link to open the developer tools
3. Use the console and network panels for debugging
#### Debugging the Popup Window
1. Click the Midscene icon in the Chrome toolbar to open the extension popup
2. Right-click on the popup and select "Inspect"
3. Use the developer tools to debug UI and interactions
#### Debugging Content Scripts
1. Open any webpage, click the Midscene icon to activate the extension
2. Open the developer tools
3. Find the Midscene scripts in the "Content scripts" section under the "Sources" panel
### Feature Description
#### Report Template
The Chrome extension uses the HTML report template from the `@midscene/visualizer` package. During the build process, it:
- Reads `packages/visualizer/dist/report/index.html`
- Converts its content to a JavaScript string
- Creates a JS file containing the `get_midscene_report_tpl()` function
- Saves it to `static/scripts/report-template.js`
#### Bridge Mode
Bridge mode allows controlling the browser from a local terminal via the Midscene SDK. This is useful for operating the browser through both scripts and manual interaction, or for reusing cookies.
## Release Process
1. Update the version number in `package.json` to match the main project
2. Run the build: `pnpm run build`
3. Verify the `midscene-extension-v{version}.zip` file generated in the `extension` directory
4. Submit the ZIP file to the Chrome Web Store
## Troubleshooting
### Common Issues
1. **Report template generation failure**
- Make sure to build the `@midscene/visualizer` package first
- Check if `packages/visualizer/dist/report/index.html` exists
2. **React Hooks errors**
- Check for multiple React instances, might need to adjust the externals configuration in `rsbuild.config.ts`
3. **async_hooks module not found**
- Check the alias configuration in `rsbuild.config.ts` to ensure it points correctly to the polyfill file
4. **Extension doesn't work properly after installation**
- Check for error messages in the Chrome console
- Verify that the build process was executed completely
- Validate the permissions configuration in the manifest.json file

View File

@ -0,0 +1,33 @@
{
"name": "chrome-extension",
"private": true,
"version": "0.12.4",
"type": "module",
"scripts": {
"build": "rsbuild build && npm run build:report-template && npm run pack-extension",
"build:report-template": "node scripts/build-report-template.js",
"dev": "rsbuild dev --open",
"preview": "rsbuild preview",
"pack-extension": "node scripts/pack-extension.js"
},
"dependencies": {
"@ant-design/icons": "^5.3.1",
"@midscene/visualizer": "workspace:*",
"@midscene/web": "workspace:*",
"antd": "5.21.6",
"dayjs": "^1.11.11",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@rsbuild/core": "^1.2.16",
"@rsbuild/plugin-less": "^1.1.1",
"@rsbuild/plugin-node-polyfill": "1.3.0",
"@rsbuild/plugin-react": "^1.1.1",
"@types/chrome": "0.0.279",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"less": "^4.2.0",
"typescript": "^5.8.2"
}
}

View File

@ -0,0 +1,82 @@
import path from 'node:path';
import { defineConfig } from '@rsbuild/core';
import { pluginLess } from '@rsbuild/plugin-less';
import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill';
import { pluginReact } from '@rsbuild/plugin-react';
import { version } from '../../packages/visualizer/package.json';
export default defineConfig({
environments: {
web: {
source: {
entry: {
index: './src/index.tsx',
popup: './src/extension/popup.tsx',
},
},
output: {
target: 'web',
sourceMap: true,
},
html: {
tags: [
{
tag: 'script',
attrs: { src: 'scripts/report-template.js' },
head: true,
append: true,
},
],
},
},
node: {
source: {
entry: {
worker: './src/scripts/worker.ts',
'stop-water-flow': './src/scripts/stop-water-flow.ts',
'water-flow': './src/scripts/water-flow.ts',
},
},
output: {
target: 'node',
sourceMap: true,
filename: {
js: 'scripts/[name].js',
},
},
},
},
dev: {
writeToDisk: true,
},
output: {
polyfill: 'entry',
copy: [
{ from: './static', to: './' },
{
from: path.resolve(
__dirname,
'../../packages/web-integration/iife-script',
),
to: 'scripts',
},
],
},
source: {
define: {
__SDK_VERSION__: JSON.stringify(version),
},
},
resolve: {
alias: {
async_hooks: path.join(__dirname, './src/scripts/blank_polyfill.ts'),
'node:async_hooks': path.join(
__dirname,
'./src/scripts/blank_polyfill.ts',
),
react: path.resolve(__dirname, 'node_modules/react'),
'react-dom': path.resolve(__dirname, 'node_modules/react-dom'),
},
},
plugins: [pluginReact(), pluginNodePolyfill(), pluginLess()],
});

View File

@ -0,0 +1,53 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Get the directory path of the current file
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Project root directory
const projectRoot = path.resolve(__dirname, '../../..');
// Path configuration
const visualizerReportPath = path.join(
projectRoot,
'packages/visualizer/dist/report/index.html',
);
const outputDir = path.join(__dirname, '../dist/scripts');
const outputFile = path.join(outputDir, 'report-template.js');
// Ensure the output directory exists
console.log(`Creating output directory: ${outputDir}`);
fs.mkdirSync(outputDir, {
recursive: true,
});
// Check if the visualizer has been built
if (!fs.existsSync(visualizerReportPath)) {
console.error(
`ERROR: Report template file not found at ${visualizerReportPath}`,
);
console.error(
'Make sure to build the visualizer package first with: npm run build -w @midscene/visualizer',
);
process.exit(1);
}
// Read the report template HTML
console.log(`Reading report template from: ${visualizerReportPath}`);
const reportHtml = fs.readFileSync(visualizerReportPath, 'utf8');
// Create JavaScript function
const jsContent = `
// Generated report template from visualizer
window.get_midscene_report_tpl = function() {
return ${JSON.stringify(reportHtml)};
};
`;
// Write to file
fs.writeFileSync(outputFile, jsContent);
console.log(`Report template successfully written to: ${outputFile}`);

View File

@ -0,0 +1,81 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import {
fileURLToPath
} from 'node:url';
import archiver from 'archiver';
// Get the directory path of the current file
const __filename = fileURLToPath(
import.meta.url);
const __dirname = path.dirname(__filename);
// Read package.json
const packageJsonPath = path.resolve(__dirname, '../package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
// Validate version string to prevent injection
const version = packageJson.version;
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?$/.test(version)) {
console.error('Invalid version format in package.json');
process.exit(1);
}
// Create extension directory
const extensionDir = path.resolve(__dirname, '../extension_output');
if (!fs.existsSync(extensionDir)) {
fs.mkdirSync(extensionDir, {
recursive: true,
});
}
// Source directory - dist
const distDir = path.resolve(__dirname, '../dist');
// Create zip file
const zipFileName = `midscene-extension-v${version}.zip`;
const zipFilePath = path.resolve(extensionDir, zipFileName);
// Delete existing zip file
if (fs.existsSync(zipFilePath)) {
fs.unlinkSync(zipFilePath);
}
// Create a file to stream archive data to
const output = fs.createWriteStream(zipFilePath);
const archive = archiver('zip', {
zlib: {
level: 9
} // Sets the compression level
});
// Listen for all archive data to be written
output.on('close', () => {
console.log(`Extension packed successfully: ${zipFileName} (${archive.pointer()} total bytes saved in extension directory)`);
});
// Handle warnings and errors
archive.on('warning', (err) => {
if (err.code === 'ENOENT') {
console.warn('Warning during archiving:', err);
} else {
console.error('Error during archiving:', err);
process.exit(1);
}
});
archive.on('error', (err) => {
console.error('Error during archiving:', err);
process.exit(1);
});
// Pipe archive data to the file
archive.pipe(output);
// Append files from dist directory, putting files at the root of archive
archive.directory(distDir, false);
// Finalize the archive (i.e. we are done appending files but streams have to finish yet)
archive.finalize();

View File

@ -0,0 +1,397 @@
/* src/extension/popup.less */
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans",
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
font-size: 14px;
}
.popup-wrapper {
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 20px 0;
background: #fff;
display: flex;
flex-direction: column;
}
.popup-wrapper .tabs-container {
flex-grow: 1;
}
.popup-wrapper .ant-tabs-nav {
padding: 0 20px;
box-sizing: border-box;
}
.popup-wrapper .popup-header {
padding: 0 20px;
}
.popup-wrapper .hr {
border-top: 1px solid #e5e5e5;
margin-bottom: 15px;
width: 100%;
padding: 0 20px;
box-sizing: border-box;
}
.popup-wrapper .popup-playground-container,
.popup-wrapper .popup-bridge-container {
flex-grow: 1;
}
.popup-wrapper .popup-bridge-container {
padding: 0 20px;
box-sizing: border-box;
}
.popup-wrapper .popup-footer {
color: #ccc;
text-align: center;
width: 100%;
}
/* src/component/logo.less */
.logo img {
height: 30px;
line-height: 30px;
vertical-align: baseline;
vertical-align: -webkit-baseline-middle;
}
.logo-with-star-wrapper {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.logo-with-star-wrapper .github-star {
height: 22px;
}
/* src/component/blackboard.less */
.blackboard .footer {
color: #aaa;
}
.blackboard ul {
padding-left: 0px;
}
.blackboard li {
list-style: none;
}
.blackboard .bottom-tip {
height: 30px;
}
.blackboard .bottom-tip-item {
max-width: 500px;
color: #aaa;
text-overflow: ellipsis;
word-wrap: break-word;
}
.blackboard-filter {
margin: 10px 0;
}
.blackboard-main-content canvas {
width: 100%;
border: 1px solid #888;
box-sizing: border-box;
}
/* src/component/player.less */
.player-container {
width: fit-content;
max-width: 100%;
max-height: 100%;
padding: 12px 0;
padding-bottom: 0;
background: #434443dd;
box-sizing: border-box;
border: 1px solid #979797;
border-radius: 6px;
line-height: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
.player-container .canvas-container {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 0 12px;
}
.player-container .canvas-container canvas {
max-width: 100%;
max-height: 100%;
box-sizing: border-box;
display: block;
margin: 0 auto;
}
.player-container .player-timeline {
width: 100%;
height: 4px;
background: #666;
position: relative;
margin-top: -2px;
}
.player-container .player-timeline .player-timeline-progress {
transition-timing-function: linear;
position: absolute;
top: 0;
left: 0;
height: 4px;
background: #06b1ab;
}
.player-container .player-tools {
margin-top: 15px;
margin-bottom: 15px;
max-width: 100%;
overflow: hidden;
color: #fff;
font-size: 16px;
box-sizing: border-box;
display: flex;
flex-direction: row;
padding: 0 12px;
justify-content: space-between;
height: 40px;
flex-shrink: 0;
}
.player-container .player-tools .player-control {
flex-grow: 1;
display: flex;
flex-direction: row;
align-items: left;
}
.player-container .player-tools .status-icon {
transition: 0.2s;
width: 40px;
height: 40px;
margin-right: 12px;
background: #666;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.player-container .player-tools .status-text {
flex-grow: 1;
overflow: hidden;
position: relative;
}
.player-container .player-tools .title {
font-weight: bold;
height: 20px;
line-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.player-container .player-tools .subtitle {
height: 20px;
line-height: 20px;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
position: absolute;
top: 18px;
left: 0;
}
.player-container .player-tools .player-tools-item {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.player-container .player-tools .player-tools-item .ant-btn-variant-link {
color: #fff;
}
/* src/component/playground-component.less */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans",
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
font-size: 14px;
}
.playground-container {
width: 100%;
height: 100%;
}
.playground-container.vertical-mode {
height: inherit;
}
.playground-container.vertical-mode .form-part {
margin-bottom: 15px;
}
.playground-container.vertical-mode .form-part h3 {
font-size: 14px;
line-height: 1.6;
}
.playground-container .playground-header {
padding: 10px 10px 30px;
}
.playground-container .playground-left-panel {
width: 100%;
height: 100%;
background-color: #fff;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow-y: auto !important;
}
.playground-container .playground-left-panel .ant-form {
height: 100%;
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.playground-container .form-part {
margin-bottom: 20px;
padding: 0 20px;
}
.playground-container .form-part h3 {
margin-top: 0;
margin-bottom: 12px;
font-size: 18px;
}
.playground-container .form-part .switch-btn-wrapper {
margin-left: 2px;
}
.playground-container .input-wrapper {
box-sizing: border-box;
}
.playground-container .input-wrapper .main-side-console-input {
position: relative;
margin-top: 4px;
}
.playground-container .input-wrapper .ant-input {
padding-bottom: 40px;
}
.playground-container .input-wrapper .form-controller-wrapper {
position: absolute;
bottom: 8px;
padding: 0 12px;
left: 0px;
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
box-sizing: border-box;
align-items: flex-end;
gap: 8px;
}
.playground-container .input-wrapper .settings-wrapper {
display: flex;
flex-direction: row;
gap: 2px;
color: #777;
flex-wrap: wrap;
}
.playground-container .input-wrapper .settings-wrapper.settings-wrapper-hover {
color: #3b3b3b;
}
.playground-container .input-wrapper .history-selector {
margin-right: 8px;
}
.playground-container .loading-container {
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
padding: 20px 20px;
}
.playground-container .loading-container .loading-progress-text {
text-align: center;
width: 100%;
color: #777;
margin-top: 16px;
}
.playground-container .loading-container .loading-progress-text-tab-info {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.playground-container .loading-container .loading-progress-text-progress {
height: 60px;
}
.playground-container .result-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
box-sizing: border-box;
padding: 20px 20px;
background-color: #f8f8f8;
border-radius: 4px;
}
.playground-container .result-wrapper.result-wrapper-compact {
padding: 0;
}
.playground-container .result-wrapper.vertical-mode-result {
height: inherit;
min-height: 300px;
}
.playground-container .result-wrapper .result-empty-tip {
text-align: center;
width: 100%;
color: #ccc;
}
.playground-container .result-wrapper pre {
display: block;
word-wrap: break-word;
white-space: pre-wrap;
}
/* src/component/open-in-playground.less */
.playground-drawer .ant-drawer-body {
padding: 0;
}
/* src/extension/bridge.less */
.bridge-status-bar {
height: 56px;
line-height: 56px;
display: flex;
flex-direction: row;
justify-content: space-between;
box-sizing: border-box;
padding: 0 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
.bridge-status-bar .bridge-status-text {
flex-grow: 1;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
display: flex;
flex-direction: row;
align-items: center;
}
.bridge-status-bar .bridge-status-text .bridge-status-tip {
margin-left: 6px;
flex-grow: 1;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.bridge-status-bar .bridge-status-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.bridge-log-container {
flex-grow: 1;
}
.bridge-log-container .bridge-log-item-content {
word-break: break-all;
white-space: pre-wrap;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
}

View File

@ -0,0 +1,7 @@
import React from 'react';
import './App.css';
import { PlaygroundPopup } from './extension/popup';
export default function App() {
return <PlaygroundPopup />;
}

1
apps/chrome-extension/src/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="@rsbuild/core/types" />

View File

@ -1,4 +1,4 @@
@import '../component/common.less';
@import './common.less';
.bridge-status-bar {
height: 56px;
@ -47,4 +47,4 @@
width: 100%;
overflow: hidden;
}
}
}

View File

@ -1,10 +1,10 @@
import { LoadingOutlined } from '@ant-design/icons';
import { ExtensionBridgePageBrowserSide } from '@midscene/web/bridge-mode-browser';
import { Button, Spin } from 'antd';
import dayjs from 'dayjs';
import { useEffect, useRef, useState } from 'react';
import './bridge.less';
import { iconForStatus } from '@/component/misc';
import dayjs from 'dayjs';
import { iconForStatus } from './misc';
interface BridgeLogItem {
time: string;
@ -250,7 +250,9 @@ export default function Bridge() {
clear
</Button>
</h3>
<div className="bridge-log-container">{logs}</div>
<div className="bridge-log-container">
{logs.length === 0 ? <p>No logs yet</p> : logs}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,26 @@
@main-text: #3b3b3b;
@primary-color: #06b1ab;
@main-orange: #F9483E;
@side-bg: #F8F8F8;
@title-bg: @side-bg;
@border-color: #E5E5E5;
@heavy-border-color: #888;
@selected-bg: #bfc4da80;
@hover-bg: #dcdcdc80;
@weak-bg: #F3F3F3;
@weak-text: #777;
@footer-text: #CCC;
@toolbar-btn-bg: #E9E9E9;
@layout-space: 20px;
@side-horizontal-padding: 10px;
@side-vertical-spacing: 10px;
@layout-extension-space-horizontal: 20px;
@layout-extension-space-vertical: 20px;

View File

@ -0,0 +1,49 @@
import {
ArrowRightOutlined,
CheckOutlined,
ClockCircleOutlined,
CloseOutlined,
LogoutOutlined,
MinusOutlined,
WarningOutlined,
} from '@ant-design/icons';
import React from 'react';
export const iconForStatus = (status: string): JSX.Element => {
switch (status) {
case 'finished':
case 'passed':
case 'success':
case 'connected':
return (
<span style={{ color: '#2B8243' }}>
<CheckOutlined />
</span>
);
case 'finishedWithWarning':
return (
<span style={{ color: '#f7bb05' }}>
<WarningOutlined />
</span>
);
case 'failed':
case 'closed':
case 'timedOut':
case 'interrupted':
return (
<span style={{ color: '#FF0A0A' }}>
<CloseOutlined />
</span>
);
case 'pending':
return <ClockCircleOutlined />;
case 'cancelled':
case 'skipped':
return <LogoutOutlined />;
case 'running':
return <ArrowRightOutlined />;
default:
return <MinusOutlined />;
}
};

View File

@ -1,4 +1,4 @@
@import '../component/common.less';
@import './common.less';
body {
margin: 0;
@ -53,4 +53,4 @@ body {
text-align: center;
width: 100%;
}
}
}

View File

@ -1,27 +1,22 @@
/// <reference types="chrome" />
import { ConfigProvider, Tabs } from 'antd';
import ReactDOM from 'react-dom/client';
import { setSideEffect } from '../init';
import './popup.less';
import { globalThemeConfig } from '@/component/color';
import Logo from '@/component/logo';
import { ApiOutlined, SendOutlined } from '@ant-design/icons';
import {
Logo,
Playground,
extensionAgentForTab,
} from '@/component/playground-component';
import { useEnvConfig } from '@/component/store';
import { ApiOutlined, SendOutlined } from '@ant-design/icons';
getExtensionVersion,
globalThemeConfig,
useEnvConfig,
} from '@midscene/visualizer/extension';
import { ConfigProvider, Tabs } from 'antd';
import Bridge from './bridge';
import { getExtensionVersion } from './utils';
import './popup.less';
setSideEffect();
declare const __SDK_VERSION__: string;
declare const __VERSION__: string;
function PlaygroundPopup() {
export function PlaygroundPopup() {
const extensionVersion = getExtensionVersion();
const { popupTab, setPopupTab, forceSameTabNavigation } = useEnvConfig();
const { popupTab, setPopupTab } = useEnvConfig();
const items = [
{
@ -76,56 +71,11 @@ function PlaygroundPopup() {
<div className="popup-footer">
<p>
Midscene.js Chrome Extension v{extensionVersion} (SDK v{__VERSION__}
)
Midscene.js Chrome Extension v{extensionVersion} (SDK v
{__SDK_VERSION__})
</p>
</div>
</div>
</ConfigProvider>
);
}
const element = document.getElementById('root');
if (element) {
const root = ReactDOM.createRoot(element);
root.render(<PlaygroundPopup />);
}
// const shotAndOpenPlayground = async (
// agent?: ChromeExtensionProxyPageAgent | null,
// ) => {
// if (!agent) {
// message.error('No agent found');
// return;
// }
// const context = await agent.getUIContext();
// // cache screenshot when page is active
// const { id } = await sendToWorker<
// WorkerRequestSaveContext,
// WorkerResponseSaveContext
// >(workerMessageTypes.SAVE_CONTEXT, {
// context,
// });
// const url = getPlaygroundUrl(id);
// chrome.tabs.create({
// url,
// active: true,
// });
// };
// const handleSendToPlayground = async () => {
// if (!tabId || !windowId) {
// message.error('No active tab or window found');
// return;
// }
// setLoading(true);
// try {
// const agent = extensionAgentForTab(tabId);
// await shotAndOpenPlayground(agent);
// await agent!.page.destroy();
// } catch (e: any) {
// message.error(e.message || 'Failed to launch Playground');
// }
// setLoading(false);
// };

View File

@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootEl = document.getElementById('root');
if (rootEl) {
const root = ReactDOM.createRoot(rootEl);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
}

View File

@ -0,0 +1 @@
export default {};

View File

@ -5,7 +5,7 @@ import {
type WorkerRequestGetContext,
type WorkerRequestSaveContext,
workerMessageTypes,
} from './utils';
} from '../utils';
// console-browserify won't work in worker, so we need to use globalThis.console
const console = globalThis.console;

View File

@ -0,0 +1,78 @@
/// <reference types="chrome" />
import type { WebUIContext } from '@midscene/web/utils';
export const workerMessageTypes = {
SAVE_CONTEXT: 'save-context',
GET_CONTEXT: 'get-context',
};
// save screenshot
export interface WorkerRequestSaveContext {
context: WebUIContext;
}
export interface WorkerResponseSaveContext {
id: string;
}
// get screenshot
export interface WorkerRequestGetContext {
id: string;
}
export interface WorkerResponseGetContext {
context: WebUIContext;
}
export async function sendToWorker<Payload, Result = any>(
type: string,
payload: Payload,
): Promise<Result> {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type, payload }, (response) => {
if (response.error) {
reject(response.error);
} else {
resolve(response);
}
});
});
}
export function getPlaygroundUrl(cacheContextId: string) {
return chrome.runtime.getURL(
`./pages/playground.html?cache_context_id=${cacheContextId}`,
);
}
export async function activeTab(): Promise<chrome.tabs.Tab> {
return new Promise((resolve, reject) => {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs?.[0]) {
resolve(tabs[0]);
} else {
reject(new Error('No active tab found'));
}
});
});
}
export async function currentWindowId(): Promise<number> {
return new Promise((resolve, reject) => {
chrome.windows.getCurrent((window) => {
if (window?.id) {
resolve(window.id);
} else {
reject(new Error('No active window found'));
}
});
});
}
export function getExtensionVersion() {
return chrome.runtime?.getManifest()?.version || 'unknown';
}
export async function getTabInfo(tabId: number) {
return await chrome.tabs.get(tabId);
}

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,29 +1,22 @@
{
"name": "Midscene.js",
"description": "Open-source SDK for automating web pages using natural language through AI.",
"version": "0.41",
"version": "0.42",
"manifest_version": 3,
"permissions": [
"activeTab",
"tabs",
"sidePanel",
"debugger"
],
"permissions": ["activeTab", "tabs", "sidePanel", "debugger"],
"incognito": "split",
"background": {
"service_worker": "./lib/worker.js"
"service_worker": "./scripts/worker.js"
},
"host_permissions": [
"<all_urls>"
],
"host_permissions": ["<all_urls>"],
"action": {
"default_icon": "icon128.png",
"default_title": "Midscene.js"
},
"side_panel": {
"default_path": "./pages/sidepanel.html"
"default_path": "./index.html"
},
"icons": {
"128": "icon128.png"
}
}
}

View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"lib": ["DOM", "ES2020"],
"jsx": "react-jsx",
"target": "ES2020",
"skipLibCheck": true,
"useDefineForClassFields": true,
/* modules */
"module": "ESNext",
"isolatedModules": true,
"resolveJsonModule": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
/* type checking */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}

View File

@ -33,7 +33,7 @@
"@types/minimist": "1.2.5",
"@types/node": "^18.0.0",
"@types/yargs": "17.0.32",
"typescript": "~5.0.4",
"typescript": "^5.8.2",
"vitest": "3.0.5",
"yargs": "17.7.2",
"chalk": "4.1.2",

View File

@ -52,7 +52,7 @@
"@modern-js/module-tools": "2.60.6",
"@types/node": "^18.0.0",
"@types/node-fetch": "2.6.11",
"typescript": "~5.0.4",
"typescript": "^5.8.2",
"vitest": "3.0.5"
},
"engines": {

View File

@ -28,7 +28,7 @@
"dotenv": "16.4.5",
"playwright": "1.44.1",
"@playwright/test": "^1.44.1",
"typescript": "~5.0.4",
"typescript": "^5.8.2",
"vitest": "3.0.5"
},
"engines": {

View File

@ -58,7 +58,7 @@
"@types/debug": "4.1.12",
"@types/node": "^18.0.0",
"rimraf": "~3.0.2",
"typescript": "~5.0.4",
"typescript": "^5.8.2",
"vitest": "3.0.5"
},
"sideEffects": [],

View File

@ -36,6 +36,21 @@ export default defineConfig({
platform: 'browser',
outDir: 'dist',
target: 'es2020',
externals: [...externals],
},
{
...commonConfig,
alias: {
async_hooks: path.join(__dirname, './src/blank_polyfill.ts'),
},
dts: false,
input: {
extension: 'src/extension.tsx',
},
platform: 'browser',
outDir: 'dist',
target: 'es2020',
externals: [...externals, 'react', 'react-dom'],
},
{
...commonConfig,
@ -45,10 +60,6 @@ export default defineConfig({
format: 'iife',
dts: false,
input: {
'water-flow': 'src/extension/scripts/water-flow.ts',
'stop-water-flow': 'src/extension/scripts/stop-water-flow.ts',
popup: 'src/extension/popup.tsx',
worker: 'src/extension/worker.ts',
'playground-entry': 'src/extension/playground-entry.tsx',
},
platform: 'browser',

View File

@ -6,6 +6,23 @@
"types": "./dist/types/index.d.ts",
"main": "./dist/lib/index.js",
"module": "./dist/es/index.js",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"default": "./dist/lib/index.js"
},
"./popup": {
"types": "./dist/types/extension/popup.d.ts",
"default": "./dist/popup.js"
},
"./extension": {
"types": "./dist/types/extension.d.ts",
"default": "./dist/extension.js"
},
"./popup.css": {
"default": "./dist/popup.css"
}
},
"files": ["dist", "html", "README.md"],
"watch": {
"build": {
@ -23,8 +40,12 @@
"upgrade": "modern upgrade",
"e2e": "node ../cli/bin/midscene ./scripts/midscene/"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@ant-design/icons": "5.3.7",
"@ant-design/icons": "^5.3.1",
"@midscene/core": "workspace:*",
"@midscene/shared": "workspace:*",
"@midscene/web": "workspace:*",
@ -35,9 +56,9 @@
"@pixi/unsafe-eval": "7.4.2",
"@types/chrome": "0.0.279",
"@types/node": "^18.0.0",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"antd": "5.21.6",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"antd": "^5.21.6",
"dayjs": "1.11.11",
"execa": "9.3.0",
"http-server": "14.1.1",
@ -45,12 +66,12 @@
"pixi-filters": "6.0.5",
"pixi.js": "8.1.1",
"query-string": "9.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-resizable-panels": "2.0.22",
"rimraf": "~3.0.2",
"tsx": "4.19.2",
"typescript": "~5.0.4",
"typescript": "^5.8.2",
"zustand": "4.5.2"
},
"sideEffects": ["**/*.css", "**/*.less", "**/*.sass", "**/*.scss"],

View File

@ -123,43 +123,6 @@ function reportHTMLWithDump(
return html;
}
/* build task: extension */
function buildExtension() {
// clear everything in the extension page dir
rmSync(outputExtensionPageDir, { recursive: true, force: true });
ensureDirectoryExistence(outputExtensionSidepanel);
// write the set-report-tpl.js into the extension
writeFileSync(
join(__dirname, '../unpacked-extension/lib/set-report-tpl.js'),
tplRetrieverFn,
);
// playground.html
const resultWithOutsource = tplReplacer(playgroundTpl, {
css: `<style>\n${playgroundCSS}\n</style>\n`,
js: `<script src="/lib/playground-entry.js"></script>`,
bootstrap: '<!-- leave it empty -->', // the entry iife will mount by itself
});
writeFileSync(
outputExtensionPlayground,
putReportTplIntoHTML(resultWithOutsource, true),
);
console.log(`HTML file generated successfully: ${outputExtensionPlayground}`);
// sidepanel.html
writeFileSync(
outputExtensionSidepanel,
putReportTplIntoHTML(extensionSidepanelTpl, true),
);
console.log(`HTML file generated successfully: ${outputExtensionSidepanel}`);
// put the htmlElement.js into the extension
safeCopyFile(
join(__dirname, '../../web-integration/iife-script/htmlElement.js'),
join(__dirname, '../unpacked-extension/lib/htmlElement.js'),
);
}
async function zipDir(src: string, dest: string) {
// console.log('cwd', dirname(src));
execSync(`zip -r "${dest}" .`, {
@ -167,25 +130,6 @@ async function zipDir(src: string, dest: string) {
});
}
async function packExtension() {
const manifest = fileContentOfPath('../unpacked-extension/manifest.json');
const version = JSON.parse(manifest).version;
const zipName = `midscene-extension-v${version}.zip`;
const distFile = join(outputExtensionZipDir, zipName);
ensureDirectoryExistence(distFile);
// zip the extension
if (platform() !== 'win32') {
await zipDir(outputExtensionUnpackedBaseDir, distFile);
// print size of the zip file
const size = statSync(distFile).size;
console.log(`Zip file size: ${size} bytes`);
} else {
console.warn('zip is not supported on this platform, will skip it');
}
}
/* build task: report and demo pages*/
function buildReport() {
const reportHTMLContent = reportHTMLWithDump();
@ -223,5 +167,3 @@ function buildReport() {
}
buildReport();
buildExtension();
packExtension();

View File

@ -0,0 +1,19 @@
export { default as Logo } from './component/logo';
export {
Playground,
extensionAgentForTab,
} from './component/playground-component';
export { globalThemeConfig } from './component/color';
export { useEnvConfig } from './component/store';
export {
type WorkerRequestGetContext,
type WorkerRequestSaveContext,
type WorkerResponseGetContext,
type WorkerResponseSaveContext,
workerMessageTypes,
getExtensionVersion,
getTabInfo,
currentWindowId,
sendToWorker,
} from './extension/utils';

View File

@ -47,7 +47,7 @@ export function getPlaygroundUrl(cacheContextId: string) {
export async function activeTab(): Promise<chrome.tabs.Tab> {
return new Promise((resolve, reject) => {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
chrome.tabs?.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs?.[0]) {
resolve(tabs[0]);
} else {
@ -70,7 +70,7 @@ export async function currentWindowId(): Promise<number> {
}
export function getExtensionVersion() {
return chrome.runtime.getManifest().version;
return chrome.runtime?.getManifest()?.version || 'unknown';
}
export async function getTabInfo(tabId: number) {

View File

@ -11,19 +11,58 @@
"midscene-playground": "./bin/midscene-playground"
},
"exports": {
".": "./dist/lib/index.js",
"./bridge-mode": "./dist/lib/bridge-mode.js",
"./bridge-mode-browser": "./dist/lib/bridge-mode-browser.js",
"./utils": "./dist/lib/utils.js",
"./ui-utils": "./dist/lib/ui-utils.js",
"./puppeteer": "./dist/lib/puppeteer.js",
"./playwright": "./dist/lib/playwright.js",
"./playwright-report": "./dist/lib/playwright-report.js",
"./playground": "./dist/lib/playground.js",
"./midscene-playground": "./dist/lib/midscene-playground.js",
"./appium": "./dist/lib/appium.js",
"./chrome-extension": "./dist/lib/chrome-extension.js",
"./yaml": "./dist/lib/yaml.js"
".": {
"types": "./dist/types/index.d.ts",
"default": "./dist/lib/index.js"
},
"./bridge-mode": {
"types": "./dist/types/bridge-mode.d.ts",
"default": "./dist/lib/bridge-mode.js"
},
"./bridge-mode-browser": {
"types": "./dist/types/bridge-mode-browser.d.ts",
"default": "./dist/lib/bridge-mode-browser.js"
},
"./utils": {
"types": "./dist/types/utils.d.ts",
"default": "./dist/lib/utils.js"
},
"./ui-utils": {
"types": "./dist/types/ui-utils.d.ts",
"default": "./dist/lib/ui-utils.js"
},
"./puppeteer": {
"types": "./dist/types/puppeteer.d.ts",
"default": "./dist/lib/puppeteer.js"
},
"./playwright": {
"types": "./dist/types/playwright.d.ts",
"default": "./dist/lib/playwright.js"
},
"./playwright-report": {
"types": "./dist/types/playwright-report.d.ts",
"default": "./dist/lib/playwright-report.js"
},
"./playground": {
"types": "./dist/types/playground.d.ts",
"default": "./dist/lib/playground.js"
},
"./midscene-playground": {
"types": "./dist/types/midscene-playground.d.ts",
"default": "./dist/lib/midscene-playground.js"
},
"./appium": {
"types": "./dist/types/appium.d.ts",
"default": "./dist/lib/appium.js"
},
"./chrome-extension": {
"types": "./dist/types/chrome-extension.d.ts",
"default": "./dist/lib/chrome-extension.js"
},
"./yaml": {
"types": "./dist/types/yaml.d.ts",
"default": "./dist/lib/yaml.js"
}
},
"typesVersions": {
"*": {
@ -96,7 +135,7 @@
"@wdio/types": "9.0.4",
"playwright": "1.44.1",
"puppeteer": "24.2.0",
"typescript": "~5.0.4",
"typescript": "^5.8.2",
"vitest": "3.0.5"
},
"peerDependencies": {

View File

@ -5,7 +5,7 @@ import { ifInBrowser } from '@midscene/shared/utils';
// extract html element from page
let scriptFileContentCache: string | null = null;
export const getHtmlElementScript = async () => {
const scriptFileToRetrieve = chrome.runtime.getURL('lib/htmlElement.js');
const scriptFileToRetrieve = chrome.runtime.getURL('scripts/htmlElement.js');
if (scriptFileContentCache) return scriptFileContentCache;
if (ifInBrowser) {
const script = await fetch(scriptFileToRetrieve);
@ -18,8 +18,9 @@ export const getHtmlElementScript = async () => {
// inject water flow animation
let waterFlowScriptFileContentCache: string | null = null;
export const injectWaterFlowAnimation = async () => {
const waterFlowScriptFileToRetrieve =
chrome.runtime.getURL('lib/water-flow.js');
const waterFlowScriptFileToRetrieve = chrome.runtime.getURL(
'scripts/water-flow.js',
);
if (waterFlowScriptFileContentCache) return waterFlowScriptFileContentCache;
if (ifInBrowser) {
const script = await fetch(waterFlowScriptFileToRetrieve);
@ -33,7 +34,7 @@ export const injectWaterFlowAnimation = async () => {
let stopWaterFlowScriptFileContentCache: string | null = null;
export const injectStopWaterFlowAnimation = async () => {
const stopWaterFlowScriptFileToRetrieve = chrome.runtime.getURL(
'lib/stop-water-flow.js',
'scripts/stop-water-flow.js',
);
if (stopWaterFlowScriptFileContentCache)
return stopWaterFlowScriptFileContentCache;

924
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
packages:
- apps/site
- apps/chrome-extension
- packages/cli
- packages/shared
- packages/core

View File

@ -4,7 +4,9 @@ const semver = require('semver');
const dayjs = require('dayjs');
const args = require('minimist')(process.argv.slice(2));
const bumpPrompt = require('@jsdevtools/version-bump-prompt');
const { execa } = require('@esm2cjs/execa');
const {
execa
} = require('@esm2cjs/execa');
const chalk = require('chalk');
const step = (msg) => {
@ -33,8 +35,7 @@ const run = async (bin, args, opts = {}) => {
const currentVersion = require('../package.json').version;
const actionPublishCanary =
['preminor', 'prepatch'].includes(args.version) && process.env.CI;
const actionPublishCanary = ['preminor', 'prepatch'].includes(args.version) && process.env.CI;
async function main() {
try {
@ -58,7 +59,9 @@ async function main() {
step('\nLinting all packages...');
await lint();
const { stdout } = await run('git', ['diff'], {
const {
stdout
} = await run('git', ['diff'], {
stdio: 'pipe',
});
if (stdout) {
@ -75,7 +78,7 @@ async function main() {
'--global',
'user.email',
process.env.GIT_USER_EMAIL ||
'github-actions[bot]@users.noreply.github.com',
'github-actions[bot]@users.noreply.github.com',
]);
}
step('\nCommitting changes...');
@ -145,7 +148,7 @@ async function test() {
async function bumpExtensionVersion(newNpmVersion) {
const manifestPath = path.join(
__dirname,
'../packages/visualizer/unpacked-extension/manifest.json',
'../apps/chrome-extension/static/manifest.json',
);
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const [a, b] = manifest.version.split('.').map(Number);
@ -256,4 +259,4 @@ async function cleanup() {
main().catch((err) => {
console.error(chalk.red(`Unexpected error: ${err.message}`));
process.exit(1);
});
});