diff --git a/.github/workflows/cli-integration-test.yml b/.github/workflows/cli-integration-test.yml deleted file mode 100644 index f325e10..0000000 --- a/.github/workflows/cli-integration-test.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: CLI Integration Test - -on: - push: - pull_request: - branches: [ main ] - workflow_dispatch: - -jobs: - cli-integration: - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build CLI - run: pnpm run build - - - name: Clone test project - run: git clone https://github.com/wokwi/esp-idf-hello-world.git test-project - - - name: Create test scenario - run: | - cat > test-project/test-scenario.yaml << 'EOF' - name: "Basic Hello World Test" - version: 1 - description: "Test that the ESP32 hello world program outputs expected text" - - steps: - - name: "Wait for boot and hello message" - wait-serial: "Hello world!" - - - name: "Wait for chip information" - wait-serial: "This is esp32 chip" - - - name: "Wait for restart message" - wait-serial: "Restarting in 10 seconds" - EOF - - - name: Run a Wokwi CI server - uses: wokwi/wokwi-ci-server-action@v1 - - - name: Test CLI with basic expect-text - run: pnpm cli test-project --timeout 5000 --expect-text "Hello" - env: - WOKWI_CLI_TOKEN: ${{ secrets.WOKWI_CLI_TOKEN }} - - - name: Test CLI with scenario file - run: pnpm cli test-project --scenario test-scenario.yaml --timeout 15000 - env: - WOKWI_CLI_TOKEN: ${{ secrets.WOKWI_CLI_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee39025..f6303da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,10 +27,10 @@ jobs: sudo apt-get update sudo apt-get install -y binfmt-support qemu-user-static - name: 'Build' - run: pnpm run package + run: pnpm run build && pnpm --filter wokwi-cli run package - name: Upload Release uses: ncipollo/release-action@v1 with: - artifacts: 'dist/bin/wokwi-cli-*,dist/bin/version.json' + artifacts: 'packages/wokwi-cli/dist/bin/wokwi-cli-*,packages/wokwi-cli/dist/bin/version.json' token: ${{ secrets.GITHUB_TOKEN }} generateReleaseNotes: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 49c1cac..e579dd0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,9 @@ name: Tests on: push: - branches: [main] pull_request: branches: [main] + workflow_dispatch: jobs: test: @@ -20,5 +20,17 @@ jobs: cache: 'pnpm' - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run tests - run: pnpm test + - name: Build packages + run: pnpm run build + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps + - name: Format check + run: pnpm run format:check + - name: Run vitest tests + run: pnpm run test:vitest + - name: Run Playwright embed tests + run: pnpm run test:embed:playwright + - name: Run CLI integration tests + run: pnpm run test:cli:integration + env: + WOKWI_CLI_TOKEN: ${{ secrets.WOKWI_CLI_TOKEN }} diff --git a/.gitignore b/.gitignore index 58c63ed..f3fc938 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ wokwi.toml *.bin diagram.json screenshot.png +playwright-report/ +test-results/ +test-project/ +wokwi-part-tests/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..a1f2ff6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,27 @@ +# Ignore node_modules +node_modules/ + +# Ignore build outputs +dist/ +build/ + +# Ignore logs +*.log + +# Ignore package-lock.json if present +package-lock.json + +# Ignore Arduino-specific files +*.hex +*.elf +*.bin + +# Ignore OS-specific files +.DS_Store +Thumbs.db + +# repo specific ignores +playwright-report/ +test-results/ +test-project/ +wokwi-part-tests/ \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..703dcaa --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,113 @@ +# Development + +This document describes how to set up a development environment for the Wokwi CLI and the Wokwi JS client. + +## Getting started + +Prerequisites: +- Node.js (https://nodejs.org/) (version 20 or higher) +- Git +- PNPM (https://pnpm.io) + +We use PNPM workspaces to manage multiple packages in this repository. Install PNPM globally following the official instructions: https://pnpm.io/installation. + +## Setting up the repository + +Clone the repository and install dependencies: + +```bash +git clone https://github.com/wokwi/wokwi-cli +cd wokwi-cli +pnpm install +``` + +We use Playwright for end-to-end testing. Install the required browsers with: + +```bash +pnpm exec playwright install +``` + +## Packages + +The repository contains two main packages: +- `wokwi-cli`: The command-line interface for Wokwi. +- `wokwi-js`: The JavaScript client library used to interact with Wokwi from a browser iframe. + +When you run `pnpm` commands from the repository root, the monorepo configuration determines which packages the command runs in. For example, `pnpm build` runs the build across packages, while `pnpm lint` runs `eslint .` for the whole repository. + +If you want to build a single package, use the `--filter` flag. For example, to build only the `wokwi-cli` package: + +```bash +pnpm --filter wokwi-cli build +``` + +Or change into the package directory and run the command there: + +```bash +cd packages/wokwi-cli +pnpm build +``` + +## Running the CLI in development mode + +Build the packages first: + +```bash +pnpm build +``` + +Then run the CLI from the `wokwi-cli` package directory: + +```bash +cd packages/wokwi-cli +pnpm cli [arguments] + +# Example: show the help screen +pnpm cli -h +``` + +Example output: + +```bash +Wokwi CLI v0-development (f33d9d579b0a) + + USAGE + + $ wokwi-cli [options] [path/to/project] + + OPTIONS + --help, -h Shows this help message and exit +... +``` + +## Running tests locally + +Before running tests, make sure you have built the packages and installed Playwright browsers: + +```bash +pnpm build +pnpm exec playwright install +``` + +We have several types of tests: +- Unit tests (Vitest) +- End-to-end tests (Playwright) +- Integration tests that run the CLI against real Wokwi projects + +To run all tests: + +```bash +pnpm test +``` + +To run tests separately, inspect the `scripts` section in the root `package.json`. + +## Automatic tests (CI) + +The repository uses GitHub Actions to run tests on every push and pull request. The workflow files live in `.github/workflows/` and contain steps to set up the environment, install dependencies, build packages, and run tests. + +If you fork the repository, you must enable GitHub Actions for your fork and add the `WOKWI_CLI_TOKEN` secret. + +Set the `WOKWI_CLI_TOKEN` secret in your fork under `Settings` > `Secrets and variables` > `Actions` > `New repository secret`. + +Instructions for obtaining the `WOKWI_CLI_TOKEN` are in the `README.md` (see the Usage section). diff --git a/README.md b/README.md index 0b17085..d63c9e3 100644 --- a/README.md +++ b/README.md @@ -74,20 +74,7 @@ To configure your AI agent to use the MCP server, add the following to your agen ## Development -Clone the repository, install the npm depenedencies, and then run the CLI: - -```bash -git clone https://github.com/wokwi/wokwi-cli -cd wokwi-cli -npm install -npm start -``` - -To pass command line arguments to the cli, use `npm start -- [arguments]`. For example, to see the help screen, run: - -``` -npm start -- -h -``` +All information about developing the Wokwi CLI can be found in [DEVELOPMENT.md](DEVELOPMENT.md). ## License diff --git a/eslint.config.ts b/eslint.config.ts index 7480f37..4e7090d 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,6 +1,5 @@ import { defineConfig } from 'eslint/config'; import eslint from '@eslint/js'; -import eslintConfigPrettier from 'eslint-config-prettier'; import tseslint from 'typescript-eslint'; export default defineConfig( @@ -18,6 +17,8 @@ export default defineConfig( '**/.git/**', '**/coverage/**', '**/*.min.js', + '**/playwright-report/**', + '**/test-results/**', ], }, @@ -64,7 +65,4 @@ export default defineConfig( 'no-undef': 'off', }, }, - - // Prettier config (must be last to override conflicting rules) - eslintConfigPrettier, ); diff --git a/package.json b/package.json index 2281752..554936a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,14 @@ "clean": "pnpm -r run clean", "lint": "eslint .", "lint:fix": "eslint . --fix", - "test": "npm run lint && vitest --run", + "format": "prettier --write packages scripts test", + "format:check": "prettier --check packages scripts test", + "test": "pnpm run test:vitest && pnpm run test:embed:playwright && pnpm run test:cli:integration", + "test:vitest": "pnpm run lint && vitest --run", + "test:embed:playwright": "playwright test test/wokwi-embed", + "test:embed:playwright:ui": "playwright test test/wokwi-embed --ui", + "test:cli:integration": "pnpm exec tsx scripts/test-cli-integration.ts", + "package": "pnpm --filter wokwi-cli run package", "cli": "tsx packages/wokwi-cli/src/main.ts", "prepare": "husky install" }, @@ -19,26 +26,15 @@ "type": "git", "url": "https://github.com/wokwi/wokwi-cli" }, - "dependencies": { - "@clack/prompts": "^0.7.0", - "@iarna/toml": "2.2.5", - "@modelcontextprotocol/sdk": "^1.0.0", - "arg": "^5.0.2", - "chalk": "^5.3.0", - "chalk-template": "^1.1.0", - "pngjs": "^7.0.0", - "ws": "^8.13.0", - "yaml": "^2.3.1" - }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.48.0", "esbuild": "^0.25.2", "eslint": "^9.39.1", - "eslint-config-prettier": "^10.1.8", "husky": "^8.0.0", "jiti": "^2.6.1", "lint-staged": "^15.4.3", - "prettier": "^3.5.0", + "prettier": "^3.6.2", "rimraf": "^5.0.0", "tsx": "^4.19.2", "typescript": "^5.9.3", @@ -47,8 +43,8 @@ }, "lint-staged": { "*.{js,ts}": [ - "prettier --write", - "eslint" + "pnpm run format:check", + "pnpm run lint" ] } } diff --git a/packages/wokwi-cli/package.json b/packages/wokwi-cli/package.json index 1799eef..7b7860b 100644 --- a/packages/wokwi-cli/package.json +++ b/packages/wokwi-cli/package.json @@ -5,10 +5,15 @@ "main": "index.js", "type": "module", "scripts": { - "build": "node tools/build.js", - "package": "npm run build && pkg --public -o dist/bin/wokwi-cli -t node20-linuxstatic-arm64,node20-linuxstatic-x64,node20-macos-arm64,node20-macos-x64,node20-win-x64 dist/cli.cjs", + "prebuild": "pnpm run clean", + "build": "tsc && pnpm run bundle", + "bundle": "node tools/bundle.js", + "package": "pnpm run build && pkg --public -o dist/bin/wokwi-cli -t node20-linuxstatic-arm64,node20-linuxstatic-x64,node20-macos-arm64,node20-macos-x64,node20-win-x64 dist/cli.cjs", + "clean": "rimraf dist", + "lint": "eslint src/**/*.ts", + "lint:fix": "eslint src/**/*.ts --fix", "cli": "tsx src/main.ts", - "test": "npm run lint && vitest --run", + "test": "pnpm run lint && vitest --run", "test:watch": "vitest --watch" }, "keywords": [ @@ -41,6 +46,7 @@ "chalk": "^5.3.0", "chalk-template": "^1.1.0", "pngjs": "^7.0.0", + "wokwi-client-js": "workspace:*", "ws": "^8.13.0", "yaml": "^2.3.1" }, diff --git a/packages/wokwi-cli/src/TestScenario.ts b/packages/wokwi-cli/src/TestScenario.ts index 2dda74b..d7077a3 100644 --- a/packages/wokwi-cli/src/TestScenario.ts +++ b/packages/wokwi-cli/src/TestScenario.ts @@ -1,5 +1,5 @@ import chalkTemplate from 'chalk-template'; -import type { APIClient } from './APIClient.js'; +import type { APIClient } from 'wokwi-client-js'; export interface IScenarioCommand { /** Validates the input to the command. Throws an exception of the input is not valid */ diff --git a/packages/wokwi-cli/src/constants.ts b/packages/wokwi-cli/src/constants.ts new file mode 100644 index 0000000..e08221e --- /dev/null +++ b/packages/wokwi-cli/src/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_SERVER = process.env.WOKWI_CLI_SERVER ?? 'wss://wokwi.com/api/ws/beta'; diff --git a/packages/wokwi-cli/src/main.ts b/packages/wokwi-cli/src/main.ts index f92a7ca..74281ac 100644 --- a/packages/wokwi-cli/src/main.ts +++ b/packages/wokwi-cli/src/main.ts @@ -3,8 +3,15 @@ import chalkTemplate from 'chalk-template'; import { createWriteStream, existsSync, readFileSync, writeFileSync } from 'fs'; import path, { join } from 'path'; import YAML from 'yaml'; -import { APIClient } from './APIClient.js'; -import type { APIEvent, ChipsLogPayload, SerialMonitorDataPayload } from './APITypes.js'; +import { + APIClient, + type APIEvent, + type ChipsLogPayload, + type SerialMonitorDataPayload, +} from 'wokwi-client-js'; +import { WebSocketTransport } from './transport/WebSocketTransport.js'; +import { DEFAULT_SERVER } from './constants.js'; +import { createSerialMonitorWritable } from './utils/serialMonitorWritable.js'; import { ExpectEngine } from './ExpectEngine.js'; import { SimulationTimeoutError } from './SimulationTimeoutError.js'; import { TestScenario } from './TestScenario.js'; @@ -22,6 +29,7 @@ import { TakeScreenshotCommand } from './scenario/TakeScreenshotCommand.js'; import { WaitSerialCommand } from './scenario/WaitSerialCommand.js'; import { WriteSerialCommand } from './scenario/WriteSerialCommand.js'; import { uploadFirmware } from './uploadFirmware.js'; +const { sha, version } = readVersion(); const millis = 1_000_000; @@ -272,7 +280,8 @@ async function main() { }); } - const client = new APIClient(token); + const transport = new WebSocketTransport(token, DEFAULT_SERVER, version, sha); + const client = new APIClient(transport); client.onConnected = (hello) => { if (!quiet) { console.log(`Connected to Wokwi Simulation API ${hello.appVersion}`); @@ -288,12 +297,15 @@ async function main() { await client.fileUpload('diagram.json', diagram); const firmwareName = await uploadFirmware(client, firmwarePath); if (elfPath != null) { - await client.fileUpload('firmware.elf', readFileSync(elfPath)); + await client.fileUpload('firmware.elf', new Uint8Array(readFileSync(elfPath))); } for (const chip of chips) { await client.fileUpload(`${chip.name}.chip.json`, readFileSync(chip.jsonPath, 'utf-8')); - await client.fileUpload(`${chip.name}.chip.wasm`, readFileSync(chip.wasmPath)); + await client.fileUpload( + `${chip.name}.chip.wasm`, + new Uint8Array(readFileSync(chip.wasmPath)), + ); } const promises = []; @@ -359,7 +371,7 @@ async function main() { }); if (interactive) { - process.stdin.pipe(client.serialMonitorWritable()); + process.stdin.pipe(await createSerialMonitorWritable(client)); } if (scenario != null) { diff --git a/packages/wokwi-cli/src/mcp/MCPServer.ts b/packages/wokwi-cli/src/mcp/MCPServer.ts index 9ffafcb..d0fa160 100644 --- a/packages/wokwi-cli/src/mcp/MCPServer.ts +++ b/packages/wokwi-cli/src/mcp/MCPServer.ts @@ -1,10 +1,10 @@ import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { - CallToolRequestSchema, +import { + CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, - ReadResourceRequestSchema + ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { readVersion } from '../readVersion.js'; import { SimulationManager } from './SimulationManager.js'; @@ -25,16 +25,19 @@ export class WokwiMCPServer { constructor(private readonly options: MCPServerOptions) { const { version } = readVersion(); - - this.server = new McpServer({ - name: 'wokwi-cli', - version, - }, { - capabilities: { - tools: {}, - resources: {}, + + this.server = new McpServer( + { + name: 'wokwi-cli', + version, }, - }); + { + capabilities: { + tools: {}, + resources: {}, + }, + }, + ); this.simulationManager = new SimulationManager(options.rootDir, options.token, options.quiet); this.tools = new WokwiMCPTools(this.simulationManager); @@ -64,7 +67,7 @@ export class WokwiMCPServer { async start() { const transport = new StdioServerTransport(); await this.server.connect(transport); - + if (!this.options.quiet) { console.error('Wokwi MCP Server started'); } @@ -74,4 +77,4 @@ export class WokwiMCPServer { await this.simulationManager.cleanup(); await this.server.close(); } -} \ No newline at end of file +} diff --git a/packages/wokwi-cli/src/mcp/SimulationManager.ts b/packages/wokwi-cli/src/mcp/SimulationManager.ts index 1962ae5..11f0115 100644 --- a/packages/wokwi-cli/src/mcp/SimulationManager.ts +++ b/packages/wokwi-cli/src/mcp/SimulationManager.ts @@ -1,9 +1,11 @@ import { existsSync, readFileSync } from 'fs'; import path from 'path'; -import { APIClient } from '../APIClient.js'; -import type { APIEvent } from '../APITypes.js'; +import { APIClient, SerialMonitorDataPayload, type APIEvent } from 'wokwi-client-js'; +import { WebSocketTransport } from '../transport/WebSocketTransport.js'; +import { DEFAULT_SERVER } from '../constants.js'; import { parseConfig } from '../config.js'; import { loadChips } from '../loadChips.js'; +import { readVersion } from '../readVersion.js'; import { uploadFirmware } from '../uploadFirmware.js'; export interface SimulationStatus { @@ -31,7 +33,9 @@ export class SimulationManager { return; } - this.client = new APIClient(this.token); + const { sha, version } = readVersion(); + const transport = new WebSocketTransport(this.token, DEFAULT_SERVER, version, sha); + this.client = new APIClient(transport); this.client.onConnected = (hello) => { this.isConnected = true; @@ -45,13 +49,10 @@ export class SimulationManager { throw new Error(`API Error: ${error.message}`); }; - this.client.onEvent = (event: APIEvent) => { - if (event.event === 'serial-monitor:data') { - const bytes = (event as any).payload.bytes; - const text = bytes.map((byte: number) => String.fromCharCode(byte)).join(''); - this.addToSerialBuffer(text); - } - }; + this.client.listen('serial-monitor:data', (event: APIEvent) => { + const { bytes } = event.payload; + this.addToSerialBuffer(String.fromCharCode(...bytes)); + }); await this.client.connected; } @@ -104,12 +105,15 @@ export class SimulationManager { const firmwareName = await uploadFirmware(this.client, firmwarePath); if (elfPath) { - await this.client.fileUpload('firmware.elf', readFileSync(elfPath)); + await this.client.fileUpload('firmware.elf', new Uint8Array(readFileSync(elfPath))); } for (const chip of chips) { await this.client.fileUpload(`${chip.name}.chip.json`, readFileSync(chip.jsonPath, 'utf-8')); - await this.client.fileUpload(`${chip.name}.chip.wasm`, readFileSync(chip.wasmPath)); + await this.client.fileUpload( + `${chip.name}.chip.wasm`, + new Uint8Array(readFileSync(chip.wasmPath)), + ); } // Start simulation diff --git a/packages/wokwi-cli/src/mcp/WokwiMCPTools.ts b/packages/wokwi-cli/src/mcp/WokwiMCPTools.ts index 02f8f57..0364c4e 100644 --- a/packages/wokwi-cli/src/mcp/WokwiMCPTools.ts +++ b/packages/wokwi-cli/src/mcp/WokwiMCPTools.ts @@ -14,7 +14,8 @@ export class WokwiMCPTools { properties: { projectPath: { type: 'string', - description: 'Path to the project directory (optional, defaults to current directory)', + description: + 'Path to the project directory (optional, defaults to current directory)', }, }, }, @@ -135,7 +136,10 @@ export class WokwiMCPTools { ]; } - async callTool(name: string, args: any): Promise<{ content: Array<{ type: 'text'; text: string }> }> { + async callTool( + name: string, + args: any, + ): Promise<{ content: Array<{ type: 'text'; text: string }> }> { try { switch (name) { case 'wokwi_start_simulation': @@ -239,4 +243,4 @@ export class WokwiMCPTools { }; } } -} \ No newline at end of file +} diff --git a/packages/wokwi-cli/src/mcp/index.ts b/packages/wokwi-cli/src/mcp/index.ts index 34cc65c..4e4d273 100644 --- a/packages/wokwi-cli/src/mcp/index.ts +++ b/packages/wokwi-cli/src/mcp/index.ts @@ -3,4 +3,4 @@ export { SimulationManager } from './SimulationManager.js'; export { WokwiMCPTools } from './WokwiMCPTools.js'; export { WokwiMCPResources } from './WokwiMCPResources.js'; export type { MCPServerOptions } from './MCPServer.js'; -export type { SimulationStatus } from './SimulationManager.js'; \ No newline at end of file +export type { SimulationStatus } from './SimulationManager.js'; diff --git a/packages/wokwi-cli/src/project/projectType.ts b/packages/wokwi-cli/src/project/projectType.ts index 4186b51..42128ff 100644 --- a/packages/wokwi-cli/src/project/projectType.ts +++ b/packages/wokwi-cli/src/project/projectType.ts @@ -25,7 +25,7 @@ export async function detectProjectType(root: string): Promise void = () => {}; + public onClose?: (code: number, reason?: string) => void; + public onError?: (error: Error) => void; + + private socket: WebSocket; + private connectionAttempts = 0; + + // to suppress close events when intentionally closing + private ignoreClose = false; + + // retryable error statuses + private readonly errorStates = [ + ErrorStatus.RequestTimeout, + ErrorStatus.ServiceUnavailable, + ErrorStatus.CfRequestTimeout, + ]; + + constructor( + private readonly token: string, + private readonly server: string, + private readonly version: string, + private readonly sha: string, + ) { + this.socket = this.createSocket(); + } + + private createSocket(): WebSocket { + return new WebSocket(this.server, { + headers: { + Authorization: `Bearer ${this.token}`, + 'User-Agent': `wokwi-cli/${this.version} (${this.sha})`, + }, + }); + } + + async connect(): Promise { + await new Promise((resolve, reject) => { + const handleOpen = () => { + this.socket.on('message', (data) => { + let dataStr: string; + if (typeof data === 'string') { + dataStr = data; + } else if (Buffer.isBuffer(data)) { + dataStr = data.toString('utf-8'); + } else { + dataStr = Buffer.from(data as ArrayBuffer).toString('utf-8'); + } + const messageObj = JSON.parse(dataStr); + this.onMessage(messageObj); + }); + this.socket.on('close', (code, reason) => { + if (!this.ignoreClose) { + this.onClose?.(code, reason?.toString()); + } + this.ignoreClose = false; + }); + resolve(); + }; + + const handleError = (err: Error) => { + cleanup(); + reject(new Error(`Error connecting to ${this.server}: ${err.message}`)); + }; + + const handleUnexpected = (_req: any, res: any) => { + cleanup(); + const statusCode = res.statusCode; + const statusMsg = res.statusMessage ?? ''; + // Decide whether to retry based on the status code + if (this.errorStates.includes(statusCode)) { + const delay = retryDelays[this.connectionAttempts++]; + if (delay != null) { + console.warn(`Connection to ${this.server} failed: ${statusMsg} (${statusCode}).`); + console.log(`Will retry in ${delay}ms...`); + this.ignoreClose = true; + this.socket.close(); + setTimeout(() => { + console.log(`Retrying connection to ${this.server}...`); + this.socket = this.createSocket(); + this.connect().then(resolve).catch(reject); + }, delay); + return; + } + reject(new Error(`Failed to connect to ${this.server}. Giving up.`)); + } else { + reject(new Error(`Error connecting to ${this.server}: ${statusCode} ${statusMsg}`)); + } + }; + + // remove handlers after success/failure to avoid leaks + const cleanup = () => { + this.socket.off('open', handleOpen); + this.socket.off('error', handleError); + this.socket.off('unexpected-response', handleUnexpected); + }; + + // attach handlers for this connection attempt + this.socket.on('open', handleOpen); + this.socket.on('error', handleError); + this.socket.on('unexpected-response', handleUnexpected); + }); + } + + send(message: any): void { + this.socket.send(JSON.stringify(message)); + } + + close(): void { + this.ignoreClose = true; + if (this.socket.readyState === WebSocket.OPEN) { + this.socket.close(); + } else { + this.socket.terminate(); + } + } +} diff --git a/packages/wokwi-cli/src/uploadFirmware.ts b/packages/wokwi-cli/src/uploadFirmware.ts index f478b90..ceeca82 100644 --- a/packages/wokwi-cli/src/uploadFirmware.ts +++ b/packages/wokwi-cli/src/uploadFirmware.ts @@ -1,11 +1,11 @@ import { readFileSync } from 'fs'; import { basename, dirname, resolve } from 'path'; -import { type APIClient } from './APIClient.js'; +import { type APIClient } from 'wokwi-client-js'; import { type IESP32FlasherJSON } from './esp/flasherArgs.js'; interface IFirmwarePiece { offset: number; - data: ArrayBuffer; + data: Uint8Array; } const MAX_FIRMWARE_SIZE = 4 * 1024 * 1024; @@ -24,7 +24,7 @@ export async function uploadESP32Firmware(client: APIClient, firmwarePath: strin throw new Error(`Invalid offset in flasher_args.json flash_files: ${offset}`); } - const data = readFileSync(resolve(dirname(firmwarePath), file)); + const data = new Uint8Array(readFileSync(resolve(dirname(firmwarePath), file))); firmwareParts.push({ offset: offsetNum, data }); firmwareSize = Math.max(firmwareSize, offsetNum + data.byteLength); } @@ -37,7 +37,7 @@ export async function uploadESP32Firmware(client: APIClient, firmwarePath: strin const firmwareData = new Uint8Array(firmwareSize); for (const { offset, data } of firmwareParts) { - firmwareData.set(new Uint8Array(data), offset); + firmwareData.set(data, offset); } await client.fileUpload('firmware.bin', firmwareData); @@ -51,6 +51,6 @@ export async function uploadFirmware(client: APIClient, firmwarePath: string) { const extension = firmwarePath.split('.').pop(); const firmwareName = `firmware.${extension}`; - await client.fileUpload(firmwareName, readFileSync(firmwarePath)); + await client.fileUpload(firmwareName, new Uint8Array(readFileSync(firmwarePath))); return firmwareName; } diff --git a/packages/wokwi-cli/src/utils/serialMonitorWritable.ts b/packages/wokwi-cli/src/utils/serialMonitorWritable.ts new file mode 100644 index 0000000..e3f5fc0 --- /dev/null +++ b/packages/wokwi-cli/src/utils/serialMonitorWritable.ts @@ -0,0 +1,20 @@ +import type { APIClient } from 'wokwi-client-js'; +import { Writable } from 'stream'; +import { Buffer } from 'buffer'; + +/** + * Creates a Node.js Writable stream that forwards data to the serial monitor. + * This function is Node.js-only and cannot be used in browser environments. + */ +export async function createSerialMonitorWritable(client: APIClient) { + return new Writable({ + write: (chunk: any, encoding: BufferEncoding, callback: (error?: Error | null) => void) => { + if (typeof chunk === 'string') { + chunk = Buffer.from(chunk, encoding); + } + client.serialMonitorWrite(chunk).then(() => { + callback(null); + }, callback); + }, + }); +} diff --git a/packages/wokwi-cli/tools/build.js b/packages/wokwi-cli/tools/bundle.js similarity index 50% rename from packages/wokwi-cli/tools/build.js rename to packages/wokwi-cli/tools/bundle.js index 53aa66e..206b9ce 100644 --- a/packages/wokwi-cli/tools/build.js +++ b/packages/wokwi-cli/tools/bundle.js @@ -1,18 +1,26 @@ import { execSync } from 'child_process'; import { build } from 'esbuild'; import { mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; -const { version } = JSON.parse(readFileSync('package.json', 'utf8')); -const sha = execSync('git rev-parse --short=12 HEAD').toString().trim(); +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const rootDir = join(__dirname, '..'); +// Get version and SHA +const { version } = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8')); +const sha = execSync('git rev-parse --short=12 HEAD', { cwd: rootDir }).toString().trim(); + +// Generate version.json for distribution const installCommands = { win32: 'iwr https://wokwi.com/ci/install.ps1 -useb | iex', default: 'curl -L https://wokwi.com/ci/install.sh | sh', }; -mkdirSync('dist/bin', { recursive: true }); +mkdirSync(join(rootDir, 'dist/bin'), { recursive: true }); writeFileSync( - 'dist/bin/version.json', + join(rootDir, 'dist/bin/version.json'), JSON.stringify( { version, @@ -24,10 +32,11 @@ writeFileSync( ), ); +// Bundle the CLI const options = { platform: 'node', - entryPoints: ['./src/main.ts'], - outfile: './dist/cli.cjs', + entryPoints: [join(rootDir, 'src/main.ts')], + outfile: join(rootDir, 'dist/cli.cjs'), bundle: true, define: { 'process.env.WOKWI_CONST_CLI_VERSION': JSON.stringify(version), diff --git a/packages/wokwi-cli/tsconfig.json b/packages/wokwi-cli/tsconfig.json index 17efde7..afe68b1 100644 --- a/packages/wokwi-cli/tsconfig.json +++ b/packages/wokwi-cli/tsconfig.json @@ -1,9 +1,9 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src/**/*.ts"], - "exclude": ["src/**/*.spec.ts"] + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "node_modules", "dist"] } diff --git a/packages/wokwi-client-js/package.json b/packages/wokwi-client-js/package.json new file mode 100644 index 0000000..f669dc8 --- /dev/null +++ b/packages/wokwi-client-js/package.json @@ -0,0 +1,61 @@ +{ + "name": "wokwi-client-js", + "version": "0.18.3", + "description": "Wokwi Client JS Library", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./browser": { + "types": "./dist/index.d.ts", + "import": "./dist/wokwi-client-js.browser.js" + }, + "./transport/MessagePortTransport.js": { + "types": "./dist/transport/MessagePortTransport.d.ts", + "import": "./dist/transport/MessagePortTransport.js" + }, + "./APIClient.js": { + "types": "./dist/APIClient.d.ts", + "import": "./dist/APIClient.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "prebuild": "pnpm run clean", + "build": "tsc && pnpm run build:browser", + "build:browser": "node tools/bundle-browser.js", + "clean": "rimraf dist", + "lint": "eslint src/**/*.ts", + "lint:fix": "eslint src/**/*.ts --fix" + }, + "keywords": [ + "wokwi", + "simulator", + "api", + "client", + "websocket", + "iot", + "embedded" + ], + "author": "Uri Shaked", + "license": "ISC", + "repository": { + "type": "git", + "url": "https://github.com/wokwi/wokwi-cli" + }, + "dependencies": { + "ws": "^8.13.0" + }, + "devDependencies": { + "@types/ws": "^8.18.1", + "esbuild": "^0.25.2", + "rimraf": "^5.0.0", + "typescript": "^5.2.2" + } +} diff --git a/packages/wokwi-cli/src/APIClient.ts b/packages/wokwi-client-js/src/APIClient.ts similarity index 63% rename from packages/wokwi-cli/src/APIClient.ts rename to packages/wokwi-client-js/src/APIClient.ts index c543440..d2edaa4 100644 --- a/packages/wokwi-cli/src/APIClient.ts +++ b/packages/wokwi-client-js/src/APIClient.ts @@ -1,5 +1,3 @@ -import { Writable } from 'stream'; -import { WebSocket } from 'ws'; import type { APICommand, APIError, @@ -11,14 +9,10 @@ import type { PinReadResponse, } from './APITypes.js'; import { PausePoint, type PausePointParams } from './PausePoint.js'; -import { readVersion } from './readVersion.js'; - -const DEFAULT_SERVER = process.env.WOKWI_CLI_SERVER ?? 'wss://wokwi.com/api/ws/beta'; -const retryDelays = [1000, 2000, 5000, 10000, 20000]; +import { ITransport } from './transport/ITransport.js'; +import { base64ToByteArray, byteArrayToBase64 } from './base64.js'; export class APIClient { - private socket: WebSocket; - private connectionAttempts = 0; private lastId = 0; private lastPausePointId = 0; private closed = false; @@ -31,108 +25,50 @@ export class APIClient { [(result: any) => void, (error: Error) => void] >(); - readonly connected; + readonly connected: Promise; onConnected?: (helloMessage: APIHello) => void; onError?: (error: APIError) => void; - constructor( - readonly token: string, - readonly server = DEFAULT_SERVER, - ) { - this.socket = this.createSocket(token, server); - this.connected = this.connectSocket(this.socket); - } - - private createSocket(token: string, server: string) { - const { sha, version } = readVersion(); - return new WebSocket(server, { - headers: { - Authorization: `Bearer ${token}`, - 'User-Agent': `wokwi-cli/${version} (${sha})`, - }, - }); - } - - private async connectSocket(socket: WebSocket) { - await new Promise((resolve, reject) => { - socket.addEventListener('message', ({ data }) => { - if (typeof data === 'string') { - const message = JSON.parse(data); - this.processMessage(message); - } else { - console.error('Unsupported binary message'); - } - }); - this.socket.addEventListener('open', resolve); - this.socket.on('unexpected-response', (req, res) => { - this.closed = true; - this.socket.close(); - const RequestTimeout = 408; - const ServiceUnavailable = 503; - const CfRequestTimeout = 524; - if ( - res.statusCode === ServiceUnavailable || - res.statusCode === RequestTimeout || - res.statusCode === CfRequestTimeout - ) { - console.warn( - `Connection to ${this.server} failed: ${res.statusMessage ?? ''} (${res.statusCode}).`, - ); - resolve(this.retryConnection()); - } else { - reject( - new Error( - `Error connecting to ${this.server}: ${res.statusCode} ${res.statusMessage ?? ''}`, - ), - ); - } - }); - this.socket.addEventListener('error', (event) => { - reject(new Error(`Error connecting to ${this.server}: ${event.message}`)); - }); - this.socket.addEventListener('close', (event) => { - if (this.closed) { - return; - } - - const message = `Connection to ${this.server} closed unexpectedly: code ${event.code}`; - if (this.onError) { - this.onError({ type: 'error', message }); - } else { - console.error(message); - } - }); - }); - } - - private async retryConnection() { - const delay = retryDelays[this.connectionAttempts++]; - if (delay == null) { - throw new Error(`Failed to connect to ${this.server}. Giving up.`); - } - - console.log(`Will retry in ${delay}ms...`); - - await new Promise((resolve) => setTimeout(resolve, delay)); + constructor(private readonly transport: ITransport) { + this.transport.onMessage = (message) => { + this.processMessage(message); + }; + this.transport.onClose = (code, reason) => { + this.handleTransportClose(code, reason); + }; + this.transport.onError = (error) => { + this.handleTransportError(error); + }; - console.log(`Retrying connection to ${this.server}...`); - this.socket = this.createSocket(this.token, this.server); - this.closed = false; - await this.connectSocket(this.socket); + // Initiate connection + this.connected = this.transport.connect(); } - async fileUpload(name: string, content: string | ArrayBuffer) { + async fileUpload(name: string, content: string | Uint8Array) { if (typeof content === 'string') { return await this.sendCommand('file:upload', { name, text: content }); } else { return await this.sendCommand('file:upload', { name, - binary: Buffer.from(content).toString('base64'), + binary: byteArrayToBase64(content), }); } } + async fileDownload(name: string): Promise { + const result = await this.sendCommand<{ text?: string; binary?: string }>('file:download', { + name, + }); + if (typeof result.text === 'string') { + return result.text; + } else if (typeof result.binary === 'string') { + return base64ToByteArray(result.binary); + } else { + throw new Error('Invalid file download response'); + } + } + async simStart(params: APISimStartParams) { this._running = false; return await this.sendCommand('sim:start', params); @@ -174,19 +110,6 @@ export class APIClient { }); } - serialMonitorWritable() { - return new Writable({ - write: (chunk, encoding, callback) => { - if (typeof chunk === 'string') { - chunk = Buffer.from(chunk, encoding); - } - this.serialMonitorWrite(chunk).then(() => { - callback(null); - }, callback); - }, - }); - } - async framebufferRead(partId: string) { return await this.sendCommand<{ png: string }>('framebuffer:read', { id: partId, @@ -258,7 +181,7 @@ export class APIClient { params, id: id.toString(), }; - this.socket.send(JSON.stringify(message)); + this.transport.send(message); }); } @@ -278,8 +201,11 @@ export class APIClient { } console.error('API Error:', message.message); if (this.pendingCommands.size > 0) { - const [, reject] = this.pendingCommands.values().next().value; - reject(new Error(message.message)); + const entry = this.pendingCommands.values().next().value; + if (entry) { + const [, reject] = entry; + reject(new Error(message.message)); + } } break; @@ -346,8 +272,17 @@ export class APIClient { close() { this.closed = true; - if (this.socket.readyState === WebSocket.OPEN) { - this.socket.close(); - } + this.transport.close(); + } + + private handleTransportClose(code: number, reason?: string) { + if (this.closed) return; + const target = (this as any).server ?? 'transport'; + const msg = `Connection to ${target} closed unexpectedly: code ${code}${reason ? ` (${reason})` : ''}`; + this.onError?.({ type: 'error', message: msg }); + } + + private handleTransportError(error: Error) { + this.onError?.({ type: 'error', message: error.message }); } } diff --git a/packages/wokwi-cli/src/APITypes.ts b/packages/wokwi-client-js/src/APITypes.ts similarity index 98% rename from packages/wokwi-cli/src/APITypes.ts rename to packages/wokwi-client-js/src/APITypes.ts index 23b5f3f..4e63fbb 100644 --- a/packages/wokwi-cli/src/APITypes.ts +++ b/packages/wokwi-client-js/src/APITypes.ts @@ -57,4 +57,5 @@ export interface APISimStartParams { export interface PinReadResponse { pin: string; value: number; + voltage: number; } diff --git a/packages/wokwi-cli/src/PausePoint.ts b/packages/wokwi-client-js/src/PausePoint.ts similarity index 100% rename from packages/wokwi-cli/src/PausePoint.ts rename to packages/wokwi-client-js/src/PausePoint.ts diff --git a/packages/wokwi-client-js/src/base64.ts b/packages/wokwi-client-js/src/base64.ts new file mode 100644 index 0000000..0eec454 --- /dev/null +++ b/packages/wokwi-client-js/src/base64.ts @@ -0,0 +1,40 @@ +const b64dict = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +export function base64ToByteArray(base64str: string): Uint8Array { + if (typeof Buffer !== 'undefined') { + // Node.js + return Uint8Array.from(Buffer.from(base64str, 'base64')); + } else { + // Browser + const binaryString = globalThis.atob(base64str); + return Uint8Array.from(binaryString, (c) => c.charCodeAt(0)); + } +} + +export function byteArrayToBase64(bytes: Uint8Array): string { + if (typeof Buffer !== 'undefined') { + // Node.js + return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64'); + } else { + // Browser: manual base64 encoding + let result = ''; + for (let i = 0; i < bytes.length - 2; i += 3) { + result += b64dict[bytes[i] >> 2]; + result += b64dict[((bytes[i] & 0x03) << 4) | (bytes[i + 1] >> 4)]; + result += b64dict[((bytes[i + 1] & 0x0f) << 2) | (bytes[i + 2] >> 6)]; + result += b64dict[bytes[i + 2] & 0x3f]; + } + if (bytes.length % 3 === 1) { + result += b64dict[bytes[bytes.length - 1] >> 2]; + result += b64dict[(bytes[bytes.length - 1] & 0x03) << 4]; + result += '=='; + } + if (bytes.length % 3 === 2) { + result += b64dict[bytes[bytes.length - 2] >> 2]; + result += b64dict[((bytes[bytes.length - 2] & 0x03) << 4) | (bytes[bytes.length - 1] >> 4)]; + result += b64dict[(bytes[bytes.length - 1] & 0x0f) << 2]; + result += '='; + } + return result; + } +} diff --git a/packages/wokwi-client-js/src/index.ts b/packages/wokwi-client-js/src/index.ts new file mode 100644 index 0000000..86f99a8 --- /dev/null +++ b/packages/wokwi-client-js/src/index.ts @@ -0,0 +1,32 @@ +// Main API Client +export { APIClient } from './APIClient.js'; + +// Transport interfaces and implementations +export { type ITransport } from './transport/ITransport.js'; +export { MessagePortTransport } from './transport/MessagePortTransport.js'; + +// Pause Point +export { PausePoint } from './PausePoint.js'; +export type { + PausePointType, + PausePointParams, + ITimePausePoint, + ISerialBytesPausePoint, +} from './PausePoint.js'; + +// API Types +export type { + APIError, + APIHello, + APICommand, + APIResponse, + APIEvent, + APIResultError, + APISimStartParams, + SerialMonitorDataPayload, + ChipsLogPayload, + PinReadResponse, +} from './APITypes.js'; + +// Utilities +export { base64ToByteArray, byteArrayToBase64 } from './base64.js'; diff --git a/packages/wokwi-client-js/src/transport/ITransport.ts b/packages/wokwi-client-js/src/transport/ITransport.ts new file mode 100644 index 0000000..4fde9fd --- /dev/null +++ b/packages/wokwi-client-js/src/transport/ITransport.ts @@ -0,0 +1,15 @@ +export interface ITransport { + /** Callback to handle incoming messages (parsed as objects) */ + onMessage: (message: any) => void; + /** Optional callback for transport closure events */ + onClose?: (code: number, reason?: string) => void; + /** Optional callback for transport-level errors */ + onError?: (error: Error) => void; + + /** Send a message through the transport */ + send(message: any): void; + /** Establish the connection (if needed) */ + connect(): Promise; + /** Close the transport */ + close(): void; +} diff --git a/packages/wokwi-client-js/src/transport/MessagePortTransport.ts b/packages/wokwi-client-js/src/transport/MessagePortTransport.ts new file mode 100644 index 0000000..1903641 --- /dev/null +++ b/packages/wokwi-client-js/src/transport/MessagePortTransport.ts @@ -0,0 +1,37 @@ +import { ITransport } from './ITransport.js'; + +/** + * Transport for communicating with a Wokwi Simulator over a MessagePort. + * This can be used in the browser to communicate with the Wokwi Simulator in iframe mode. + */ +export class MessagePortTransport implements ITransport { + public onMessage: (message: any) => void = () => {}; + public onClose?: (code: number, reason?: string) => void; + public onError?: (error: Error) => void; + + private readonly port: MessagePort; + + constructor(port: MessagePort) { + this.port = port; + this.port.onmessage = (event) => { + this.onMessage(event.data); + }; + this.port.start(); + } + + async connect(): Promise { + // MessagePort is ready to use immediately; no handshake needed + } + + send(message: any): void { + this.port.postMessage(message); + } + + close(): void { + try { + this.port.close(); + } catch { + // Ignore errors when closing port + } + } +} diff --git a/packages/wokwi-client-js/tools/bundle-browser.js b/packages/wokwi-client-js/tools/bundle-browser.js new file mode 100644 index 0000000..ba5a825 --- /dev/null +++ b/packages/wokwi-client-js/tools/bundle-browser.js @@ -0,0 +1,31 @@ +import { build } from 'esbuild'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const rootDir = join(__dirname, '..'); + +const options = { + entryPoints: [join(rootDir, 'src/index.ts')], + outfile: join(rootDir, 'dist/wokwi-client-js.browser.js'), + bundle: true, + platform: 'browser', + format: 'esm', + target: 'es2020', + banner: { + js: `// Browser bundle of wokwi-client-js + // Use MessagePortTransport for browser communication with Wokwi Simulator +`, + }, +}; + +// Build the browser bundle +build(options) + .then(() => { + console.log('✓ Browser bundle created: dist/wokwi-client-js.browser.js'); + }) + .catch((error) => { + console.error('✗ Browser bundle failed:', error); + process.exit(1); + }); diff --git a/packages/wokwi-client-js/tsconfig.json b/packages/wokwi-client-js/tsconfig.json new file mode 100644 index 0000000..7d96d43 --- /dev/null +++ b/packages/wokwi-client-js/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "node_modules"] +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..112c638 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './test', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + webServer: { + command: 'npx --yes http-server . -p 8000 -c-1', + url: 'http://127.0.0.1:8000', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fd4032..2e88176 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,47 +7,19 @@ settings: importers: .: - dependencies: - '@clack/prompts': - specifier: ^0.7.0 - version: 0.7.0 - '@iarna/toml': - specifier: 2.2.5 - version: 2.2.5 - '@modelcontextprotocol/sdk': - specifier: ^1.0.0 - version: 1.20.2 - arg: - specifier: ^5.0.2 - version: 5.0.2 - chalk: - specifier: ^5.3.0 - version: 5.6.2 - chalk-template: - specifier: ^1.1.0 - version: 1.1.2 - pngjs: - specifier: ^7.0.0 - version: 7.0.0 - ws: - specifier: ^8.13.0 - version: 8.18.3 - yaml: - specifier: ^2.3.1 - version: 2.8.1 devDependencies: '@eslint/js': specifier: ^9.39.1 version: 9.39.1 + '@playwright/test': + specifier: ^1.48.0 + version: 1.56.1 esbuild: specifier: ^0.25.2 version: 0.25.11 eslint: specifier: ^9.39.1 version: 9.39.1(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.1(jiti@2.6.1)) husky: specifier: ^8.0.0 version: 8.0.3 @@ -58,7 +30,7 @@ importers: specifier: ^15.4.3 version: 15.5.2 prettier: - specifier: ^3.5.0 + specifier: ^3.6.2 version: 3.6.2 rimraf: specifier: ^5.0.0 @@ -99,6 +71,9 @@ importers: pngjs: specifier: ^7.0.0 version: 7.0.0 + wokwi-client-js: + specifier: workspace:* + version: link:../wokwi-client-js ws: specifier: ^8.13.0 version: 8.18.3 @@ -140,6 +115,25 @@ importers: specifier: ^3.0.5 version: 3.2.4(@types/node@24.9.2)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1) + packages/wokwi-client-js: + dependencies: + ws: + specifier: ^8.13.0 + version: 8.18.3 + devDependencies: + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + esbuild: + specifier: ^0.25.2 + version: 0.25.11 + rimraf: + specifier: ^5.0.0 + version: 5.0.10 + typescript: + specifier: ^5.2.2 + version: 5.9.3 + packages: '@babel/generator@7.28.5': @@ -421,6 +415,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.56.1': + resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} + engines: {node: '>=18'} + hasBin: true + '@rollup/rollup-android-arm-eabi@4.52.5': resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==} cpu: [arm] @@ -916,12 +915,6 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-prettier@10.1.8: - resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' - eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1070,6 +1063,11 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1503,6 +1501,16 @@ packages: resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} engines: {node: '>=16.20.0'} + playwright-core@1.56.1: + resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.56.1: + resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} + engines: {node: '>=18'} + hasBin: true + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -2225,6 +2233,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.56.1': + dependencies: + playwright: 1.56.1 + '@rollup/rollup-android-arm-eabi@4.52.5': optional: true @@ -2743,10 +2755,6 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)): - dependencies: - eslint: 9.39.1(jiti@2.6.1) - eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -2950,6 +2958,9 @@ snapshots: fs-constants@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -3318,6 +3329,14 @@ snapshots: pkce-challenge@5.0.0: {} + playwright-core@1.56.1: {} + + playwright@1.56.1: + dependencies: + playwright-core: 1.56.1 + optionalDependencies: + fsevents: 2.3.2 + pngjs@7.0.0: {} postcss@8.5.6: diff --git a/scripts/test-cli-integration.ts b/scripts/test-cli-integration.ts new file mode 100644 index 0000000..8910b58 --- /dev/null +++ b/scripts/test-cli-integration.ts @@ -0,0 +1,66 @@ +import { execSync } from 'child_process'; +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import path from 'node:path'; + +const TEST_PROJECT_DIR = 'test-project'; +const TEST_REPO = 'https://github.com/wokwi/esp-idf-hello-world.git'; + +function cloneWithGit(dir: string) { + console.log('Cloning test project with git...'); + execSync(`git clone ${TEST_REPO} ${dir}`, { stdio: 'inherit' }); +} + +async function ensureTestProject() { + if (fsSync.existsSync(TEST_PROJECT_DIR)) { + console.log('Test project already exists, skipping clone/download...'); + return; + } + + try { + cloneWithGit(TEST_PROJECT_DIR); + return; + } catch (err) { + console.log('git clone failed:', (err as Error).message); + throw new Error('git clone failed; git is required to run integration tests'); + } +} + +async function createScenarioFile() { + const content = `name: "Basic Hello World Test"\nversion: 1\ndescription: "Test that the ESP32 hello world program outputs expected text"\n\nsteps:\n - name: "Wait for boot and hello message"\n wait-serial: "Hello world!"\n\n - name: "Wait for chip information"\n wait-serial: "This is esp32 chip"\n\n - name: "Wait for restart message"\n wait-serial: "Restarting in 10 seconds"\n`; + await fs.writeFile(path.join(TEST_PROJECT_DIR, 'test-scenario.yaml'), content, 'utf8'); + console.log('Test scenario file created.'); +} + +async function main() { + try { + await ensureTestProject(); + await createScenarioFile(); + + if (!process.env.WOKWI_CLI_TOKEN) { + console.error('Warning: WOKWI_CLI_TOKEN environment variable is not set.'); + console.error('Integration tests require a Wokwi API token to run.'); + console.error('Set WOKWI_CLI_TOKEN environment variable to run these tests.'); + process.exit(1); + } + + console.log('Running CLI integration tests...'); + + console.log('Test 1: Basic expect-text test'); + execSync(`pnpm cli ${TEST_PROJECT_DIR} --timeout 5000 --expect-text Hello`, { + stdio: 'inherit', + }); + + console.log('Test 2: Scenario file test'); + execSync(`pnpm cli ${TEST_PROJECT_DIR} --scenario test-scenario.yaml --timeout 15000`, { + stdio: 'inherit', + }); + + console.log('All CLI integration tests passed!'); + } catch (err) { + console.error('Integration tests failed:', (err as Error).message); + process.exit(1); + } +} + +await main(); diff --git a/test/wokwi-embed/index.html b/test/wokwi-embed/index.html new file mode 100644 index 0000000..d3384aa --- /dev/null +++ b/test/wokwi-embed/index.html @@ -0,0 +1,30 @@ + + + + + + MicroPython + ESP32 on Wokwi + + + +

MicroPython + ESP32 on Wokwi

+
+ +
+ +

Serial Monitor Output

+
+ + + diff --git a/test/wokwi-embed/script.js b/test/wokwi-embed/script.js new file mode 100644 index 0000000..1101b00 --- /dev/null +++ b/test/wokwi-embed/script.js @@ -0,0 +1,66 @@ +import { MessagePortTransport, APIClient } from 'wokwi-client-js'; + +const diagram = `{ + "version": 1, + "author": "Uri Shaked", + "editor": "wokwi", + "parts": [ + { + "type": "board-esp32-devkit-c-v4", + "id": "esp", + "top": 0, + "left": 0, + "attrs": { "env": "micropython-20231227-v1.22.0" } + } + ], + "connections": [ [ "esp:TX", "$serialMonitor:RX", "", [] ], [ "esp:RX", "$serialMonitor:TX", "", [] ] ], + "dependencies": {} +}`; + +const microPythonCode = ` +import time +while True: + print(f"Hello, World {time.time()}") + time.sleep(1) +`; + +const outputText = document.getElementById('output-text'); + +window.addEventListener('message', async (event) => { + if (!event.data.port) { + return; + } + + const client = new APIClient(new MessagePortTransport(event.data.port)); + + // Wait for connection + await client.connected; + console.log('Wokwi client connected'); + + // Set up event listeners + client.listen('serial-monitor:data', (event) => { + const rawBytes = new Uint8Array(event.payload.bytes); + outputText.textContent += new TextDecoder().decode(rawBytes); + }); + + // Initialize simulation + try { + await client.serialMonitorListen(); + await client.fileUpload('main.py', microPythonCode); + await client.fileUpload('diagram.json', diagram); + } catch (error) { + console.error('Error initializing simulation:', error); + } + + document.querySelector('.start-button').addEventListener('click', async () => { + try { + await client.simStart({ + firmware: 'main.py', + elf: 'main.py', + }); + } catch (error) { + console.error('Error starting simulation:', error); + } + }); +}); +console.log('Wokwi ESP32 MicroPython script loaded'); diff --git a/test/wokwi-embed/wokwi-embed.spec.ts b/test/wokwi-embed/wokwi-embed.spec.ts new file mode 100644 index 0000000..35d0b0b --- /dev/null +++ b/test/wokwi-embed/wokwi-embed.spec.ts @@ -0,0 +1,20 @@ +import { test, expect, type Page } from '@playwright/test'; + +test.describe('Wokwi Embed', () => { + test('should start simulation and wait for output', async ({ page }: { page: Page }) => { + await page.goto('http://127.0.0.1:8000/test/wokwi-embed/'); + + // Wait 3 seconds after page load + await page.waitForTimeout(3000); + + // Click the start button + const startButton = page.locator('.start-button'); + await startButton.click(); + + // Wait until output-text contains the expected messages + const outputText = page.locator('#output-text'); + await expect(outputText).toContainText('Hello, World 2', { timeout: 60000 }); + await expect(outputText).toContainText('Hello, World 3', { timeout: 60000 }); + await expect(outputText).toContainText('Hello, World 4', { timeout: 60000 }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index be80751..001a1d7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,9 +2,12 @@ "extends": "./tsconfig.base.json", "include": [ "eslint.config.ts", + "playwright.config.ts", + "vitest.config.ts", "./test/**/*.test.ts", "./test/**/*.ts", - "./packages/*/src/**/*.ts" + "./packages/*/src/**/*.ts", + "./scripts/**/*.ts" ], "exclude": [ "node_modules", diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..cdc0391 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/playwright-report/**', + '**/test-results/**', + '**/test/wokwi-embed/**', // Exclude Playwright tests + ], + }, +}); +