From 3db85569f0895428be2f001c8eeb477c724cbcda Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Sun, 25 Feb 2024 20:18:18 -0800 Subject: [PATCH] improved CLI --- README.md | 30 ++++++++++++++ bin/{bx.js => bx} | 0 package-lock.json | 19 ++++++++- package.json | 4 +- src/cli/index.ts | 4 ++ src/cli/run.ts | 71 +++++++++++++++++++++++++++++++++ src/cli/session.ts | 88 +++++++++++++++++++++++++++++++++++++++++ src/commands/index.ts | 19 --------- src/commands/session.ts | 28 ------------- src/constants.ts | 9 ++++- src/index.ts | 40 ++++++------------- src/runner.ts | 11 ++++++ src/server.ts | 2 +- src/session.ts | 49 +++++++++++++++++++++-- src/types.ts | 6 +-- src/utils.ts | 16 +++++++- 16 files changed, 310 insertions(+), 86 deletions(-) rename bin/{bx.js => bx} (100%) create mode 100644 src/cli/index.ts create mode 100644 src/cli/run.ts create mode 100644 src/cli/session.ts delete mode 100644 src/commands/index.ts delete mode 100644 src/commands/session.ts diff --git a/README.md b/README.md index 4786a5c..1e24f73 100644 --- a/README.md +++ b/README.md @@ -31,4 +31,34 @@ Running this with `bx` results in: ```sh > npx bx ./html.html Hello World! +``` + +## Session Management + +If you like to speed up your execution, you can create browser sessions on your system and run scripts through them immediately without having to spin up the browser. You can create a session via: + +```sh +# create a session with random session name, e.g. "chrome-1" +npx bx session --browserName chrome +# create a session with custom name +npx bx session --browserName chrome --name chrome +``` + +You can now run scripts faster by providing a session name: + +```sh +npx bx ./script.ts --sessionName chrome +``` + +To view all opened sessions, run: + +```sh +npx bx session +``` + +Kill specific or all sessions via: + +```sh +npx bx session --kill chrome +npx bx session --killAll ``` \ No newline at end of file diff --git a/bin/bx.js b/bin/bx similarity index 100% rename from bin/bx.js rename to bin/bx diff --git a/package-lock.json b/package-lock.json index 4b5836c..ed82ec0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,15 @@ "license": "MIT", "dependencies": { "vite": "^5.1.3", - "webdriverio": "^8.32.0" + "webdriverio": "^8.32.0", + "yargs": "^17.7.2" }, "bin": { "bx": "bin/bx.js" }, "devDependencies": { "@types/node": "^20.11.19", + "@types/yargs": "^17.0.32", "@vitest/coverage-v8": "^1.2.2", "npm-run-all": "^4.1.5", "rimraf": "^5.0.5", @@ -773,6 +775,21 @@ "@types/node": "*" } }, + "node_modules/@types/yargs": { + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", + "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", diff --git a/package.json b/package.json index 560c7e2..ac537c2 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "homepage": "https://github.com/webdriverio/bx#readme", "devDependencies": { "@types/node": "^20.11.19", + "@types/yargs": "^17.0.32", "@vitest/coverage-v8": "^1.2.2", "npm-run-all": "^4.1.5", "rimraf": "^5.0.5", @@ -38,6 +39,7 @@ }, "dependencies": { "vite": "^5.1.3", - "webdriverio": "^8.32.0" + "webdriverio": "^8.32.0", + "yargs": "^17.7.2" } } diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..c9120ea --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,4 @@ +import * as runCmd from './run.js' +import * as sessionCmd from './session.js' + +export default [runCmd, sessionCmd] \ No newline at end of file diff --git a/src/cli/run.ts b/src/cli/run.ts new file mode 100644 index 0000000..1cbe2cd --- /dev/null +++ b/src/cli/run.ts @@ -0,0 +1,71 @@ +import yargs from 'yargs' +import { hideBin } from 'yargs/helpers' +import type { Argv } from 'yargs' + +import { ViteServer } from '../server.js' +import { run } from '../runner.js' +import { CLI_EPILOGUE } from '../constants.js' + +export const command = ' [options]' +export const desc = 'Run script, html file or URL.' +export const cmdArgs = { + browserName: { + type: 'string', + alias: 'b', + description: 'Name of the browser to use. Defaults to "chrome".' + }, + browserVersion: { + type: 'string', + alias: 'v', + description: 'Version of the browser to use.' + }, + headless: { + type: 'boolean', + alias: 'h', + description: 'Run the browser in headless mode. Defaults to true in CI environments.' + }, + rootDir: { + type: 'string', + alias: 'r', + description: 'Root directory of the project. Defaults to the current working directory.' + } +} as const + +const yargsInstance = yargs(hideBin(process.argv)).options(cmdArgs) + +export const builder = (yargs: Argv) => { + return yargs + .example('$0 ./script.js --browserName chrome', 'Run a JavaScript script with Chrome') + .example('$0 ./script.ts --browserName chrome', 'Run a TypeScript file with Chrome') + .example('$0 ./site.html --browserName chrome', 'Run a HTML file with Chrome') + .example('$0 http://localhost:8080 --browserName chrome', 'Run a website with Chrome') + .epilogue(CLI_EPILOGUE) + .help() +} + +export const handler = async () => { + const params = await yargsInstance.parse() + const rootDir = params.rootDir || process.cwd() + const server = new ViteServer({ root: rootDir }) + const target = params._[0] as string | undefined + + if (!target) { + console.error('Error: No target provided') + process.exit(1) + } + + if (target.startsWith('http')) { + console.error('Error: Running URLs is not supported yet') + process.exit(1) + } + + try { + const env = await server.start(target) + await run(env, params) + } catch (err) { + console.error('Error:', (err as Error).message) + process.exit(1) + } finally { + await server.stop() + } +} \ No newline at end of file diff --git a/src/cli/session.ts b/src/cli/session.ts new file mode 100644 index 0000000..c81b494 --- /dev/null +++ b/src/cli/session.ts @@ -0,0 +1,88 @@ +import yargs from 'yargs' +import { hideBin } from 'yargs/helpers' +import type { Argv } from 'yargs' + +import { cmdArgs as runCmdArgs } from './run.js' +import { CLI_EPILOGUE } from '../constants.js' +import { initBrowserSession } from '../utils.js' +import { SessionManager } from '../session.js' + +export const command = 'session [options]' +export const desc = 'Manage `bx` sessions.' +export const cmdArgs = { + name: { + type: 'string', + alias: 's', + description: 'Name of the session.' + }, + kill: { + type: 'string', + description: 'Kill a session by name.' + }, + killAll: { + type: 'boolean', + description: 'Kill all sessions.' + }, + ...runCmdArgs +} as const + +const yargsInstance = yargs(hideBin(process.argv)).options(cmdArgs) + +export const builder = (yargs: Argv) => { + return yargs + .example('$0 session --browserName chrome', 'Create a new session with Chrome') + .example('$0 session --name myChromeSession --browserName chrome', 'Create a named session with Chrome') + .example('$0 session --killAll', 'Kill all local sessions') + .example('$0 session --kill myChromeSession ', 'Kill a local session by name') + .epilogue(CLI_EPILOGUE) + .help() +} + +export const handler = async () => { + const params = await yargsInstance.parse() + + if (typeof params.kill === 'string') { + await SessionManager.deleteSession(params.kill) + return console.log(`Session "${params.kill}" stopped`) + } + + if (params.killAll) { + await SessionManager.deleteAllSessions() + return console.log('All sessions stopped') + } + + const browserName = params.browserName + + /** + * if browserName is not provided, list all sessions + */ + if (!browserName) { + const sessions = await SessionManager.listSessions() + if (sessions.length === 0) { + return console.log('No sessions found!') + } + + console.log('Available sessions:') + for (const session of sessions) { + console.log(`- ${session.name} (${session.capabilities.browserName} ${session.capabilities.browserVersion})`) + } + return + } + + let sessionName = params.name + /** + * if no session name is provided, generate a random one + */ + if (!sessionName) { + const sessions = await SessionManager.listSessions() + const browserNameSessions = sessions.filter((session) => session.requestedCapabilities.browserName === browserName) + sessionName = `${browserName}-${browserNameSessions.length}` + } + + const headless = Boolean(params.headless) + const rootDir = params.rootDir || process.cwd() + const browser = await initBrowserSession({ ...params, rootDir, headless, browserName }) + await SessionManager.saveSession(browser, sessionName) + console.log(`Session "${sessionName}" started, you can now run scripts faster e.g. \`npx bx ./script.js --sessionName ${sessionName}\``) + process.exit(0) +} \ No newline at end of file diff --git a/src/commands/index.ts b/src/commands/index.ts deleted file mode 100644 index 77ee7f6..0000000 --- a/src/commands/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ViteServer } from '../server.js' -import { run } from '../runner.js' -import type { RunnerArgs } from '../types.js' - -export async function runCommand (script: string, args: RunnerArgs) { - const server = new ViteServer({ root: args.rootDir }) - - try { - const env = await server.start(script) - await run(env, args) - } catch (err) { - console.error('Error:', (err as Error).message) - process.exit(1) - } finally { - await server.stop() - } -} - -export * from './session.js' \ No newline at end of file diff --git a/src/commands/session.ts b/src/commands/session.ts deleted file mode 100644 index 176995c..0000000 --- a/src/commands/session.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { initBrowserSession } from '../utils.js' -import { SessionManager } from '../session.js' -import type { RunnerArgs } from '../types.js' - -export async function startSession (args: RunnerArgs) { - const sessionName = args.sessionName - if (!sessionName) { - throw new Error('Please provide a session name') - } - - const browser = await initBrowserSession(args) - await SessionManager.saveSession(browser, sessionName) - console.log(`Session "${sessionName}" started, you can now run scripts faster e.g. \`npx exweb ./script.js --sessionName ${sessionName}\``) - process.exit(0) -} - -export async function stopSession (args: RunnerArgs) { - const sessionName = args.sessionName - if (!sessionName) { - throw new Error('Please provide a session name') - } - - const browser = await SessionManager.loadSession(sessionName) - await browser.deleteSession() - - console.log(`Session "${sessionName}" stopped`) - process.exit(0) -} \ No newline at end of file diff --git a/src/constants.ts b/src/constants.ts index e6b8fb4..f8a1bd3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,5 @@ +import { createRequire } from 'node:module' + export const SUPPORTED_FILE_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.html'] export const IS_CI = Boolean(process.env.HEADLESS || process.env.CI) export const CLI_OPTIONS = { @@ -12,4 +14,9 @@ export const PARSE_OPTIONS = { options: CLI_OPTIONS, tokens: true, allowPositionals: true -} as const \ No newline at end of file +} as const + +const require = createRequire(import.meta.url) +export const pkg = require('../package.json') + +export const CLI_EPILOGUE = `bx (v${pkg.version})` \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 0dde91d..f1f6f6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,32 +1,18 @@ -import { parseArgs } from 'node:util' +import yargs from 'yargs' +import { hideBin } from 'yargs/helpers' -import { parseFileName } from './utils.js' -import { CLI_OPTIONS, PARSE_OPTIONS } from './constants.js' -import { runCommand, startSession, stopSession } from './commands/index.js' -import type { RunnerArgs } from './types.js' +import commands from './cli/index.js' +import { handler } from './cli/run.js' export default async function cli () { - const { values, tokens, positionals } = parseArgs(PARSE_OPTIONS) - - ;(tokens || []).filter((token) => token.kind === 'option').forEach((token: any) => { - if (token.name.startsWith('no-')) { - // Store foo:false for --no-foo - const positiveName = token.name.slice(3) as 'headless' - values[positiveName] = false - delete values[token.name as 'headless'] - } else { - // Re-save value so last one wins if both --foo and --no-foo. - values[token.name as keyof typeof CLI_OPTIONS] = token.value ?? true - } - }) - - if (positionals.includes('session')) { - if (positionals.includes('stop')) { - return stopSession(values as RunnerArgs) - } - - return startSession(values as RunnerArgs) + const argv = yargs(hideBin(process.argv)) + .command(commands) + .help() + .argv + + const cmdNames = commands.map((command: { command: string }) => command.command.split(' ')[0]) + const params = await argv + if (!cmdNames.includes(`${params['_'][0]}`)) { + await handler() } - - return runCommand(parseFileName(positionals[0]), values as RunnerArgs) } diff --git a/src/runner.ts b/src/runner.ts index 3273396..602a7b1 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -2,6 +2,8 @@ import { initBrowserSession } from './utils.js' import { SessionManager } from './session.js' import type { ExecutionEnvironment, RunnerArgs } from './types.js' +const SAFARI_ERROR_PREFIX = 'module code@' + export async function run (env: ExecutionEnvironment, args: RunnerArgs) { const browser = args.sessionName ? await SessionManager.loadSession(args.sessionName) @@ -18,6 +20,15 @@ export async function run (env: ExecutionEnvironment, args: RunnerArgs) { env.server.ws.on('bx:event', (message) => { if (message.name === 'errorEvent') { error = new Error(message.message) + + /** + * Safari sometimes doesn't return a formatted error stack, so + * we need to replace the stack with the original error message + */ + if (message.error.startsWith(SAFARI_ERROR_PREFIX)) { + message.error = `${error.message}\n\tat ${message.error.replace(SAFARI_ERROR_PREFIX, '')}` + } + error.stack = message.error.replace(`http://localhost:${env.server.config.server.port}/@fs`, 'file://') return resolve() } diff --git a/src/server.ts b/src/server.ts index 0e8970a..5ab785a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -71,7 +71,7 @@ async function instrument (filename: string, onConnect: (value: ViteDevServer) = const code = path.extname(filename) === '.html' ? await fs.readFile(filename, 'utf-8') - : `` + : `` const template = ` diff --git a/src/session.ts b/src/session.ts index 410643d..2d2228a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -6,12 +6,14 @@ import { attach } from 'webdriverio' import type { Options } from '@wdio/types' const SESSION_DIR = process.env.TMPDIR || os.tmpdir() +const SESSION_FILE_PREFIX = 'bx_session_' function getSessionFilePath(sessionName: string) { - return path.join(SESSION_DIR, `${sessionName}.json`) + return path.join(SESSION_DIR, `${SESSION_FILE_PREFIX}${sessionName}.json`) } interface SessionFile { + name: string capabilities: WebdriverIO.Capabilities requestedCapabilities: WebdriverIO.Capabilities sessionId: string @@ -31,14 +33,55 @@ export class SessionManager { } const session: SessionFile = JSON.parse(await fs.readFile(sessionFilePath, 'utf8')) - return attach(session) + return attach({ ...session.options, ...session }) } static async saveSession(browser: WebdriverIO.Browser, sessionName: string) { const { capabilities, requestedCapabilities, sessionId, options } = browser await fs.writeFile( getSessionFilePath(sessionName), - JSON.stringify({ capabilities, requestedCapabilities, sessionId, options } as SessionFile) + JSON.stringify({ name: sessionName, capabilities, requestedCapabilities, sessionId, options } as SessionFile) ) } + + static async listSessions(): Promise { + const files = await fs.readdir(SESSION_DIR) + const sessionFiles: SessionFile[] = [] + + for (const file of files) { + if (file.startsWith(SESSION_FILE_PREFIX)) { + const session = JSON.parse(await fs.readFile(path.join(SESSION_DIR, file), 'utf8')) + sessionFiles.push(session) + } + } + + return sessionFiles + } + + static async deleteSession(sessionName?: string) { + if (!sessionName) { + throw new Error('Please provide a session name') + } + + const sessionFilePath = getSessionFilePath(sessionName) + const sessionExists = await fs.access(sessionFilePath).then(() => true, () => false) + if (!sessionExists) { + throw new Error(`Session "${sessionName}" not found`) + } + + await fs.unlink(sessionFilePath) + } + + static async deleteAllSessions() { + const files = await fs.readdir(SESSION_DIR) + return Promise.all(files.map(async (file) => { + if (!file.startsWith(SESSION_FILE_PREFIX)) { + return + } + const sessionName = path.basename(file, path.extname(file)).replace(SESSION_FILE_PREFIX, '') + const session = await SessionManager.loadSession(sessionName) + await fs.unlink(path.join(SESSION_DIR, file)) + await session.deleteSession() + })) + } } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index aa6f170..7f01974 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,10 +1,10 @@ import type { ViteDevServer } from 'vite' export interface RunnerArgs { - browserName: string + browserName?: string browserVersion?: string - headless: boolean - rootDir: string + headless?: boolean + rootDir?: string sessionName?: string } diff --git a/src/utils.ts b/src/utils.ts index 9b7c5b7..aad9b5c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -21,7 +21,7 @@ export function getHeadlessArgs ({ browserName, headless }: Pick