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/test.yml b/.github/workflows/test.yml index 49c1cac..24986af 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,15 @@ 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: 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..4ea6eee 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ wokwi.toml *.bin diagram.json screenshot.png +playwright-report/ +test-results/ +test-project/ diff --git a/eslint.config.ts b/eslint.config.ts index 7480f37..13482f9 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -18,6 +18,8 @@ export default defineConfig( '**/.git/**', '**/coverage/**', '**/*.min.js', + '**/playwright-report/**', + '**/test-results/**', ], }, diff --git a/package.json b/package.json index 2281752..0710a0a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,11 @@ "clean": "pnpm -r run clean", "lint": "eslint .", "lint:fix": "eslint . --fix", - "test": "npm run lint && vitest --run", + "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": "bash scripts/test-cli-integration.sh", "cli": "tsx packages/wokwi-cli/src/main.ts", "prepare": "husky install" }, @@ -19,19 +23,9 @@ "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", 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..cf20405 --- /dev/null +++ b/packages/wokwi-cli/src/constants.ts @@ -0,0 +1,2 @@ +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..dbe9dca 100644 --- a/packages/wokwi-cli/src/main.ts +++ b/packages/wokwi-cli/src/main.ts @@ -3,8 +3,9 @@ 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 { ExpectEngine } from './ExpectEngine.js'; import { SimulationTimeoutError } from './SimulationTimeoutError.js'; import { TestScenario } from './TestScenario.js'; @@ -22,41 +23,42 @@ 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; function printVersion(short = false) { const { sha, version } = readVersion(); if (short) { - console.log(`${version} (${sha})`); + console.log(`${version} (${sha})`); } else { - console.log(`Wokwi CLI v${version} (${sha})`); + console.log(`Wokwi CLI v${version} (${sha})`); } } async function main() { const args = arg( - { - '--help': Boolean, - '--quiet': Boolean, - '--version': Boolean, - '--short-version': Boolean, - '--diagram-file': String, - '--elf': String, - '--expect-text': String, - '--fail-text': String, - '--interactive': Boolean, - '--serial-log-file': String, - '--scenario': String, - '--screenshot-part': String, - '--screenshot-file': String, - '--screenshot-time': Number, - '--timeout': Number, - '--timeout-exit-code': Number, - '-h': '--help', - '-q': '--quiet', - }, - { argv: process.argv.slice(2) }, + { + '--help': Boolean, + '--quiet': Boolean, + '--version': Boolean, + '--short-version': Boolean, + '--diagram-file': String, + '--elf': String, + '--expect-text': String, + '--fail-text': String, + '--interactive': Boolean, + '--serial-log-file': String, + '--scenario': String, + '--screenshot-part': String, + '--screenshot-file': String, + '--screenshot-time': Number, + '--timeout': Number, + '--timeout-exit-code': Number, + '-h': '--help', + '-q': '--quiet', + }, + { argv: process.argv.slice(2) }, ); const quiet = args['--quiet']; @@ -75,51 +77,51 @@ async function main() { const timeoutNanos = timeout * millis; if (args['--version'] === true || args['--short-version'] === true) { - printVersion(args['--short-version']); - process.exit(0); + printVersion(args['--short-version']); + process.exit(0); } if (!quiet) { - printVersion(); + printVersion(); } if (args['--help']) { - cliHelp(); - process.exit(0); + cliHelp(); + process.exit(0); } if (args._[0] === 'init') { - await initProjectWizard(args._[1] ?? '.', { diagramFile }); - process.exit(0); + await initProjectWizard(args._[1] ?? '.', { diagramFile }); + process.exit(0); } const token = process.env.WOKWI_CLI_TOKEN; if (token == null || token.length === 0) { - console.error( - chalkTemplate`{red Error:} Missing {yellow WOKWI_CLI_TOKEN} environment variable. Please set it to your Wokwi token.\nGet your token at {yellow https://wokwi.com/dashboard/ci}.`, - ); - process.exit(1); + console.error( + chalkTemplate`{red Error:} Missing {yellow WOKWI_CLI_TOKEN} environment variable. Please set it to your Wokwi token.\nGet your token at {yellow https://wokwi.com/dashboard/ci}.`, + ); + process.exit(1); } if (args._[0] === 'mcp') { - const rootDir = args._[1] || '.'; + const rootDir = args._[1] || '.'; - const mcpServer = new WokwiMCPServer({ rootDir, token, quiet }); + const mcpServer = new WokwiMCPServer({ rootDir, token, quiet }); - process.on('SIGINT', () => { - void mcpServer.stop().then(() => { - process.exit(0); - }); - }); + process.on('SIGINT', () => { + void mcpServer.stop().then(() => { + process.exit(0); + }); + }); - process.on('SIGTERM', () => { - void mcpServer.stop().then(() => { - process.exit(0); - }); - }); + process.on('SIGTERM', () => { + void mcpServer.stop().then(() => { + process.exit(0); + }); + }); - await mcpServer.start(); - return; + await mcpServer.start(); + return; } const rootDir = args._[0] || '.'; @@ -128,51 +130,51 @@ async function main() { const espIdfFlasherArgsPath = path.resolve(rootDir, 'build/flasher_args.json'); const espIdfProjectDescriptionPath = path.resolve(rootDir, 'build/project_description.json'); const isIDFProject = - existsSync(espIdfFlasherArgsPath) && existsSync(espIdfProjectDescriptionPath); + existsSync(espIdfFlasherArgsPath) && existsSync(espIdfProjectDescriptionPath); let configExists = existsSync(configPath); let diagramExists = existsSync(diagramFilePath); if (isIDFProject) { - if (!quiet) { - console.log(`Detected IDF project in ${rootDir}`); - } - if ( - !idfProjectConfig({ - rootDir, - configPath, - diagramFilePath, - projectDescriptionPath: espIdfProjectDescriptionPath, - createConfig: !configExists, - createDiagram: !diagramExists, - quiet, - }) - ) { - process.exit(1); - } - configExists = true; - diagramExists = true; + if (!quiet) { + console.log(`Detected IDF project in ${rootDir}`); + } + if ( + !idfProjectConfig({ + rootDir, + configPath, + diagramFilePath, + projectDescriptionPath: espIdfProjectDescriptionPath, + createConfig: !configExists, + createDiagram: !diagramExists, + quiet, + }) + ) { + process.exit(1); + } + configExists = true; + diagramExists = true; } if (!elf && !configExists) { - console.error( - chalkTemplate`{red Error:} {yellow wokwi.toml} not found in {yellow ${path.resolve( - rootDir, - )}}.`, - ); - console.error( - chalkTemplate`Run \`{green wokwi-cli init}\` to automatically create a {yellow wokwi.toml} file.`, - ); - process.exit(1); + console.error( + chalkTemplate`{red Error:} {yellow wokwi.toml} not found in {yellow ${path.resolve( + rootDir, + )}}.`, + ); + console.error( + chalkTemplate`Run \`{green wokwi-cli init}\` to automatically create a {yellow wokwi.toml} file.`, + ); + process.exit(1); } if (!existsSync(diagramFilePath)) { - console.error( - chalkTemplate`{red Error:} {yellow diagram.json} not found in {yellow ${diagramFilePath}}.`, - ); - console.error( - chalkTemplate`Run \`{green wokwi-cli init}\` to automatically create a {yellow diagram.json} file.`, - ); - process.exit(1); + console.error( + chalkTemplate`{red Error:} {yellow diagram.json} not found in {yellow ${diagramFilePath}}.`, + ); + console.error( + chalkTemplate`Run \`{green wokwi-cli init}\` to automatically create a {yellow diagram.json} file.`, + ); + process.exit(1); } let firmwarePath; @@ -180,37 +182,37 @@ async function main() { let config; if (configExists) { - const configData = readFileSync(configPath, 'utf8'); - config = await parseConfig(configData, rootDir); + const configData = readFileSync(configPath, 'utf8'); + config = await parseConfig(configData, rootDir); - firmwarePath = elf ?? join(rootDir, config.wokwi.firmware); - const configElfPath = config.wokwi.elf ? join(rootDir, config.wokwi.elf) : undefined; - elfPath = elf ?? configElfPath; + firmwarePath = elf ?? join(rootDir, config.wokwi.firmware); + const configElfPath = config.wokwi.elf ? join(rootDir, config.wokwi.elf) : undefined; + elfPath = elf ?? configElfPath; } else if (elf) { - firmwarePath = elf; - elfPath = elf; + firmwarePath = elf; + elfPath = elf; } else { - throw new Error('Internal error: neither elf nor config exists'); + throw new Error('Internal error: neither elf nor config exists'); } if (!existsSync(firmwarePath)) { - const fullPath = path.resolve(firmwarePath); - console.error( - chalkTemplate`{red Error:} {yellow firmware file} not found: {yellow ${fullPath}}.`, - ); - console.error( - chalkTemplate`Please check the {yellow firmware} path in your {yellow wokwi.toml} configuration file.`, - ); - process.exit(1); + const fullPath = path.resolve(firmwarePath); + console.error( + chalkTemplate`{red Error:} {yellow firmware file} not found: {yellow ${fullPath}}.`, + ); + console.error( + chalkTemplate`Please check the {yellow firmware} path in your {yellow wokwi.toml} configuration file.`, + ); + process.exit(1); } if (elfPath != null && !existsSync(elfPath)) { - const fullPath = path.resolve(elfPath); - console.error(chalkTemplate`{red Error:} ELF file not found: {yellow ${fullPath}}.`); - console.error( - chalkTemplate`Please check the {yellow elf} path in your {yellow wokwi.toml} configuration file.`, - ); - process.exit(1); + const fullPath = path.resolve(elfPath); + console.error(chalkTemplate`{red Error:} ELF file not found: {yellow ${fullPath}}.`); + console.error( + chalkTemplate`Please check the {yellow elf} path in your {yellow wokwi.toml} configuration file.`, + ); + process.exit(1); } const diagram = readFileSync(diagramFilePath, 'utf8'); @@ -219,172 +221,173 @@ async function main() { const resolvedScenarioFile = scenarioFile ? path.resolve(rootDir, scenarioFile) : null; if (resolvedScenarioFile && !existsSync(resolvedScenarioFile)) { - const fullPath = path.resolve(resolvedScenarioFile); - console.error(chalkTemplate`{red Error:} scenario file not found: {yellow ${fullPath}}.`); - process.exit(1); + const fullPath = path.resolve(resolvedScenarioFile); + console.error(chalkTemplate`{red Error:} scenario file not found: {yellow ${fullPath}}.`); + process.exit(1); } const expectEngine = new ExpectEngine(); let scenario: TestScenario | undefined; if (resolvedScenarioFile) { - scenario = new TestScenario(YAML.parse(readFileSync(resolvedScenarioFile, 'utf-8'))); - scenario.registerCommands({ - delay: new DelayCommand(), - 'expect-pin': new ExpectPinCommand(), - 'set-control': new SetControlCommand(), - 'wait-serial': new WaitSerialCommand(), - 'write-serial': new WriteSerialCommand(), - 'take-screenshot': new TakeScreenshotCommand(path.dirname(resolvedScenarioFile)), - }); - scenario.validate(); + scenario = new TestScenario(YAML.parse(readFileSync(resolvedScenarioFile, 'utf-8'))); + scenario.registerCommands({ + delay: new DelayCommand(), + 'expect-pin': new ExpectPinCommand(), + 'set-control': new SetControlCommand(), + 'wait-serial': new WaitSerialCommand(), + 'write-serial': new WriteSerialCommand(), + 'take-screenshot': new TakeScreenshotCommand(path.dirname(resolvedScenarioFile)), + }); + scenario.validate(); } const serialLogStream = serialLogFile ? createWriteStream(serialLogFile) : null; if (expectText) { - expectEngine.expectTexts.push(expectText); - expectEngine.on('match', (text) => { - if (text !== expectText) { - return; - } - - if (!quiet) { - console.log(chalkTemplate`\n\nExpected text found: {green "${expectText}"}`); - console.log('TEST PASSED.'); - } - client.close(); - process.exit(0); - }); + expectEngine.expectTexts.push(expectText); + expectEngine.on('match', (text) => { + if (text !== expectText) { + return; + } + + if (!quiet) { + console.log(chalkTemplate`\n\nExpected text found: {green "${expectText}"}`); + console.log('TEST PASSED.'); + } + client.close(); + process.exit(0); + }); } if (failText) { - expectEngine.failTexts.push(failText); - expectEngine.on('fail', (text) => { - if (text !== failText) { - return; - } - - console.error(chalkTemplate`\n\n{red Error:} Unexpected text found: {yellow "${text}"}`); - console.error('TEST FAILED.'); - client.close(); - process.exit(1); - }); + expectEngine.failTexts.push(failText); + expectEngine.on('fail', (text) => { + if (text !== failText) { + return; + } + + console.error(chalkTemplate`\n\n{red Error:} Unexpected text found: {yellow "${text}"}`); + console.error('TEST FAILED.'); + client.close(); + process.exit(1); + }); } - - 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}`); - } + if (!quiet) { + console.log(`Connected to Wokwi Simulation API ${hello.appVersion}`); + } }; client.onError = (error) => { - console.error('API Error:', error.message); - process.exit(1); + console.error('API Error:', error.message); + process.exit(1); }; try { - await client.connected; - await client.fileUpload('diagram.json', diagram); - const firmwareName = await uploadFirmware(client, firmwarePath); - if (elfPath != null) { - await client.fileUpload('firmware.elf', 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)); - } - - const promises = []; - let screenshotPromise = Promise.resolve(); - - if (screenshotPart != null && screenshotTime != null) { - if (timeout && screenshotTime > timeout) { - console.error( - chalkTemplate`{red Error:} Screenshot time (${screenshotTime}ms) can't be greater than the timeout (${timeout}ms).`, - ); - process.exit(1); - } - screenshotPromise = client.atNanos(screenshotTime * millis).then(async () => { - try { - const result = await client.framebufferRead(screenshotPart); - writeFileSync(screenshotFile, result.png, 'base64'); - await client.simResume(); - } catch (err) { - console.error('Error taking screenshot:', (err as Error).toString()); - throw err; - } - }); - } - - if (!quiet) { - console.log('Starting simulation...'); - } - - await client.serialMonitorListen(); - - client.listen('serial-monitor:data', (event: APIEvent) => { - let { bytes } = event.payload; - bytes = scenario?.processSerialBytes(bytes) ?? bytes; - process.stdout.write(new Uint8Array(bytes)); - - serialLogStream?.write(Buffer.from(bytes)); - expectEngine.feed(bytes); - }); - - client.listen('chips:log', (event: APIEvent) => { - const { message, chip } = event.payload; - console.log(chalkTemplate`[{magenta ${chip}}] ${message}`); - }); - - if (timeoutNanos) { - promises.push( - client.atNanos(timeoutNanos).then(async () => { - await screenshotPromise; // wait for the screenshot to be saved, if any - console.error(chalkTemplate`\n{red Timeout}: simulation did not finish in ${timeout}ms`); - throw new SimulationTimeoutError( - timeoutExitCode, - `simulation did not finish in ${timeout}ms`, - ); - }), - ); - } - - await client.simStart({ - firmware: firmwareName, - elf: elfPath != null ? 'firmware.elf' : undefined, - chips: chips.map((chip) => chip.name), - pause: scenario != null, - }); - - if (interactive) { - process.stdin.pipe(client.serialMonitorWritable()); - } - - if (scenario != null) { - promises.push(scenario.start(client)); - } else { - await client.simResume(); - } - - if (promises.length === 0) { - // wait forever - await new Promise(() => {}); - } - - // wait until the scenario finishes or a timeout occurs - await Promise.race(promises); - // wait for the screenshot to be saved, if any - await screenshotPromise; + await client.connected; + await client.fileUpload('diagram.json', diagram); + const firmwareName = await uploadFirmware(client, firmwarePath); + if (elfPath != null) { + 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`, new Uint8Array(readFileSync(chip.wasmPath))); + } + + const promises = []; + let screenshotPromise = Promise.resolve(); + + if (screenshotPart != null && screenshotTime != null) { + if (timeout && screenshotTime > timeout) { + console.error( + chalkTemplate`{red Error:} Screenshot time (${screenshotTime}ms) can't be greater than the timeout (${timeout}ms).`, + ); + process.exit(1); + } + screenshotPromise = client.atNanos(screenshotTime * millis).then(async () => { + try { + const result = await client.framebufferRead(screenshotPart); + writeFileSync(screenshotFile, result.png, 'base64'); + await client.simResume(); + } catch (err) { + console.error('Error taking screenshot:', (err as Error).toString()); + throw err; + } + }); + } + + if (!quiet) { + console.log('Starting simulation...'); + } + + await client.serialMonitorListen(); + + client.listen('serial-monitor:data', (event: APIEvent) => { + let { bytes } = event.payload; + bytes = scenario?.processSerialBytes(bytes) ?? bytes; + process.stdout.write(new Uint8Array(bytes)); + + serialLogStream?.write(Buffer.from(bytes)); + expectEngine.feed(bytes); + }); + + client.listen('chips:log', (event: APIEvent) => { + const { message, chip } = event.payload; + console.log(chalkTemplate`[{magenta ${chip}}] ${message}`); + }); + + if (timeoutNanos) { + promises.push( + client.atNanos(timeoutNanos).then(async () => { + await screenshotPromise; // wait for the screenshot to be saved, if any + console.error(chalkTemplate`\n{red Timeout}: simulation did not finish in ${timeout}ms`); + throw new SimulationTimeoutError( + timeoutExitCode, + `simulation did not finish in ${timeout}ms`, + ); + }), + ); + } + + await client.simStart({ + firmware: firmwareName, + elf: elfPath != null ? 'firmware.elf' : undefined, + chips: chips.map((chip) => chip.name), + pause: scenario != null, + }); + + if (interactive) { + process.stdin.pipe(await client.serialMonitorWritable()); + } + + if (scenario != null) { + promises.push(scenario.start(client)); + } else { + await client.simResume(); + } + + if (promises.length === 0) { + // wait forever + await new Promise(() => {}); + } + + // wait until the scenario finishes or a timeout occurs + await Promise.race(promises); + // wait for the screenshot to be saved, if any + await screenshotPromise; } finally { - client.close(); + client.close(); } } main().catch((err) => { if (err instanceof SimulationTimeoutError) { - process.exit(err.exitCode); + process.exit(err.exitCode); } console.error(err); process.exit(1); diff --git a/packages/wokwi-cli/src/mcp/SimulationManager.ts b/packages/wokwi-cli/src/mcp/SimulationManager.ts index 1962ae5..5603695 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, 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; @@ -104,12 +108,12 @@ 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/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/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..365bc8a 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), @@ -36,3 +45,4 @@ const options = { }; build(options).catch(() => process.exit(1)); + diff --git a/packages/wokwi-cli/tsconfig.json b/packages/wokwi-cli/tsconfig.json index 17efde7..f45021b 100644 --- a/packages/wokwi-cli/tsconfig.json +++ b/packages/wokwi-cli/tsconfig.json @@ -5,5 +5,5 @@ "rootDir": "src" }, "include": ["src/**/*.ts"], - "exclude": ["src/**/*.spec.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 66% rename from packages/wokwi-cli/src/APIClient.ts rename to packages/wokwi-client-js/src/APIClient.ts index c543440..2d3ecc7 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,13 +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; @@ -31,108 +26,45 @@ export class APIClient { [(result: any) => void, (error: Error) => void] >(); - readonly connected; + readonly connected: Promise; onConnected?: (helloMessage: APIHello) => void; onError?: (error: APIError) => void; + onEvent?: (event: APIEvent) => void; constructor( - readonly token: string, - readonly server = DEFAULT_SERVER, + private readonly transport: ITransport, ) { - 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...`); + this.transport.onMessage = (message) => { this.processMessage(message); }; + this.transport.onClose = (code, reason) => { this.handleTransportClose(code, reason); }; + this.transport.onError = (error) => { this.handleTransportError(error); }; - await new Promise((resolve) => setTimeout(resolve, delay)); - - 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,9 +106,12 @@ export class APIClient { }); } - serialMonitorWritable() { + async serialMonitorWritable() { + // Dynamic import for Node.js-only API + const { Writable } = await import('stream'); + const { Buffer } = await import('buffer'); return new Writable({ - write: (chunk, encoding, callback) => { + write: (chunk: any, encoding: BufferEncoding, callback: (error?: Error | null) => void) => { if (typeof chunk === 'string') { chunk = Buffer.from(chunk, encoding); } @@ -258,7 +193,7 @@ export class APIClient { params, id: id.toString(), }; - this.socket.send(JSON.stringify(message)); + this.transport.send(message); }); } @@ -278,8 +213,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; @@ -311,6 +249,7 @@ export class APIClient { } } this._lastNanos = message.nanos; + this.onEvent?.(message); this.apiEvents.dispatchEvent(new CustomEvent(message.event, { detail: message })); } @@ -346,8 +285,21 @@ 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})` : ''}`; + const errorObj: APIError = { type: 'error', message: msg }; + this.onError?.(errorObj); + console.error(msg); + } + + private handleTransportError(error: Error) { + const errorObj: APIError = { type: 'error', message: error.message }; + this.onError?.(errorObj); + console.error('Transport error:', 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 99% rename from packages/wokwi-cli/src/PausePoint.ts rename to packages/wokwi-client-js/src/PausePoint.ts index 983bde1..a494804 100644 --- a/packages/wokwi-cli/src/PausePoint.ts +++ b/packages/wokwi-client-js/src/PausePoint.ts @@ -29,3 +29,4 @@ export class PausePoint { this._resolve(info); } } + diff --git a/packages/wokwi-client-js/src/base64.ts b/packages/wokwi-client-js/src/base64.ts new file mode 100644 index 0000000..a05cf5c --- /dev/null +++ b/packages/wokwi-client-js/src/base64.ts @@ -0,0 +1,41 @@ +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..9761c11 --- /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 { 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..3b5104d --- /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..da5b731 --- /dev/null +++ b/packages/wokwi-client-js/tools/bundle-browser.js @@ -0,0 +1,35 @@ +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', + // Explicitly mark Node.js-only packages as external + external: ['ws', 'stream', 'buffer'], + banner: { + js: `// Browser bundle of wokwi-client-js +// Note: serialMonitorWritable() requires Node.js and won't work in browsers +// 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..0e0f569 --- /dev/null +++ b/packages/wokwi-client-js/tsconfig.json @@ -0,0 +1,10 @@ +{ + "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..7f1d95c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,38 +7,13 @@ 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 @@ -99,6 +74,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 +118,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 +418,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] @@ -1070,6 +1072,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 +1510,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 +2242,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 @@ -2950,6 +2971,9 @@ snapshots: fs-constants@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -3318,6 +3342,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.sh b/scripts/test-cli-integration.sh new file mode 100755 index 0000000..86df7dc --- /dev/null +++ b/scripts/test-cli-integration.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -e + +TEST_PROJECT_DIR="test-project" +TEST_REPO="https://github.com/wokwi/esp-idf-hello-world.git" + +# Clone test project if it doesn't exist +if [ ! -d "$TEST_PROJECT_DIR" ]; then + echo "Cloning test project..." + git clone "$TEST_REPO" "$TEST_PROJECT_DIR" +else + echo "Test project already exists, skipping clone..." +fi + +# Create test scenario file +cat > "$TEST_PROJECT_DIR/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 + +echo "Test scenario file created." + +# Check if WOKWI_CLI_TOKEN is set +if [ -z "$WOKWI_CLI_TOKEN" ]; then + echo "Warning: WOKWI_CLI_TOKEN environment variable is not set." + echo "Integration tests require a Wokwi API token to run." + echo "Set WOKWI_CLI_TOKEN environment variable to run these tests." + exit 1 +fi + +# Run CLI tests +echo "Running CLI integration tests..." + +echo "Test 1: Basic expect-text test" +pnpm cli "$TEST_PROJECT_DIR" --timeout 5000 --expect-text "Hello" + +echo "Test 2: Scenario file test" +pnpm cli "$TEST_PROJECT_DIR" --scenario "test-scenario.yaml" --timeout 15000 + +echo "All CLI integration tests passed!" + diff --git a/test/wokwi-embed/index.html b/test/wokwi-embed/index.html new file mode 100644 index 0000000..053d1c4 --- /dev/null +++ b/test/wokwi-embed/index.html @@ -0,0 +1,30 @@ + + + + + + MicroPython + ESP32 on Wokwi + + + +

MicroPython + ESP32 on Wokwi

+
+ +
+ +

Serial Monitor Output

+
+ + + \ No newline at end of file diff --git a/test/wokwi-embed/script.js b/test/wokwi-embed/script.js new file mode 100644 index 0000000..25716f3 --- /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'); \ No newline at end of file diff --git a/test/wokwi-embed/wokwi-embed.spec.ts b/test/wokwi-embed/wokwi-embed.spec.ts new file mode 100644 index 0000000..4230c37 --- /dev/null +++ b/test/wokwi-embed/wokwi-embed.spec.ts @@ -0,0 +1,21 @@ +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..2fc49ab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,8 @@ "extends": "./tsconfig.base.json", "include": [ "eslint.config.ts", + "playwright.config.ts", + "vitest.config.ts", "./test/**/*.test.ts", "./test/**/*.ts", "./packages/*/src/**/*.ts" 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 + ], + }, +}); +