diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..6d08ee00d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "attribution": { + "commit": "", + "pr": "", + "sessionUrl": false + } +} diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 000000000..3d680d606 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,84 @@ +--- +name: release +description: Use when asked to deploy, release, publish, cut or tag a new version of this patched vscode-react-native fork (versions like v1.14.20261012), or to install the latest patched VSIX into the local editor. +argument-hint: "[version] [install] | install" +--- + +# Release the patched fork + +## Mode + +Decide the mode from the request: +- **Install only.** The request asks to install and doesn't mention deploy/release/publish. Set `V=$(gh release view --repo teoboetti/vscode-react-native --json tagName -q .tagName | sed 's/^v//')`, then do step 5 only. +- **Release.** Do steps 1-4. Also do step 5 if the request mentions install. +- **Version.** If the request names one (e.g. `1.14.20261020`), use it without the `v`. Otherwise use `..$(date +%Y%m%d)`, taking major.minor from `package.json`. If that version is already released, STOP and ask. Never add a suffix to the date: `1.14.2026101201` sorts above every later date and blocks future releases. + +## How this works + +A release is a `chore: release X` commit that bumps the version in `package.json` and `package-lock.json`, plus a `vX` tag **on that commit**. Pushing the tag runs `.github/workflows/build-vsix.yml`, which builds the VSIX and creates the GitHub release with it attached. The VSIX version comes from `package.json`, not from the tag. + +**Shell variables don't persist between Bash calls.** Start every call with: +```bash +cd "$(git rev-parse --show-toplevel)"; R=teoboetti/vscode-react-native; V= +``` +Pass `--repo $R` to every `gh` call. The gh default repo here is upstream `microsoft/vscode-react-native`. + +## Steps + +**1. Preflight.** If any check fails, STOP and report it: +```bash +git fetch origin --quiet +git status --porcelain --untracked-files=no # must print nothing +git rev-parse --abbrev-ref HEAD # must be master +git rev-parse master origin/master # both lines must match +gh run list --repo $R --workflow ci.yml --commit "$(git rev-parse HEAD)" --limit 1 # must be completed + success +node -p 'require("./package.json").version' # must be lower than $V +``` + +**2. Tag check.** +```bash +git ls-remote --tags origin "v$V" # prints anything → already released: STOP and report +git tag -l "v$V" # prints the tag → it's a stale local tag: git tag -d "v$V" +``` + +**3. Bump, commit, tag.** +```bash +OLD=$(node -p 'require("./package.json").version') +sed -i '' "s/\"version\": \"$OLD\"/\"version\": \"$V\"/" package.json package-lock.json +grep -c "\"version\": \"$V\"" package.json package-lock.json # must be package.json:1, package-lock.json:2 +git commit -am "chore: release $V" # a pre-commit lint hook runs +git tag "v$V" +``` +The commit gets no attribution trailers. If anything in steps 3-4 fails **before the push**, undo it with `git tag -d "v$V"; git reset --hard origin/master`, then STOP and report. This is safe because preflight confirmed there were no tracked changes. + +**4. Push and verify.** Use a 10-minute Bash timeout. +```bash +git push --atomic origin master "v$V" # both refs land, or neither does +for i in $(seq 12); do id=$(gh run list --repo $R --workflow build-vsix.yml --branch "v$V" --commit "$(git rev-parse HEAD)" --limit 1 --json databaseId -q '.[0].databaseId'); [ -n "$id" ] && break; sleep 5; done +gh run watch "${id:?no build-vsix run found for v$V}" --repo $R --exit-status --interval 15 +gh release view "v$V" --repo $R --json url,assets -q '.url, .assets[].name' # must list vscode-react-native-$V.vsix +``` +If the build fails after the push, don't delete the pushed tag. Report the run URL; rerunning it is the user's call. + +**5. Install.** The patched build lives in **VSCodium** (`codium`). VS Code (`code`) runs the Microsoft marketplace version, so don't touch it unless the request names it (then use `code` wherever this step says `codium`). +```bash +codium --list-extensions --show-versions | grep -x "msjsdiag.vscode-react-native@$V" # prints → already installed: skip the next two commands +D=$(mktemp -d) && gh release download "v$V" --repo $R --dir "$D" && codium --install-extension "$D/vscode-react-native-$V.vsix" --force +codium --list-extensions --show-versions | grep -x "msjsdiag.vscode-react-native@$V" # must print +``` + +## Report + +Report in a few lines. On success, give the version, the commit sha, the release URL, the VSIX asset name, and the installed version if you installed. After an install, remind the user to run **Developer: Reload Window** in VSCodium. On a STOP, give the failing command, its output, and anything you undid. + +## Common mistakes + +| Mistake | Result | +|---|---| +| Tagging before bumping `package.json` | VSIX and release carry the previous version | +| Bumping only `package.json` | `npm ci` in the workflow fails on the lockfile mismatch | +| Using `npm version` to bump | Can rewrite the v2 lockfile | +| Bare `gh` without `--repo $R` | Queries the upstream Microsoft repo | +| Picking the build run without `--branch` + `--commit` | Can watch an old run from an earlier push of the same tag | +| Pushing `master` and the tag separately | One can land without the other, and the undo step then deletes the only copy of the tag | +| Installing a local `npx vsce package` build | Installed build differs from the published release | diff --git a/.eslintrc.js b/.eslintrc.js index c3cac4161..013ae65b8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -194,7 +194,37 @@ module.exports = { cases: { camelCase: true, // pascalCase: true, }, - ignore: [/rn-extension\.ts/], + ignore: [ + /rn-extension\.ts/, + /CDPAPINames\.ts/, + /ConsoleLogger\.ts/, + /ICDPMessageHandler\.ts/, + /IExperiment\.ts/, + /IWDPHelper\.ts/, + /LogHelper\.ts/, + /NullLogger\.ts/, + /OutputChannelLogger\.ts/, + /RNTPreviewPrompt\.ts/, + /baseCDPMessageHandler\.ts/, + /configEASBuild\.ts/, + /graphQLFormatter\.ts/, + /hermesCDPMessageHandler\.ts/, + /iOSClienDevice\.ts/, + /iOSContainerUtility\.ts/, + /iOSDebugModeManager\.ts/, + /iOSDeviceTracker\.ts/, + /iOSDirectCDPMessageHandler\.ts/, + /iOSPlatform\.ts/, + /iOSTargetManager\.ts/, + /macOSDebugModeManager\.ts/, + /macOSPlatform\.ts/, + /nodeJS\.ts/, + /openEASProject\.ts/, + /openRNUpgradeHelper\.ts/, + /reactNativeCDPProxy\.ts/, + /reopenQRCode\.ts/, + /rnCDPMessageHandler\.ts/, + ], }, ], "unicorn/no-array-reduce": "warn", diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..ca79ca5b4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/build-vsix.yml b/.github/workflows/build-vsix.yml new file mode 100644 index 000000000..0322c8cb8 --- /dev/null +++ b/.github/workflows/build-vsix.yml @@ -0,0 +1,51 @@ +name: Build VSIX + +# Packages the extension and, for a v* tag, attaches the VSIX to that release +# so it can be installed on a machine without the build toolchain: +# +# gh release download v1.14.2 --repo teoboetti/vscode-react-native +# codium --install-extension vscode-react-native-1.14.2.vsix + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + package: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Package + run: npx vsce package + + - name: Attach the VSIX to the release + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" ./*.vsix --clobber + else + gh release create "$GITHUB_REF_NAME" ./*.vsix --generate-notes + fi + + - uses: actions/upload-artifact@v7 + with: + name: vsix + path: "*.vsix" + if-no-files-found: error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..22461b155 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + +permissions: + contents: read + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Bundle extension + run: npx gulp prod-build + + - name: Build + run: npm run build + + - name: Test + run: xvfb-run -a npm test + + - name: Localization tests + run: xvfb-run -a npm run test-localization diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 000000000..150da808c --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,66 @@ +name: Sync upstream + +on: + schedule: + - cron: "0 6 * * 1" # Mondays 06:00 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + env: + # PAT (repo + workflow scopes): GITHUB_TOKEN can't push upstream workflow changes and its PRs don't trigger CI + GH_TOKEN: ${{ secrets.SYNC_PAT }} + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + token: ${{ secrets.SYNC_PAT }} + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: npm + + - name: Merge upstream + id: merge + run: | + # an open sync PR may hold manual fixes; don't overwrite it + gh pr list --head upstream-sync --state open --json number -q '.[].number' | grep -q . && exit 0 + git config user.name "upstream-sync" + git config user.email "upstream-sync@users.noreply.github.com" + git fetch https://github.com/microsoft/vscode-react-native.git master + [ "$(git rev-list --count HEAD..FETCH_HEAD)" = 0 ] && exit 0 + git checkout -b upstream-sync + if git merge --no-edit FETCH_HEAD; then + echo "result=clean" >> "$GITHUB_OUTPUT" + else + echo "files=$(git diff --name-only --diff-filter=U | tr '\n' ' ')" >> "$GITHUB_OUTPUT" + echo "result=conflict" >> "$GITHUB_OUTPUT" + fi + + - name: Resolve conflicts with Claude + if: steps.merge.outputs.result == 'conflict' + timeout-minutes: 30 # hard cap; the default is 6 hours + run: npx -y @anthropic-ai/claude-code -p "$PROMPT" --allowedTools "Bash,Read,Edit,Write" --max-turns 40 --model sonnet + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # from `claude setup-token` + PROMPT: | + A merge of microsoft/vscode-react-native master into this fork stopped on conflicts in: + ${{ steps.merge.outputs.files }} + Resolve them: take upstream's changes but keep this fork's patches (see `git log` for fork-only commits). + Then run `npm ci`, `npm run lint`, `npx gulp prod-build` and `npm run build`, and fix anything the merge broke. + Finish with `git add -A` and `git commit --no-edit`. Do not push. + + - name: Open PR + if: steps.merge.outputs.result != '' + run: | + if git grep -n '^<<<<<<< '; then exit 1; fi # leftover conflict markers: fail, no PR + git push -f origin upstream-sync + gh pr create --head upstream-sync --base master \ + --title "Sync upstream $(date +%F)" \ + --body "Weekly upstream merge. Files resolved by Claude: ${{ steps.merge.outputs.files || 'none' }}" diff --git a/README.md b/README.md index 84098e7aa..5104397fa 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,49 @@ You can not only download React Native Tools in the marketplace, but can also pa Building and testing React Native Tools from source requires Node.js 22 or later. This development requirement does not affect the runtime requirements of the installed extension. +## Install in VSCodium + +VSCodium installs extensions from [Open VSX](https://open-vsx.org/), which does not carry this fork, so install the packaged `.vsix` directly. + +### From a release + +A `v*` tag builds the extension in CI and attaches the `.vsix` to the release, so no local toolchain is needed: + +```bash +gh release download --repo teoboetti/vscode-react-native --pattern '*.vsix' +codium --install-extension vscode-react-native-*.vsix +``` + +Omitting the tag takes the latest release; pass one (for example `v1.14.20260928`) to install a specific build. + +### From source + +```bash +git clone https://github.com/teoboetti/vscode-react-native.git +cd vscode-react-native +npm install +npm run install-local +``` + +`npm run install-local` stamps the version with the build date, packages the extension and installs it into VSCodium in one step. + +Reload the window afterwards (**Developer: Reload Window**) for VSCodium to pick up the new build. + +If `codium` is not on your `PATH`, run **Shell Command: Install 'codium' command in PATH** from the Command Palette, or install the `.vsix` through **Extensions: Install from VSIX...**. + +### Telling it apart from the published extension + +Local builds are named **React Native Tools (patched)** and versioned `1.14.`, so they are recognisable in the extensions list and always sort above a published `1.14.x`. + +They keep upstream's `msjsdiag.vscode-react-native` identifier, so VSCodium may still try to replace one with a marketplace build. Right-click the extension and choose **Disable Auto Update** to prevent that. + +To pick up upstream changes and rebuild: + +```bash +git fetch upstream && git rebase upstream/master +npm run install-local +``` + ## About the extension This VS Code extension provides a development environment for React Native and Expo projects. @@ -34,6 +77,7 @@ Using this extension, you can **debug your code and quickly run `react-native` o - [React Native Tools Preview](#react-native-tools-preview) - [Package extension locally](#package-extension-locally) +- [Install in VSCodium](#install-in-vscodium) - [About the extension](#about-the-extension) - [Getting started](#getting-started) - [React Native commands in the Command Palette](#react-native-commands-in-the-command-palette) diff --git a/gulp_scripts/builder.js b/gulp_scripts/builder.js index 4472a946b..51ac4edbe 100644 --- a/gulp_scripts/builder.js +++ b/gulp_scripts/builder.js @@ -28,7 +28,7 @@ const generateSrcLocBundle = () => { return runTypeScriptCompile().then(() => streamToPromise( gulp - .src(["src/**/*.js"], { base: ".", allowEmpty: true }) + .src(["src/**/*.js"], { base: appRoot, allowEmpty: true }) .pipe(nls.createMetaDataFiles()) .pipe(nls.createAdditionalLanguageFiles(defaultLanguages, "i18n")) .pipe(nls.bundleMetaDataFiles(fullExtensionName, "dist")) @@ -81,7 +81,7 @@ async function build(failOnError, buildNls) { await runTypeScriptCompile(); const stream = gulp - .src(["src/**/*.js", "test/**/*.js"], { base: ".", allowEmpty: true }) + .src(["src/**/*.js", "test/**/*.js"], { base: appRoot, allowEmpty: true }) .pipe(createLinePreservingPreprocessor(preprocessorContext)) .pipe(buildNls ? nls.rewriteLocalizeCalls() : es.through()) .pipe( diff --git a/package-lock.json b/package-lock.json index ba9c32ed2..223f6b48e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-react-native", - "version": "1.14.2", + "version": "1.14.20261019", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vscode-react-native", - "version": "1.14.2", + "version": "1.14.20261019", "hasInstallScript": true, "license": "%reactNative.license%", "dependencies": { diff --git a/package.json b/package.json index 1cdadfc28..6fa382fd9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vscode-react-native", - "displayName": "React Native Tools", - "version": "1.14.2", + "displayName": "React Native Tools (patched)", + "version": "1.14.20261019", "private": true, "publisher": "msjsdiag", "icon": "resources/images/icon.png", @@ -132,6 +132,12 @@ "category": "React Native", "enablement": "!config.security.workspace.trust.enabled || isWorkspaceTrusted" }, + { + "command": "reactNative.selectDevice", + "title": "%reactNative.command.selectDevice.title%", + "category": "React Native", + "enablement": "!config.security.workspace.trust.enabled || isWorkspaceTrusted" + }, { "command": "reactNative.launchExpoWeb", "title": "%reactNative.command.launchExpoWeb.title%", @@ -1394,6 +1400,7 @@ }, "scripts": { "build": "gulp build", + "install-local": "npm version 1.14.$(date +%Y%m%d) --no-git-tag-version --allow-same-version && vsce package -o vscode-react-native-local.vsix && codium --install-extension vscode-react-native-local.vsix", "lint": "gulp lint", "format": "gulp format", "vscode:prepublish": "gulp", @@ -1524,8 +1531,8 @@ "should": "8.3.0", "sinon": "1.17.3", "source-map-support": "0.4.0", - "through2": "2.0.1", "terser-webpack-plugin": "^5.4.0", + "through2": "2.0.1", "typescript": "npm:@typescript/typescript6@6.0.2", "typescript-7": "npm:typescript@7.0.2", "vscode-debugprotocol": "1.51.0", diff --git a/package.nls.json b/package.nls.json index 6b4043408..94fec838c 100644 --- a/package.nls.json +++ b/package.nls.json @@ -5,6 +5,7 @@ "reactNative.virtualWorkspaces.description": "Debugging is not possible in virtual workspaces.", "reactNative.command.launchAndroidSimulator.title": "Launch Android Emulator", "reactNative.command.launchIOSSimulator.title": "Launch iOS Simulator", + "reactNative.command.selectDevice.title": "Select Device", "reactNative.command.launchExpoWeb.title": "Expo - Launch ExpoWeb", "reactNative.command.selectAndInsertDebugConfiguration.title": "Select a React Native debug configuration", "reactNative.command.runAndroidSimulator.title": "Run Android on Emulator", diff --git a/src/cdp-proxy/CDPMessageHandlers/CDPAPINames.ts b/src/cdp-proxy/CDPMessageHandlers/CDPAPINames.ts index e005635f9..710553244 100644 --- a/src/cdp-proxy/CDPMessageHandlers/CDPAPINames.ts +++ b/src/cdp-proxy/CDPMessageHandlers/CDPAPINames.ts @@ -12,4 +12,5 @@ export const CDP_API_NAMES = { CLOSE: "close", DEBUGGER_SCRIPT_PARSED: "Debugger.scriptParsed", DEBUGGER_SET_BREAKPOINT_BY_URL: "Debugger.setBreakpointByUrl", + PAGE_RELOAD: "Page.reload", }; diff --git a/src/cdp-proxy/CDPMessageHandlers/hermesCDPMessageHandler.ts b/src/cdp-proxy/CDPMessageHandlers/hermesCDPMessageHandler.ts index f2fb1931c..821f76153 100644 --- a/src/cdp-proxy/CDPMessageHandlers/hermesCDPMessageHandler.ts +++ b/src/cdp-proxy/CDPMessageHandlers/hermesCDPMessageHandler.ts @@ -20,8 +20,36 @@ export class HermesCDPMessageHandler extends BaseCDPMessageHandler { */ private readonly HERMES_NATIVE_FUNCTION_SCRIPT_ID: string = "4294967295"; - public processDebuggerCDPMessage(event: ProtocolMessage): ProcessedCDPMessage { + /** + * @description Quiet period after the last breakpoint request before the application + * is reloaded, so that a burst of breakpoint requests causes a single reload + * @type {number} + */ + private readonly RELOAD_DEBOUNCE_MS: number = 300; + + /** + * @description Id of the out-of-band Page.reload request. The debugger never sent it, + * so it discards the reply as an unknown response id + * @type {number} + */ + private readonly RELOAD_REQUEST_ID: number = 2147483646; + + private isFirstRun: boolean = true; + private reloadTimer: NodeJS.Timeout | null = null; + + public processDebuggerCDPMessage(initialEvent: ProtocolMessage): ProcessedCDPMessage { + let event = initialEvent; let sendBack = false; + if ("method" in event) { + if (event.method === CDP_API_NAMES.CLOSE) { + this.cancelFirstRunReload(); + } else if ( + event.method === CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT || + event.method === CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL + ) { + this.scheduleFirstRunReload(); + } + } if ("method" in event && "params" in event) { if (event.method === CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT) { event = this.handleBreakpointSetting(event); @@ -102,6 +130,46 @@ export class HermesCDPMessageHandler extends BaseCDPMessageHandler { return event; } + /** + * The application finishes executing its bundle before the debugger manages to attach, + * so breakpoints registered for the first run resolve but are never reached. Reload the + * application once the debugger has finished sending its initial breakpoints, so that the + * bundle is evaluated again while they are in place. Reloading is driven by the device + * (the "nativePageReloads" capability), so the debugger stays attached across it. + */ + private scheduleFirstRunReload(): void { + if (!this.isFirstRun) { + return; + } + + if (this.reloadTimer) { + clearTimeout(this.reloadTimer); + } + + this.reloadTimer = setTimeout(() => { + this.reloadTimer = null; + + if (!this.isFirstRun) { + return; + } + this.isFirstRun = false; + + this.applicationTarget?.send({ + id: this.RELOAD_REQUEST_ID, + method: CDP_API_NAMES.PAGE_RELOAD, + params: {}, + }); + }, this.RELOAD_DEBOUNCE_MS); + } + + private cancelFirstRunReload(): void { + if (this.reloadTimer) { + clearTimeout(this.reloadTimer); + this.reloadTimer = null; + } + this.isFirstRun = true; + } + private handleBreakpointSetting(event: IProtocolCommand): IProtocolCommand { if (event.params) { const params = event.params as { location?: { columnNumber?: unknown } }; diff --git a/src/cdp-proxy/debuggerEndpointHelper.ts b/src/cdp-proxy/debuggerEndpointHelper.ts index 397ac22c8..a1dcfc426 100644 --- a/src/cdp-proxy/debuggerEndpointHelper.ts +++ b/src/cdp-proxy/debuggerEndpointHelper.ts @@ -34,22 +34,26 @@ export class DebuggerEndpointHelper { */ public async retryGetWSEndpoint( browserURL: string, - attemptNumber: number, + initialAttemptNumber: number, cancellationToken: CancellationToken, isHermes: boolean = false, settingsPort?: number, ): Promise { + let attemptNumber = initialAttemptNumber; while (true) { try { let url = ""; if (settingsPort) { url = `http://localhost:${settingsPort}`; try { + // eslint-disable-next-line no-await-in-loop -- sequential retry loop return await this.getWSEndpoint(browserURL, isHermes); } catch { + // eslint-disable-next-line no-await-in-loop -- sequential retry loop return await this.getWSEndpoint(url, isHermes); } } else { + // eslint-disable-next-line no-await-in-loop -- sequential retry loop return await this.getWSEndpoint(browserURL, isHermes); } } catch (err) { @@ -71,6 +75,7 @@ export class DebuggerEndpointHelper { } attemptNumber--; + // eslint-disable-next-line no-await-in-loop -- delay between retry attempts await PromiseUtil.delay(700); } } diff --git a/src/cdp-proxy/reactNativeCDPProxy.ts b/src/cdp-proxy/reactNativeCDPProxy.ts index dad97c42f..a06de5f62 100644 --- a/src/cdp-proxy/reactNativeCDPProxy.ts +++ b/src/cdp-proxy/reactNativeCDPProxy.ts @@ -112,17 +112,15 @@ export class ReactNativeCDPProxy { this.debuggerTarget.pause(); // don't listen for events until the target is ready if (!this.browserInspectUri) { - if (this.cancellationToken) { - this.browserInspectUri = await this.debuggerEndpointHelper.retryGetWSEndpoint( - `http://localhost:${this.applicationTargetPort}`, - 90, - this.cancellationToken, - ); - } else { - this.browserInspectUri = await this.debuggerEndpointHelper.getWSEndpoint( - `http://localhost:${this.applicationTargetPort}`, - ); - } + this.browserInspectUri = this.cancellationToken + ? await this.debuggerEndpointHelper.retryGetWSEndpoint( + `http://localhost:${this.applicationTargetPort}`, + 90, + this.cancellationToken, + ) + : await this.debuggerEndpointHelper.getWSEndpoint( + `http://localhost:${this.applicationTargetPort}`, + ); } this.applicationTarget = new Connection(await this.createApplicationTransport()); @@ -215,6 +213,8 @@ export class ReactNativeCDPProxy { } private createApplicationTransport(): Promise { + // Metro's inspector proxy rejects debugger WebSockets whose Origin is not the + // dev server itself (or a loopback host), so send the expected Origin header. const webSocket = new WebSocket(this.browserInspectUri, [], { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024, diff --git a/src/common/entryPointHandler.ts b/src/common/entryPointHandler.ts index 38509b38b..a63d820cb 100644 --- a/src/common/entryPointHandler.ts +++ b/src/common/entryPointHandler.ts @@ -76,14 +76,18 @@ export class EntryPointHandler { } private handleErrors( - error: InternalError, + internalError: InternalError, resultOfCode: Promise, errorsAreFatal: boolean, ): Promise { resultOfCode.catch(reason => { const isDebugeeProcess = this.processType === ProcessType.Debugee; const shouldLogStack = !errorsAreFatal || isDebugeeProcess; - this.logger.error(error.message, ErrorHelper.wrapError(error, reason), shouldLogStack); + this.logger.error( + internalError.message, + ErrorHelper.wrapError(internalError, reason), + shouldLogStack, + ); // For the debugee process we don't want to throw an exception because the debugger // will appear to the user if he turned on the VS Code uncaught exceptions feature. if (errorsAreFatal) { diff --git a/src/common/error/errorStrings.ts b/src/common/error/errorStrings.ts index 9d443bec1..ec3136fb5 100644 --- a/src/common/error/errorStrings.ts +++ b/src/common/error/errorStrings.ts @@ -423,4 +423,5 @@ export const ERROR_STRINGS = { [InternalErrorCode.FailedToToggleNetworkView]: "Failed to config network view", [InternalErrorCode.FailedToRunEasBuild]: "Failed to run eas build", [InternalErrorCode.FailedToInstallPods]: "Failed to install pods", + [InternalErrorCode.FailedToSelectDevice]: "Failed to select device", }; diff --git a/src/common/error/internalErrorCode.ts b/src/common/error/internalErrorCode.ts index 61734b2ad..be707fa0d 100644 --- a/src/common/error/internalErrorCode.ts +++ b/src/common/error/internalErrorCode.ts @@ -40,6 +40,7 @@ export enum InternalErrorCode { FailedToToggleNetworkView = 136, FailedToRunEasBuild = 137, FailedToInstallPods = 138, + FailedToSelectDevice = 139, // Device Deployer errors IOSDeployNotFound = 201, diff --git a/src/common/installHelper.ts b/src/common/installHelper.ts index 68380ab5b..1c92bd77b 100644 --- a/src/common/installHelper.ts +++ b/src/common/installHelper.ts @@ -7,7 +7,10 @@ import { SimctrlHelper } from "../extension/ios/simctl"; import { OutputChannelLogger } from "../extension/log/OutputChannelLogger"; import { ChildProcess } from "./node/childProcess"; -export async function installAndroidApplication(project: AppLauncher, appPath: string) { +export async function installAndroidApplication( + project: AppLauncher, + appPath: string, +): Promise { const logger = OutputChannelLogger.getMainChannel(); const adbHelper = new AdbHelper( project.getPackager().getProjectPath(), @@ -15,7 +18,7 @@ export async function installAndroidApplication(project: AppLauncher, appPath: s ); const targets = await adbHelper.getOnlineTargets(); - if (targets.length == 0) { + if (targets.length === 0) { throw new Error("No online target found, please check your emulator status."); } else if (targets.length > 1) { logger.logStream( @@ -37,7 +40,7 @@ export async function installAndroidApplication(project: AppLauncher, appPath: s } } -export async function installiOSApplication(project: AppLauncher, appPath: string) { +export async function installiOSApplication(project: AppLauncher, appPath: string): Promise { const logger = OutputChannelLogger.getMainChannel(); const childProcess: ChildProcess = new ChildProcess(); @@ -54,7 +57,7 @@ export async function installiOSApplication(project: AppLauncher, appPath: strin throw e; } - if (targets.length == 1) { + if (targets.length === 1) { throw new Error("No booted iOS simulator found, please check your simulator status."); } else if (targets.length > 2) { logger.logStream( diff --git a/src/common/node/childProcess.ts b/src/common/node/childProcess.ts index 32f460cee..bf1877ca5 100644 --- a/src/common/node/childProcess.ts +++ b/src/common/node/childProcess.ts @@ -58,12 +58,7 @@ export class ChildProcess { process = this.childProcess.exec( command, options, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ( - error: nodeChildProcess.ExecException | null, - stdout: string | Buffer, - stderr: string | Buffer, - ) => { + (error: nodeChildProcess.ExecException | null, stdout: string | Buffer) => { if (error) { reject( ErrorHelper.getNestedError( @@ -98,11 +93,7 @@ export class ChildProcess { command, args, options, - ( - error: nodeChildProcess.ExecException | null, - stdout: string | Buffer, - stderr: string | Buffer, - ) => { + (error: nodeChildProcess.ExecException | null, stdout: string | Buffer) => { if (error) { reject( ErrorHelper.getNestedError( diff --git a/src/common/node/promise.ts b/src/common/node/promise.ts index fa7f06041..500857b95 100644 --- a/src/common/node/promise.ts +++ b/src/common/node/promise.ts @@ -49,10 +49,10 @@ export class PromiseUtil { ): Promise { const arraySources: T[] = sources instanceof Promise ? await sources : sources; - return arraySources.reduce(async (previousReduction: Promise, newSource: T) => { - await previousReduction; - return generateAsyncOperation(newSource); - }, Promise.resolve()); + for (const source of arraySources) { + // eslint-disable-next-line no-await-in-loop -- operations run sequentially by design + await generateAsyncOperation(source); + } } public static async delay(duration: number): Promise { @@ -66,7 +66,7 @@ export class PromiseUtil { interval: number = 1000, timeout?: number, ): Promise { - return new Promise(async (resolve, reject) => { + return new Promise((resolve, reject) => { let rejectTimeout: NodeJS.Timeout | undefined; // eslint-disable-next-line prefer-const let сheckInterval: NodeJS.Timeout | undefined; @@ -103,14 +103,14 @@ export class PromiseUtil { } }; - const resolved = await tryToResolve(); - if (resolved) { - return; - } - - сheckInterval = setInterval(async () => { - await tryToResolve(); - }, interval); + void tryToResolve().then(resolved => { + if (resolved) { + return; + } + сheckInterval = setInterval(async () => { + await tryToResolve(); + }, interval); + }); }); } diff --git a/src/common/nodeHelper.ts b/src/common/nodeHelper.ts index 5effd87ab..52c35d338 100644 --- a/src/common/nodeHelper.ts +++ b/src/common/nodeHelper.ts @@ -3,7 +3,7 @@ import { ChildProcess } from "./node/childProcess"; -export async function getNodeVersion(projectPath: string, env: object) { +export async function getNodeVersion(projectPath: string, env: object): Promise { try { return await new ChildProcess().execToString("node -v", { cwd: projectPath, env }); } catch (error) { diff --git a/src/common/packageLoader.ts b/src/common/packageLoader.ts index d3a1daa8e..cdd20e268 100644 --- a/src/common/packageLoader.ts +++ b/src/common/packageLoader.ts @@ -175,6 +175,7 @@ export class PackageLoader { const load = this.packagesQueue.length; const packagesForInstall = this.packagesQueue.slice(0, load); + // eslint-disable-next-line no-await-in-loop -- packages are installed one npm command at a time await commandExecutor.spawnWithProgress( HostPlatform.getNpmCliCommand("npm"), ["install", ...packagesForInstall, "--verbose", "--no-save", "--global-style"], @@ -184,7 +185,8 @@ export class PackageLoader { ); // Try to require all pending packages after every 'npm install ...' command const requiresToRemove: ((load?: string[]) => Promise)[] = []; - for (tryToRequire of this.requireQueue) { + for (const tryToRequire of this.requireQueue) { + // eslint-disable-next-line no-await-in-loop -- iterates a live queue that may grow while awaiting if (await tryToRequire(packagesForInstall)) { requiresToRemove.push(tryToRequire); } diff --git a/src/common/packager.ts b/src/common/packager.ts index e69168629..ff5a89f7c 100644 --- a/src/common/packager.ts +++ b/src/common/packager.ts @@ -273,13 +273,16 @@ export class Packager { ); let packagerSpawnResult; - if (this.runOptions?.platform != "exponent" && this.runOptions?.platform != "expoweb") { + if ( + this.runOptions?.platform !== "exponent" && + this.runOptions?.platform !== "expoweb" + ) { packagerSpawnResult = new CommandExecutor( nodeModulesRoot, this.projectPath, this.logger, ).spawnReactPackager(args, spawnOptions); - } else if (this.runOptions?.platform == "exponent") { + } else if (this.runOptions?.platform === "exponent") { this.runOptions?.expoPlatformType && args.push(`--${this.runOptions?.expoPlatformType?.toLowerCase()}`); logger.log( diff --git a/src/common/projectVersionHelper.ts b/src/common/projectVersionHelper.ts index 1c9715d7c..298bc51e1 100644 --- a/src/common/projectVersionHelper.ts +++ b/src/common/projectVersionHelper.ts @@ -56,8 +56,9 @@ export class ProjectVersionHelper { */ public static async getReactNativeRequiredNodeRange( projectRoot: string, - nodeModulesRoot?: string, + initialNodeModulesRoot?: string, ): Promise { + let nodeModulesRoot = initialNodeModulesRoot; try { if (!nodeModulesRoot) { nodeModulesRoot = @@ -88,8 +89,9 @@ export class ProjectVersionHelper { public static async getReactNativeVersions( projectRoot: string, additionalPackagesToCheck?: ParsedPackage[], - nodeModulesRoot?: string, + initialNodeModulesRoot?: string, ): Promise { + let nodeModulesRoot = initialNodeModulesRoot; if (!nodeModulesRoot) { nodeModulesRoot = ProjectsStorage.getFolderByProjectRootPath( @@ -113,8 +115,9 @@ export class ProjectVersionHelper { public static async tryToGetRNSemverValidVersionsFromProjectPackage( projectRoot: string, additionalPackagesToCheck?: ParsedPackage[], - nodeModulesRoot?: string, + initialNodeModulesRoot?: string, ): Promise { + let nodeModulesRoot = initialNodeModulesRoot; const versions = await ProjectVersionHelper.getReactNativeVersionsFromProjectPackage( projectRoot, additionalPackagesToCheck, @@ -186,11 +189,7 @@ export class ProjectVersionHelper { }); const packageVersionArray = await Promise.all(versionPromises); - const packageVersions = packageVersionArray.reduce( - (allPackageVersions, packageVersion) => - Object.assign(allPackageVersions, packageVersion), - {}, - ); + const packageVersions: PackageVersion = Object.assign({}, ...packageVersionArray); if (ProjectVersionHelper.isVersionError(packageVersions["react-native"])) { throw ErrorHelper.getInternalError(InternalErrorCode.ReactNativePackageIsNotInstalled); } diff --git a/src/common/reactNativeProjectHelper.ts b/src/common/reactNativeProjectHelper.ts index 129511be8..c1bb0545d 100644 --- a/src/common/reactNativeProjectHelper.ts +++ b/src/common/reactNativeProjectHelper.ts @@ -101,7 +101,7 @@ export class ReactNativeProjectHelper { return hermesEnabled; } - public static async UpdateMertoBundlerForExpoWeb(launchArgs: any) { + public static async UpdateMertoBundlerForExpoWeb(launchArgs: any): Promise { const appJsonPath = path.join(launchArgs.cwd, "app.json"); const fs = new FileSystem(); const appJson = await fs.readFile(appJsonPath).then(content => { @@ -114,7 +114,7 @@ export class ReactNativeProjectHelper { } } - public static async verifyMetroConfigFile(projectRoot: string) { + public static async verifyMetroConfigFile(projectRoot: string): Promise { const logger = OutputChannelLogger.getChannel(OutputChannelLogger.MAIN_CHANNEL_NAME, true); let version; diff --git a/src/common/telemetryHelper.ts b/src/common/telemetryHelper.ts index 1cc4945b5..854112c8d 100644 --- a/src/common/telemetryHelper.ts +++ b/src/common/telemetryHelper.ts @@ -60,8 +60,9 @@ export class TelemetryHelper { public static addPlatformPropertiesToTelemetryProperties( args: IRunOptions, versions: RNPackageVersions, - properties: ICommandTelemetryProperties, + initialProperties: ICommandTelemetryProperties, ): any { + let properties = initialProperties; properties = TelemetryHelper.addPropertyToTelemetryProperties( versions.reactNativeVersion, "reactNativeVersion", @@ -286,11 +287,11 @@ export class TelemetryHelper { propertyValue: any, isPii: boolean, ): void { - for (let i = 0; i < propertyValue.length; i++) { + for (const [i, value] of propertyValue.entries()) { TelemetryHelper.setTelemetryEventProperty( event, propertyName + String(i), - propertyValue[i], + value, isPii, ); } diff --git a/src/common/utils.ts b/src/common/utils.ts index 0084ecebd..394f19d41 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -46,9 +46,9 @@ export function areSameDates(date1: Date, date2: Date): boolean { ); } -export function getRandomIntInclusive(min: number, max: number): number { - min = Math.ceil(min); - max = Math.floor(max); +export function getRandomIntInclusive(initialMin: number, initialMax: number): number { + const min = Math.ceil(initialMin); + const max = Math.floor(initialMax); return Math.floor(Math.random() * (max - min + 1)) + min; } @@ -127,7 +127,7 @@ export function ipToBuffer(ip: string): Buffer { throw new Error("Invalid IP address format."); } -export async function switchBundleOptions(projectRootPath: string, flag: boolean) { +export async function switchBundleOptions(projectRootPath: string, flag: boolean): Promise { const splitBundleOptionsPath = path.resolve( projectRootPath, "node_modules", diff --git a/src/debugger/debugSessionBase.ts b/src/debugger/debugSessionBase.ts index 095ab1625..ee3937d49 100644 --- a/src/debugger/debugSessionBase.ts +++ b/src/debugger/debugSessionBase.ts @@ -17,7 +17,6 @@ import { ILaunchArgs, IRunOptions, PlatformType } from "../extension/launchArgs" import type { AppLauncher } from "../extension/appLauncher"; import { RNPackageVersions } from "../common/projectVersionHelper"; import { OutputChannelLogger } from "../extension/log/OutputChannelLogger"; -import { DeviceStatusIndicator } from "../extension/deviceStatusIndicator"; import { RNSession } from "./debugSessionWrapper"; nls.config({ @@ -215,7 +214,7 @@ export abstract class DebugSessionBase extends LoggingDebugSession { ); } const settingsPort = this.appLauncher.getPackagerPort(projectRootPath); - if (this.appLauncher.getPackager().getPort() != settingsPort) { + if (this.appLauncher.getPackager().getPort() !== settingsPort) { this.appLauncher.getPackager().resetToSettingsPort(); } } @@ -229,10 +228,11 @@ export abstract class DebugSessionBase extends LoggingDebugSession { ): Promise { if (this.appLauncher) { await this.appLauncher.getRnCdpProxy().stopServer(); + if (args.terminateDebuggee && !args.restart) { + await this.appLauncher.getPackager().stop(true); + } } - DeviceStatusIndicator.hide(); - this.cancellationTokenSource.cancel(); this.cancellationTokenSource.dispose(); diff --git a/src/debugger/direct/directDebugSession.ts b/src/debugger/direct/directDebugSession.ts index e8e9f5dce..d1deb8262 100644 --- a/src/debugger/direct/directDebugSession.ts +++ b/src/debugger/direct/directDebugSession.ts @@ -79,7 +79,7 @@ export class DirectDebugSession extends DebugSessionBase { try { try { - if (launchArgs.platform != "exponent") { + if (launchArgs.platform !== "exponent") { await ReactNativeProjectHelper.verifyMetroConfigFile(launchArgs.cwd); } await this.initializeSettings(launchArgs); @@ -184,11 +184,12 @@ export class DirectDebugSession extends DebugSessionBase { // eslint-disable-next-line @typescript-eslint/no-unused-vars await TelemetryHelper.generate("attach", extProps, async generator => { - const port = attachArgs.useHermesEngine - ? attachArgs.port || this.appLauncher.getPackagerPort(attachArgs.cwd) - : attachArgs.platform === PlatformType.iOS - ? attachArgs.port || IWDPHelper.iOS_WEBKIT_DEBUG_PROXY_DEFAULT_PORT - : null; + let port: number | null = null; + if (attachArgs.useHermesEngine) { + port = attachArgs.port || this.appLauncher.getPackagerPort(attachArgs.cwd); + } else if (attachArgs.platform === PlatformType.iOS) { + port = attachArgs.port || IWDPHelper.iOS_WEBKIT_DEBUG_PROXY_DEFAULT_PORT; + } if (port === null) { throw ErrorHelper.getInternalError( InternalErrorCode.CouldNotDirectDebugWithoutHermesEngine, @@ -199,11 +200,12 @@ export class DirectDebugSession extends DebugSessionBase { logger.log(`Connecting to ${attachArgs.port} port`); await this.appLauncher.getRnCdpProxy().stopServer(); - const cdpMessageHandler: BaseCDPMessageHandler | null = attachArgs.useHermesEngine - ? new HermesCDPMessageHandler() - : attachArgs.platform === PlatformType.iOS - ? new IOSDirectCDPMessageHandler() - : null; + let cdpMessageHandler: BaseCDPMessageHandler | null = null; + if (attachArgs.useHermesEngine) { + cdpMessageHandler = new HermesCDPMessageHandler(); + } else if (attachArgs.platform === PlatformType.iOS) { + cdpMessageHandler = new IOSDirectCDPMessageHandler(); + } if (!cdpMessageHandler) { throw ErrorHelper.getInternalError( @@ -262,7 +264,7 @@ export class DirectDebugSession extends DebugSessionBase { const debuggerType = await this.debuggerEndpointHelper.getDebuggerTpye( `http://localhost:${attachArgs.port}`, ); - if (debuggerType == "expo") { + if (debuggerType === "expo") { const expoBrowserInspectUri = `${browserInspectUri.split("&")[0]}&page=2`; this.appLauncher.getRnCdpProxy().setBrowserInspectUri(expoBrowserInspectUri); } else { @@ -291,7 +293,7 @@ export class DirectDebugSession extends DebugSessionBase { this.debugSessionStatus = DebugSessionStatus.Stopping; // Stop packager when using expo-cli, to avoid launch conflicts in the next launch request - if (this.appLauncher.getPackager().getPlatform() == "exponent") { + if (this.appLauncher.getPackager().getPlatform() === "exponent") { await this.appLauncher.getPackager().stop(); } diff --git a/src/debugger/forkedAppWorker.ts b/src/debugger/forkedAppWorker.ts index 691599e15..8c6836798 100644 --- a/src/debugger/forkedAppWorker.ts +++ b/src/debugger/forkedAppWorker.ts @@ -136,7 +136,8 @@ export class ForkedAppWorker implements IDebuggeeWorker { return port; } - public async postMessage(rnMessage: RNAppMessage): Promise { + public async postMessage(rawMessage: RNAppMessage): Promise { + let rnMessage = rawMessage; // Before sending messages, make sure that the worker is loaded await new Promise(resolve => { if (this.workerLoaded) { diff --git a/src/debugger/jsDebugConfigAdapter.ts b/src/debugger/jsDebugConfigAdapter.ts index 00e3f84b3..e81a0c481 100644 --- a/src/debugger/jsDebugConfigAdapter.ts +++ b/src/debugger/jsDebugConfigAdapter.ts @@ -30,7 +30,7 @@ export class JsDebugConfigAdapter { attachArgs: IAttachRequestArgs, cdpProxyPort: number, sessionId: string, - ) { + ): Promise { const extraArgs: any = {}; const versions = await ProjectVersionHelper.getReactNativeVersions(attachArgs.cwd); // Handle project file path from 0.76 diff --git a/src/debugger/rnDebugSession.ts b/src/debugger/rnDebugSession.ts index 4227caba8..9deb0d474 100644 --- a/src/debugger/rnDebugSession.ts +++ b/src/debugger/rnDebugSession.ts @@ -59,7 +59,7 @@ export class RNDebugSession extends DebugSessionBase { ): Promise { try { try { - if (launchArgs.platform != "exponent") { + if (launchArgs.platform !== "exponent") { await ReactNativeProjectHelper.verifyMetroConfigFile(launchArgs.cwd); } const requiredNodeRange = diff --git a/src/debugger/sourceMap.ts b/src/debugger/sourceMap.ts index f9f4bba02..fc026b7da 100644 --- a/src/debugger/sourceMap.ts +++ b/src/debugger/sourceMap.ts @@ -103,7 +103,8 @@ export class SourceMapUtil { } } - public appendSourceMapPaths(scriptBody: string, sourceMappingUrl: string): string { + public appendSourceMapPaths(initialScriptBody: string, sourceMappingUrl: string): string { + let scriptBody = initialScriptBody; scriptBody += `//# sourceMappingURL=${sourceMappingUrl}`; return scriptBody; } @@ -176,11 +177,14 @@ export class SourceMapUtil { * 2. It changes the path separators to Unix style. */ private updateSourceMapPath( - sourcePath: string, + initialSourcePath: string, sourcesRootPath: string, - packagerRemoteRoot?: string, - packagerLocalRoot?: string, + initialPackagerRemoteRoot?: string, + initialPackagerLocalRoot?: string, ) { + let sourcePath = initialSourcePath; + let packagerRemoteRoot = initialPackagerRemoteRoot; + let packagerLocalRoot = initialPackagerLocalRoot; if (packagerRemoteRoot && packagerLocalRoot) { packagerRemoteRoot = this.makeUnixStylePath(packagerRemoteRoot); packagerLocalRoot = this.makeUnixStylePath(packagerLocalRoot); diff --git a/src/debugger/sourceMapsCombinator.ts b/src/debugger/sourceMapsCombinator.ts index 76ac031c0..7208ca7c9 100644 --- a/src/debugger/sourceMapsCombinator.ts +++ b/src/debugger/sourceMapsCombinator.ts @@ -22,17 +22,14 @@ const DISK_LETTER_RE = /^(?:[a-z]{2,}:\/{3})?[a-z]:/i; export class SourceMapsCombinator { public convert(rawBundleSourcemap: RawSourceMap): RawSourceMap { // Find user files from bundle files list - const consumers: { [key: string]: SourceMapConsumer } = rawBundleSourcemap.sources.reduce( - (result: { [key: string]: SourceMapConsumer }, file) => { - // Skip files inside node_modules - if (file.includes("node_modules")) return result; - - const consumer: SourceMapConsumer | null = this.getSourceMapConsumerFrom(file); - if (consumer) result[file] = consumer; - return result; - }, - {}, - ); + const consumers: { [key: string]: SourceMapConsumer } = {}; + for (const file of rawBundleSourcemap.sources) { + // Skip files inside node_modules + if (file.includes("node_modules")) continue; + + const consumer: SourceMapConsumer | null = this.getSourceMapConsumerFrom(file); + if (consumer) consumers[file] = consumer; + } if (Object.keys(consumers).length === 0) { // Sourcemaps not found, so return original bundle sourcemap diff --git a/src/debugger/webDebugSession.ts b/src/debugger/webDebugSession.ts index c0e89d82b..ea71b14ff 100644 --- a/src/debugger/webDebugSession.ts +++ b/src/debugger/webDebugSession.ts @@ -68,7 +68,7 @@ export class WebDebugSession extends DebugSessionBase { logger.verbose(`Launching the application: ${JSON.stringify(launchArgs, null, 2)}`); await this.updateWebpackMetroConfig(launchArgs); - await this.verifyExpoWebRequiredDependencies(launchArgs); + this.verifyExpoWebRequiredDependencies(launchArgs); await this.appLauncher.launchExpoWeb(launchArgs); await this.waitExpoWebIsRunning(launchArgs); await this.appLauncher.launchBrowser(launchArgs); @@ -89,7 +89,6 @@ export class WebDebugSession extends DebugSessionBase { protected async attachRequest( response: DebugProtocol.AttachResponse, attachArgs: IAttachRequestArgs, - request?: DebugProtocol.Request, ): Promise { const doAttach = async (attachArgs: IAttachRequestArgs) => { try { @@ -128,7 +127,7 @@ export class WebDebugSession extends DebugSessionBase { } }, ); - await this.establishDebugSession(processedAttachArgs); + this.establishDebugSession(processedAttachArgs); this.debugSessionStatus = DebugSessionStatus.ConnectionDone; }); @@ -162,10 +161,7 @@ export class WebDebugSession extends DebugSessionBase { return super.disconnectRequest(response, args, request); } - protected establishDebugSession( - attachArgs: IAttachRequestArgs, - resolve?: (value?: void | PromiseLike | undefined) => void, - ): void { + protected establishDebugSession(attachArgs: IAttachRequestArgs): void { if (this.cdpProxy) { const attachArguments = JsDebugConfigAdapter.createChromeDebuggingConfig( attachArgs, diff --git a/src/extension/android/adb.ts b/src/extension/android/adb.ts index 2fc99fbdd..d51b2cddf 100644 --- a/src/extension/android/adb.ts +++ b/src/extension/android/adb.ts @@ -279,7 +279,7 @@ export class AdbHelper { const result: IDebuggableMobileTarget[] = []; const regex = new RegExp("^(\\S+)\\t(\\S+)$", "mg"); let match = regex.exec(input); - while (match != null) { + while (match !== null) { result.push({ id: match[1], isOnline: match[2] === "device", diff --git a/src/extension/android/androidContainerUtility.ts b/src/extension/android/androidContainerUtility.ts index 05d6b4502..f25813a26 100644 --- a/src/extension/android/androidContainerUtility.ts +++ b/src/extension/android/androidContainerUtility.ts @@ -41,7 +41,7 @@ class RunAsError extends Error { * @format */ -export function push( +export async function push( adbHelper: AdbHelper, deviceId: string, app: string, @@ -49,27 +49,22 @@ export function push( contents: string, logger?: OutputChannelLogger, ): Promise { - return validateAppName(app).then(validApp => - validateFilePath(filepath).then(validFilepath => - validateFileContent(contents).then(validContent => - _push(adbHelper, deviceId, validApp, validFilepath, validContent, logger), - ), - ), - ); + const validApp = await validateAppName(app); + const validFilepath = await validateFilePath(filepath); + const validContent = await validateFileContent(contents); + return _push(adbHelper, deviceId, validApp, validFilepath, validContent, logger); } -export function pull( +export async function pull( adbHelper: AdbHelper, deviceId: string, app: string, path: string, logger?: OutputChannelLogger, ): Promise { - return validateAppName(app).then(validApp => - validateFilePath(path).then(validPath => - _pull(adbHelper, deviceId, validApp, validPath, logger), - ), - ); + const validApp = await validateAppName(app); + const validPath = await validateFilePath(path); + return _pull(adbHelper, deviceId, validApp, validPath, logger); } /** @@ -152,7 +147,7 @@ function validateFileContent(content: string): Promise { return Promise.reject(new Error(`Disallowed escaping file content: ${content}`)); } -function _push( +async function _push( adbHelper: AdbHelper, deviceId: string, app: string, @@ -161,25 +156,25 @@ function _push( logger?: OutputChannelLogger, ): Promise { const command = `echo \\"${contents}\\" > "${filename}" && chmod 644 "${filename}"`; - return executeCommandAsApp(adbHelper, deviceId, app, command) - .then(res => { - logger?.debug(res); - }) - .catch(error => { - if (error instanceof RunAsError) { - // Fall back to running the command directly. This will work if adb is running as root. - return executeCommandWithSu(adbHelper, deviceId, app, command) - .then(() => undefined) - .catch(e => { - logger?.debug(e.toString()); - throw error; - }); + try { + const res = await executeCommandAsApp(adbHelper, deviceId, app, command); + logger?.debug(res); + } catch (error) { + if (error instanceof RunAsError) { + // Fall back to running the command directly. This will work if adb is running as root. + try { + await executeCommandWithSu(adbHelper, deviceId, app, command); + return; + } catch (e) { + logger?.debug(String(e)); + throw error; } - throw error; - }); + } + throw error; + } } -function _pull( +async function _pull( adbHelper: AdbHelper, deviceId: string, app: string, @@ -187,17 +182,21 @@ function _pull( logger?: OutputChannelLogger, ): Promise { const command = `cat "${path}"`; - return executeCommandAsApp(adbHelper, deviceId, app, command).catch(error => { + try { + return await executeCommandAsApp(adbHelper, deviceId, app, command); + } catch (error) { if (error instanceof RunAsError) { // Fall back to running the command directly. This will work if adb is running as root. - return executeCommandWithSu(adbHelper, deviceId, app, command).catch(e => { + try { + return await executeCommandWithSu(adbHelper, deviceId, app, command); + } catch (e) { // Throw the original error. - logger?.debug(e.toString()); + logger?.debug(String(e)); throw error; - }); + } } throw error; - }); + } } // Keep this method private since it relies on pre-validated arguments diff --git a/src/extension/android/androidDeviceTracker.ts b/src/extension/android/androidDeviceTracker.ts index 6fb8d5da7..f64e858a8 100644 --- a/src/extension/android/androidDeviceTracker.ts +++ b/src/extension/android/androidDeviceTracker.ts @@ -44,6 +44,7 @@ export class AndroidDeviceTracker extends AbstractDeviceTracker { onlineDevice.isVirtualTarget, ClientOS.Android, ); + // eslint-disable-next-line no-await-in-loop -- adb reverse is set up per device sequentially; keeps fail-fast and insertion order await this.initAndroidDevice(androidDevice); DeviceStorage.devices.set(androidDevice.id, androidDevice); } diff --git a/src/extension/android/androidPlatform.ts b/src/extension/android/androidPlatform.ts index 83c404dc5..c75a1bf2d 100644 --- a/src/extension/android/androidPlatform.ts +++ b/src/extension/android/androidPlatform.ts @@ -201,13 +201,22 @@ export class AndroidPlatform extends GeneralMobilePlatform { target => target.id, ); let targetId: string | undefined; + let runError: unknown; try { try { await output; + } catch (error) { + runError = error; + throw error; } finally { - targetId = await this.getTargetIdForRunApp(onlineTargetsIds); + targetId = await this.getTargetIdForRunApp(onlineTargetsIds).catch(error => { + if (runError && onlineTargetsIds.length) { + return undefined; + } + throw error; + }); this.packageName = await this.getPackageName(); - devicesIdsForLaunch = [targetId]; + devicesIdsForLaunch = targetId ? [targetId] : []; // Save target info for status indicator if (targetId) { @@ -244,9 +253,6 @@ export class AndroidPlatform extends GeneralMobilePlatform { } } } catch (error) { - if (!targetId) { - targetId = await this.getTargetIdForRunApp(onlineTargetsIds); - } if ( (error as Error).message === ErrorHelper.getInternalError( @@ -343,14 +349,19 @@ export class AndroidPlatform extends GeneralMobilePlatform { "--deviceId", ); } - return deviceId - ? deviceId - : this.runOptions.target && - this.runOptions.target !== TargetType.Simulator && - this.runOptions.target !== TargetType.Device && - onlineTargetsIds.find(id => id === this.runOptions.target) - ? this.runOptions.target - : (await this.getTarget()).id; + if (deviceId) { + return deviceId; + } + const { target } = this.runOptions; + if ( + target && + target !== TargetType.Simulator && + target !== TargetType.Device && + onlineTargetsIds.includes(target) + ) { + return target; + } + return (await this.getTarget()).id; } private getAppIdSuffixFromRunArgumentsIfExists(): string | undefined { diff --git a/src/extension/android/androidTargetManager.ts b/src/extension/android/androidTargetManager.ts index 5954e4fb2..436bf13bc 100644 --- a/src/extension/android/androidTargetManager.ts +++ b/src/extension/android/androidTargetManager.ts @@ -119,6 +119,7 @@ export class AndroidTargetManager extends MobileTargetManager { const onlineTargets = await this.adbHelper.getOnlineTargets(); for (const device of onlineTargets) { if (device.isVirtualTarget && collectSimulators) { + // eslint-disable-next-line no-await-in-loop -- adb is queried per device sequentially const avdName = await this.adbHelper.getAvdNameById(device.id); const emulatorTarget = targetList.find(target => target.name === avdName); if (emulatorTarget) { @@ -139,12 +140,19 @@ export class AndroidTargetManager extends MobileTargetManager { return this.selectTarget(filter); } - protected async launchSimulator(emulatorTarget: IMobileTarget): Promise { + public async launchSimulator( + emulatorTarget: IMobileTarget, + coldBoot = false, + ): Promise { return new Promise((resolve, reject) => { let emulatorLaunchFailed = false; const emulatorProcess = this.childProcess.spawn( AndroidTargetManager.EMULATOR_COMMAND, - [AndroidTargetManager.EMULATOR_AVD_START_COMMAND, emulatorTarget.name as string], + [ + AndroidTargetManager.EMULATOR_AVD_START_COMMAND, + emulatorTarget.name as string, + ...(coldBoot ? ["-no-snapshot-load"] : []), + ], { detached: true, }, @@ -153,7 +161,7 @@ export class AndroidTargetManager extends MobileTargetManager { emulatorProcess.outcome.catch(error => { emulatorLaunchFailed = true; if ( - process.platform == "win32" && + process.platform === "win32" && process.env.SESSIONNAME && process.env.SESSIONNAME.toLowerCase().includes("rdp-tcp") ) { @@ -175,6 +183,7 @@ export class AndroidTargetManager extends MobileTargetManager { throw new Error("Android emulator launch failed unexpectedly"); const connectedDevices = await this.adbHelper.getOnlineTargets(); for (const target of connectedDevices) { + // eslint-disable-next-line no-await-in-loop -- polling condition, returns on first match const onlineAvdName = await this.adbHelper.getAvdNameById(target.id); if (onlineAvdName === emulatorTarget.name) { return target.id; diff --git a/src/extension/appLauncher.ts b/src/extension/appLauncher.ts index d0f786af4..3beb498a1 100644 --- a/src/extension/appLauncher.ts +++ b/src/extension/appLauncher.ts @@ -38,7 +38,6 @@ import { LaunchScenariosManager } from "./launchScenariosManager"; import { createAdditionalWorkspaceFolder, onFolderAdded } from "./rn-extension"; import { RNProjectObserver } from "./rnProjectObserver"; import { GeneralMobilePlatform } from "./generalMobilePlatform"; -import { DeviceStatusIndicator } from "./deviceStatusIndicator"; nls.config({ messageFormat: nls.MessageFormat.bundle, @@ -205,7 +204,6 @@ export class AppLauncher { this.packager.getStatusIndicator().dispose(); void this.packager.stop(true); this.mobilePlatform.dispose(); - DeviceStatusIndicator.hide(); } public async openFileAtLocation(filename: string, lineNumber: number): Promise { @@ -341,14 +339,6 @@ export class AppLauncher { await this.mobilePlatform.runApp(); } - // Show device name in status bar after app is launched - if (this.mobilePlatform instanceof GeneralMobilePlatform) { - const target = this.mobilePlatform.getResolvedTarget(); - if (target?.name) { - DeviceStatusIndicator.show(target.name); - } - } - if (mobilePlatformOptions.isDirect) { if (launchArgs.useHermesEngine) { generator.step("mobilePlatform.enableHermesDebuggingMode"); @@ -519,7 +509,7 @@ export class AppLauncher { // Launch browser let browserFinder: BrowserHelper.IBrowserFinder; - if (launchArgs.platform == PlatformType.ExpoWeb) { + if (launchArgs.platform === PlatformType.ExpoWeb) { switch (launchArgs.browserTarget) { case BROWSER_TYPES.Edge: browserFinder = new BrowserHelper.EdgeBrowserFinder( @@ -563,9 +553,9 @@ export class AppLauncher { public getRunArguments(launchArgs: any): string[] { let userDataDir; - if (launchArgs.browserTarget == BROWSER_TYPES.Chrome) { + if (launchArgs.browserTarget === BROWSER_TYPES.Chrome) { userDataDir = path.join(HostPlatform.getSettingsHome(), AppLauncher.CHROME_DATA_DIR); - } else if (launchArgs.browserTarget == BROWSER_TYPES.Edge) { + } else if (launchArgs.browserTarget === BROWSER_TYPES.Edge) { userDataDir = path.join(HostPlatform.getSettingsHome(), AppLauncher.EDGE_DATA_DIR); } else { userDataDir = ""; diff --git a/src/extension/commands/cleanRestartPackager.ts b/src/extension/commands/cleanRestartPackager.ts index dae1b2baa..6ba1534c7 100644 --- a/src/extension/commands/cleanRestartPackager.ts +++ b/src/extension/commands/cleanRestartPackager.ts @@ -71,6 +71,7 @@ export class CleanRestartPackager extends ReactNativeCommand { if (match && match[1]) { const pid = match[1]; logger.info(`Found Metro process with PID: ${pid}`); + // eslint-disable-next-line no-await-in-loop -- kill PIDs one at a time; taskkill /T kills process trees, so later PIDs may already be gone await childProcess .exec(`taskkill /PID ${pid} /F /T`) .then(killResult => killResult.outcome); @@ -143,6 +144,7 @@ export class CleanRestartPackager extends ReactNativeCommand { const stat = fs.statSync(filePath); if (stat.isDirectory()) { + // eslint-disable-next-line no-await-in-loop -- entries are removed sequentially before rmdir await this.deleteDirectory(filePath); } else { fs.unlinkSync(filePath); diff --git a/src/extension/commands/index.ts b/src/extension/commands/index.ts index 407a37a1b..1e542ad35 100644 --- a/src/extension/commands/index.ts +++ b/src/extension/commands/index.ts @@ -16,6 +16,7 @@ export * from "./runIos"; export * from "./runMacOs"; export * from "./runWindows"; export * from "./selectAndInsertDebugConfiguration"; +export * from "./selectDevice"; export * from "./showDevMenu"; export * from "./startLogCatMonitor"; export * from "./startPackager"; diff --git a/src/extension/commands/installExpoGoApplication.ts b/src/extension/commands/installExpoGoApplication.ts index c7e03110f..c6c47c1e1 100644 --- a/src/extension/commands/installExpoGoApplication.ts +++ b/src/extension/commands/installExpoGoApplication.ts @@ -46,7 +46,7 @@ export class InstallExpoGoApplication extends Command { const currentSdkVersion = await expoHelper.exponentSdk(true); const expoUrlInfo = jsonContent.sdkVersions[currentSdkVersion]; - if (item == "Android") { + if (item === "Android") { void vscode.window.showInformationMessage("Downloading Expo Go for Android."); logger.logStream( localize("DownloadAndroidExpoGo", "\nDownloading Expo Go for Android. \n"), @@ -70,7 +70,7 @@ export class InstallExpoGoApplication extends Command { } } - if (installItem == "Auto") { + if (installItem === "Auto") { try { await installAndroidApplication(this.project, fileName); } catch { @@ -86,8 +86,8 @@ export class InstallExpoGoApplication extends Command { ), ); } - } else if (item == "iOS") { - if (os.platform() != "darwin") { + } else if (item === "iOS") { + if (os.platform() !== "darwin") { logger.warning( localize( "NotDarwinPlatform", @@ -122,7 +122,7 @@ export class InstallExpoGoApplication extends Command { } } - if (installItem == "Auto" && os.platform() == "darwin") { + if (installItem === "Auto" && os.platform() === "darwin") { try { await installiOSApplication(this.project, tarFile); } catch { diff --git a/src/extension/commands/launchExpoWeb.ts b/src/extension/commands/launchExpoWeb.ts index 4e78737ef..cb86ecf85 100644 --- a/src/extension/commands/launchExpoWeb.ts +++ b/src/extension/commands/launchExpoWeb.ts @@ -20,7 +20,7 @@ export class launchExpoWeb extends Command { label = "Launch ExpoWeb"; error = ErrorHelper.getInternalError(InternalErrorCode.FailedToLaunchExpoWeb); - async baseFn(launchArgs: any): Promise { + async baseFn(): Promise { assert(this.project); const expoHelper = this.project.getExponentHelper(); logger.info(localize("CheckExpoEnvironment", "Checking Expo project environment.")); diff --git a/src/extension/commands/openEASProject.ts b/src/extension/commands/openEASProject.ts index 268115c21..aa5309097 100644 --- a/src/extension/commands/openEASProject.ts +++ b/src/extension/commands/openEASProject.ts @@ -42,14 +42,14 @@ export class OpenEASProject extends ReactNativeCommand { await expoHelper.getExpoEasProjectName().then(result => { name = result; }); - if (id == null || owner == null) { + if (id === null || owner === null) { const error = localize( "ExpoProjectNotLinkToEAS", "Your app not link to EAS project. Please run 'eas init' firstly to bind your app to EAS project.", ); void vscode.window.showErrorMessage(error); logger.error(error); - } else if (name != null) { + } else if (name !== null) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions const url = `https://expo.dev/accounts/${owner}/projects/${name}`; await vscode.env.openExternal(vscode.Uri.parse(url)); diff --git a/src/extension/commands/revertOpenModule.ts b/src/extension/commands/revertOpenModule.ts index f417acf2c..75acade5c 100644 --- a/src/extension/commands/revertOpenModule.ts +++ b/src/extension/commands/revertOpenModule.ts @@ -78,7 +78,7 @@ export class RevertOpenModule extends ReactNativeCommand { const mainFilePath = path.resolve(openModulePath, "open-main.js"); if (fs.existsSync(mainFilePath)) { try { - await fs.unlinkSync(mainFilePath); + fs.unlinkSync(mainFilePath); } catch { logger.error( localize("FailedToDeleteMainFile", "Failed to delete open-main.js file."), @@ -95,10 +95,10 @@ export class RevertOpenModule extends ReactNativeCommand { const packageFilePath = path.resolve(openModulePath, "package.json"); const packageJson = JSON.parse(fs.readFileSync(packageFilePath, "utf-8")); - if (packageJson.main == "open-main.js") { + if (packageJson.main === "open-main.js") { try { delete packageJson.main; - await fs.writeFileSync( + fs.writeFileSync( packageFilePath, JSON.stringify(>packageJson), ); diff --git a/src/extension/commands/runEasBuild.ts b/src/extension/commands/runEasBuild.ts index 7481462ea..818f110b3 100644 --- a/src/extension/commands/runEasBuild.ts +++ b/src/extension/commands/runEasBuild.ts @@ -15,8 +15,8 @@ export class runEasBuild extends ReactNativeCommand { error = ErrorHelper.getInternalError(InternalErrorCode.FailedToRunEasBuild); async baseFn(): Promise { assert(this.project); - const packager = await this.project.getPackager(); - const projectRootPath = await packager.getProjectPath(); + const packager = this.project.getPackager(); + const projectRootPath = packager.getProjectPath(); if (!projectRootPath) { void vscode.window.showErrorMessage( diff --git a/src/extension/commands/selectDevice.ts b/src/extension/commands/selectDevice.ts new file mode 100644 index 000000000..ce3d298f0 --- /dev/null +++ b/src/extension/commands/selectDevice.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +import { ErrorHelper } from "../../common/error/errorHelper"; +import { InternalErrorCode } from "../../common/error/internalErrorCode"; +import { DeviceSelector } from "../deviceSelector"; +import { Command } from "./util/command"; + +export class SelectDevice extends Command { + codeName = "selectDevice"; + label = "Select Device"; + requiresProject = false; + error = ErrorHelper.getInternalError(InternalErrorCode.FailedToSelectDevice); + + async baseFn(): Promise { + await DeviceSelector.instance?.showPicker(); + } +} diff --git a/src/extension/commands/stopPackager.ts b/src/extension/commands/stopPackager.ts index 096d885a5..af05442b2 100644 --- a/src/extension/commands/stopPackager.ts +++ b/src/extension/commands/stopPackager.ts @@ -17,11 +17,8 @@ export class StopPackager extends ReactNativeCommand<[AppLauncher]> { async onBeforeExecute(appLauncher: AppLauncher): Promise { await super.onBeforeExecute(appLauncher); - if (appLauncher instanceof AppLauncher) { - this.project = appLauncher; - } else { - this.project = await this.selectProject(); - } + this.project = + appLauncher instanceof AppLauncher ? appLauncher : await this.selectProject(); } // this function requires argument because we need it in extension 'deactivate' hook diff --git a/src/extension/commands/testDevEnvironment.ts b/src/extension/commands/testDevEnvironment.ts index a4eae2a31..77b85003b 100644 --- a/src/extension/commands/testDevEnvironment.ts +++ b/src/extension/commands/testDevEnvironment.ts @@ -64,13 +64,11 @@ export class TestDevEnvironment extends Command { [ValidationCategoryE.macOS]: (projectObserver && projectObserver.isRNMacosProject) || false, } as const; - if (project && projectObserver) { - await runChecks( - shouldCheck, - RNPackageVersionsToPackageVersion(projectObserver.rnPackageVersions), - ); - } else { - await runChecks(shouldCheck); - } + await (project && projectObserver + ? runChecks( + shouldCheck, + RNPackageVersionsToPackageVersion(projectObserver.rnPackageVersions), + ) + : runChecks(shouldCheck)); } } diff --git a/src/extension/commands/util/index.ts b/src/extension/commands/util/index.ts index 8d413ceba..f126e1de1 100644 --- a/src/extension/commands/util/index.ts +++ b/src/extension/commands/util/index.ts @@ -112,7 +112,10 @@ export async function selectProject(): Promise { return ProjectsStorage.projectsCache[selected]; } -export async function sendMessageToMetro(method: "devMenu" | "reload", project: AppLauncher) { +export async function sendMessageToMetro( + method: "devMenu" | "reload", + project: AppLauncher, +): Promise { const port = SettingsHelper.getPackagerPort(project.getWorkspaceFolderUri().fsPath); const ws = new WebSocket(`ws://localhost:${port}/message`); await new Promise(resolve => { diff --git a/src/extension/debuggingConfiguration/multiStepInput.ts b/src/extension/debuggingConfiguration/multiStepInput.ts index d4f48a48a..418a0f267 100644 --- a/src/extension/debuggingConfiguration/multiStepInput.ts +++ b/src/extension/debuggingConfiguration/multiStepInput.ts @@ -112,11 +112,7 @@ export class MultiStepInput implements IMultiStepInput { input.items = items; input.matchOnDescription = matchOnDescription || false; input.matchOnDetail = matchOnDetail || false; - if (activeItem) { - input.activeItems = [activeItem]; - } else { - input.activeItems = []; - } + input.activeItems = activeItem ? [activeItem] : []; input.buttons = [ ...(this.steps.length > 1 ? [QuickInputButtons.Back] : []), ...(buttons || []), @@ -242,6 +238,7 @@ export class MultiStepInput implements IMultiStepInput { this.current.busy = true; } try { + // eslint-disable-next-line no-await-in-loop -- each step depends on the previous step's result step = await step(this, state); } catch (err) { if (err === InputFlowAction.back) { diff --git a/src/extension/deviceSelector.ts b/src/extension/deviceSelector.ts new file mode 100644 index 000000000..934d2c905 --- /dev/null +++ b/src/extension/deviceSelector.ts @@ -0,0 +1,573 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +import * as vscode from "vscode"; +import * as nls from "vscode-nls"; +import { ChildProcess } from "../common/node/childProcess"; +import { AdbHelper } from "./android/adb"; +import { AndroidTargetManager } from "./android/androidTargetManager"; +import { TargetType } from "./generalPlatform"; +import { IDebuggableIOSTarget, IOSTargetManager } from "./ios/iOSTargetManager"; +import { PlatformType } from "./launchArgs"; +import { OutputChannelLogger } from "./log/OutputChannelLogger"; +import { IDebuggableMobileTarget } from "./mobileTarget"; +import { ProjectsStorage } from "./projectsStorage"; + +nls.config({ + messageFormat: nls.MessageFormat.bundle, + bundleFormat: nls.BundleFormat.standalone, +})(); +const localize = nls.loadMessageBundle(); + +export interface Device { + platform: PlatformType.Android | PlatformType.iOS; + /** Stable identity, saved and passed as `target`: AVD name, Android serial or iOS UDID. */ + key: string; + /** Runtime id shown in the picker: serial (emulator-5554) or UDID. */ + id: string; + name: string; + isVirtual: boolean; + isOnline: boolean; + /** iOS only, e.g. "iOS 27.0". */ + system?: string; +} + +export interface DeviceItem extends vscode.QuickPickItem { + device?: Device; + coldBoot?: boolean; +} + +export function statusBarText(device: Device | undefined): string { + if (!device) { + return localize("NoDevice", "No Device"); + } + let kind = ""; + if (device.isVirtual) { + kind = device.platform === PlatformType.iOS ? " simulator" : " emulator"; + } + return `${device.name} (${device.platform}${kind})`; +} + +export function statusBarTooltip(devices: Device[]): string | undefined { + const count = devices.filter(device => device.isOnline).length; + if (count === 0) { + return undefined; + } + return count === 1 + ? localize("OneDeviceConnected", "1 Device Connected") + : localize("DevicesConnected", "{0} Devices Connected", count); +} + +export function buildPickerItems(devices: Device[], current: Device | undefined): DeviceItem[] { + const isCurrent = (device: Device) => device.key === current?.key; + const online = devices + .filter(device => device.isOnline) + .sort( + (a, b) => Number(isCurrent(b)) - Number(isCurrent(a)) || a.name.localeCompare(b.name), + ); + const offline = devices.filter(device => !device.isOnline && device.isVirtual); + const items: DeviceItem[] = []; + + if (online.length) { + items.push({ + kind: vscode.QuickPickItemKind.Separator, + label: localize("AvailableDevices", "Available Devices"), + }); + for (const device of online) { + items.push({ + label: `$(device-mobile) ${device.name}`, + description: `${device.id} - ${device.platform}`, + detail: isCurrent(device) ? localize("CurrentDevice", "Current Device") : undefined, + device, + }); + } + } + + if (offline.length) { + items.push({ + kind: vscode.QuickPickItemKind.Separator, + label: localize("OfflineEmulators", "Offline Emulators"), + }); + for (const device of offline) { + const label = `$(play) ${localize("StartDevice", "Start {0}", device.name)}`; + if (device.platform === PlatformType.Android) { + items.push({ label, description: "mobile emulator", device }); + items.push({ + label, + description: "mobile emulator (cold boot)", + device, + coldBoot: true, + }); + } else { + items.push({ + label, + description: `mobile simulator (${device.system ?? ""})`, + device, + }); + } + } + } + + return items; +} + +/** + * Picks the current device after a refresh. Pass `previous === undefined` on the first refresh. + * A newly connected device is selected unless the current one is the device the user picked + * (`saved`); the picked device is always re-selected when it reconnects. + */ +export function nextCurrent( + current: Device | undefined, + previous: Device[] | undefined, + devices: Device[], + saved: string | undefined, +): Device | undefined { + const online = devices.filter(device => device.isOnline); + const find = (key: string | undefined) => online.find(device => device.key === key); + const fallback = () => find(saved) ?? online[0]; + if (!previous) { + return fallback(); + } + + const wasOnline = new Set(previous.filter(device => device.isOnline).map(device => device.key)); + let next = find(current?.key); + for (const device of online) { + const isNew = !wasOnline.has(device.key); + if (isNew && (!next || device.key === saved || next.key !== saved)) { + next = device; + } + } + return next ?? fallback(); +} + +/** + * Fills `config.target` from the selected device when the launch config leaves the target open. + * Returns a note for the output channel when the selection could not be used. + */ +export function applySelectedDevice( + config: vscode.DebugConfiguration, + device: Device | undefined, +): string | undefined { + if ( + config.request !== "launch" || + (config.platform !== PlatformType.Android && config.platform !== PlatformType.iOS) + ) { + return undefined; + } + const target = typeof config.target === "string" ? config.target.toLowerCase() : ""; + if (target && target !== TargetType.Simulator && target !== TargetType.Device) { + return undefined; // an explicit target always wins + } + if (!device) { + return localize( + "NoDeviceSelected", + "No device selected; using the default target selection.", + ); + } + if (device.platform !== config.platform) { + return localize( + "SelectedDeviceMismatch", + "Selected device '{0}' ({1}) doesn't match '{2}' ({3}); using the default target selection.", + device.name, + device.platform, + config.name, + config.platform, + ); + } + const typeMatches = + !target || + (target === TargetType.Simulator && device.isVirtual) || + (target === TargetType.Device && !device.isVirtual); + if (typeMatches) { + config.target = device.key; + } + return undefined; +} + +interface RunningAndroidTarget { + id: string; + isVirtual: boolean; + name: string; +} + +/** Running emulators are keyed by AVD name (serials change between boots), phones by serial. */ +export function toAndroidDevices(running: RunningAndroidTarget[], avdNames: string[]): Device[] { + const devices = running.map( + (target): Device => ({ + platform: PlatformType.Android, + key: target.isVirtual ? target.name : target.id, + id: target.id, + name: target.name, + isVirtual: target.isVirtual, + isOnline: true, + }), + ); + const runningAvds = new Set(devices.filter(device => device.isVirtual).map(d => d.key)); + for (const avd of avdNames) { + if (!runningAvds.has(avd)) { + devices.push({ + platform: PlatformType.Android, + key: avd, + id: avd, + name: avd, + isVirtual: true, + isOnline: false, + }); + } + } + return devices; +} + +/** + * Flutter-style device selector: a status bar item showing the selected device, a picker to + * change it or start an offline emulator/simulator, and a debug config resolver that runs + * launches on the selected device. + */ +export class DeviceSelector implements vscode.DebugConfigurationProvider, vscode.Disposable { + public static instance: DeviceSelector | undefined; + + private static readonly SAVED_DEVICE_KEY = "reactNative.selectedDevice"; + private static readonly REFRESH_INTERVAL_MS = 5000; + // ponytail: `xcrun xctrace list devices` takes ~2.5 s, so physical iPhones refresh only every + // 6th tick (~30 s) and when the picker opens. Switch to `xcrun devicectl list devices` + // (0.14 s) once its JSON format settles (fields are deprecated in Xcode 27). + private static readonly IOS_DEVICES_EVERY_N_REFRESHES = 6; + + private readonly statusBarItem: vscode.StatusBarItem; + private readonly logger = OutputChannelLogger.getMainChannel(); + private readonly childProcess = new ChildProcess(); + private readonly simulatorManager = new IOSTargetManager(); + private readonly iosDeviceManager = new IOSTargetManager(); + private readonly onDidChangeDevices = new vscode.EventEmitter(); + private readonly androidNames = new Map(); + private readonly loggedErrors = new Set(); + private readonly disposables: vscode.Disposable[] = []; + private adbHelper?: AdbHelper; + private devices?: Device[]; + private iosDevices: Device[] = []; + private current?: Device; + private launchingText?: string; + private refreshing?: Promise; + private wantIosDevices = false; + private refreshCount = 0; + private timer?: NodeJS.Timeout; + + constructor(private readonly workspaceState: vscode.Memento) { + this.statusBarItem = vscode.window.createStatusBarItem( + "reactNative.device", + vscode.StatusBarAlignment.Right, + Number.MIN_SAFE_INTEGER, // rightmost: lower priority sits further right + ); + this.statusBarItem.name = "React Native Device"; + this.statusBarItem.command = "reactNative.selectDevice"; + DeviceSelector.instance = this; + } + + /** Shows the status bar item and starts refreshing. Safe to call more than once. */ + public start(): void { + if (this.timer) { + return; + } + this.timer = setInterval(() => { + if (vscode.window.state.focused) { + void this.refresh(); + } + }, DeviceSelector.REFRESH_INTERVAL_MS); + this.disposables.push( + vscode.window.onDidChangeWindowState(state => { + if (state.focused) { + void this.refresh(); + } + }), + ); + this.render(); + this.statusBarItem.show(); + void this.refresh(); + } + + public async showPicker(): Promise { + const quickPick = vscode.window.createQuickPick(); + quickPick.placeholder = localize("SelectDeviceToUse", "Select a device to use"); + quickPick.ignoreFocusOut = true; + const updateItems = () => { + quickPick.items = buildPickerItems(this.devices ?? [], this.current); + }; + const subscription = this.onDidChangeDevices.event(updateItems); + updateItems(); + quickPick.busy = true; + void this.refresh(true).finally(() => { + quickPick.busy = false; + }); + + const selection = await new Promise(resolve => { + quickPick.onDidAccept(() => resolve(quickPick.selectedItems[0])); + quickPick.onDidHide(() => resolve(undefined)); + quickPick.show(); + }); + subscription.dispose(); + quickPick.dispose(); + + if (selection?.device?.isOnline) { + this.select(selection.device); + } else if (selection?.device) { + await this.launch(selection.device, !!selection.coldBoot); + } + } + + public resolveDebugConfiguration( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, + ): vscode.DebugConfiguration { + const note = applySelectedDevice(config, this.current); + if (note) { + this.logger.info(note); + } + return config; + } + + public dispose(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + this.disposables.forEach(disposable => disposable.dispose()); + this.onDidChangeDevices.dispose(); + this.statusBarItem.dispose(); + DeviceSelector.instance = undefined; + } + + private select(device: Device): void { + this.current = device; + void this.workspaceState.update(DeviceSelector.SAVED_DEVICE_KEY, device.key); + this.render(); + } + + private render(): void { + this.statusBarItem.text = this.launchingText ?? statusBarText(this.current); + this.statusBarItem.tooltip = statusBarTooltip(this.devices ?? []); + } + + /** Coalesces concurrent calls; a request for iOS devices during a run triggers one more run. */ + private refresh(withIosDevices = false): Promise { + this.wantIosDevices = this.wantIosDevices || withIosDevices; + if (!this.refreshing) { + this.refreshing = this.collectAll().finally(() => { + this.refreshing = undefined; + if (this.wantIosDevices) { + void this.refresh(); + } + }); + } + return this.refreshing; + } + + private async collectAll(): Promise { + const withIosDevices = + this.wantIosDevices || + this.refreshCount++ % DeviceSelector.IOS_DEVICES_EVERY_N_REFRESHES === 0; + this.wantIosDevices = false; + const [android, simulators, iosDevices] = await Promise.all([ + this.collectAndroid(), + this.collectIos(this.simulatorManager, TargetType.Simulator), + withIosDevices + ? this.collectIos(this.iosDeviceManager, TargetType.Device) + : Promise.resolve(this.iosDevices), + ]); + this.iosDevices = iosDevices; + this.update([...android, ...simulators, ...iosDevices]); + } + + private update(devices: Device[]): void { + const previous = this.devices; + if (previous && JSON.stringify(previous) === JSON.stringify(devices)) { + return; + } + this.devices = devices; + this.current = nextCurrent( + this.current, + previous, + devices, + this.workspaceState.get(DeviceSelector.SAVED_DEVICE_KEY), + ); + this.render(); + this.onDidChangeDevices.fire(); + } + + private async collectAndroid(): Promise { + const adbHelper = this.getAdbHelper(); + if (!adbHelper) { + return []; + } + const [avdNames, running] = await Promise.all([ + this.attempt("Android emulators", () => adbHelper.getAvdsNames(), [] as string[]), + this.attempt("Android devices", () => this.runningAndroidTargets(adbHelper), []), + ]); + return toAndroidDevices(running, avdNames); + } + + private async runningAndroidTargets(adbHelper: AdbHelper): Promise { + const online = await adbHelper.getOnlineTargets(); + for (const serial of this.androidNames.keys()) { + if (!online.some(target => target.id === serial)) { + this.androidNames.delete(serial); + } + } + return Promise.all( + online.map(async target => ({ + id: target.id, + isVirtual: target.isVirtualTarget, + name: await this.androidName(adbHelper, target), + })), + ); + } + + /** AVD name for emulators, model for phones. Lookups that fail are retried next refresh. */ + private async androidName( + adbHelper: AdbHelper, + target: IDebuggableMobileTarget, + ): Promise { + const cached = this.androidNames.get(target.id); + if (cached) { + return cached; + } + const name = target.isVirtualTarget + ? await adbHelper.getAvdNameById(target.id) + : ( + await adbHelper + .executeShellCommand(target.id, "getprop ro.product.model") + .catch(() => "") + ).trim(); + if (!name) { + return target.id; + } + this.androidNames.set(target.id, name); + return name; + } + + private getAdbHelper(): AdbHelper | undefined { + const project = Object.values(ProjectsStorage.projectsCache)[0]; + if (!this.adbHelper && project) { + try { + this.adbHelper = new AdbHelper( + project.getPackager().getProjectPath(), + project.getOrUpdateNodeModulesRoot(), + ); + } catch { + // node_modules is not resolvable yet; retried on the next refresh + } + } + return this.adbHelper; + } + + private async collectIos(manager: IOSTargetManager, targetType: TargetType): Promise { + if (process.platform !== "darwin") { + return []; + } + const source = targetType === TargetType.Simulator ? "iOS simulators" : "iOS devices"; + return this.attempt( + source, + async () => { + await manager.collectTargets(targetType); + const targets = (await manager.getTargetList()) as IDebuggableIOSTarget[]; + return targets.map( + (target): Device => ({ + platform: PlatformType.iOS, + key: target.id, + id: target.id, + name: target.name, + isVirtual: target.isVirtualTarget, + isOnline: target.isOnline, + system: target.system, + }), + ); + }, + [], + ); + } + + /** Runs `collect`. Its first failure per source is logged until it succeeds again. */ + private async attempt(source: string, collect: () => Promise, fallback: T): Promise { + try { + const result = await collect(); + this.loggedErrors.delete(source); + return result; + } catch (error) { + if (!this.loggedErrors.has(source)) { + this.loggedErrors.add(source); + this.logger.warning( + localize( + "CouldNotListDevices", + "Could not list {0}: {1}", + source, + error instanceof Error ? error.message : String(error), + ), + ); + } + return fallback; + } + } + + private async launch(device: Device, coldBoot: boolean): Promise { + const isIos = device.platform === PlatformType.iOS; + this.launchingText = isIos + ? localize("LaunchingSimulator", "Launching simulator...") + : localize("LaunchingEmulator", "Launching emulator..."); + this.render(); + try { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: localize("LaunchingDevice", "Launching {0}...", device.name), + }, + () => (isIos ? this.bootSimulator(device) : this.bootEmulator(device, coldBoot)), + ); + await this.refresh(); + this.select( + this.devices?.find(d => d.key === device.key && d.isOnline) ?? { + ...device, + isOnline: true, + }, + ); + } catch (error) { + void vscode.window.showErrorMessage( + localize( + "FailedToLaunchDevice", + "Failed to launch {0}: {1}", + device.name, + error instanceof Error ? error.message : String(error), + ), + ); + } finally { + this.launchingText = undefined; + this.render(); + } + } + + private async bootEmulator(device: Device, coldBoot: boolean): Promise { + const adbHelper = this.getAdbHelper(); + if (!adbHelper) { + throw new Error(localize("NoReactNativeProject", "No React Native project is open")); + } + await new AndroidTargetManager(adbHelper).launchSimulator( + { name: device.key, isOnline: false, isVirtualTarget: true }, + coldBoot, + ); + } + + private async bootSimulator(device: Device): Promise { + // A separate manager instance: launchSimulator re-collects targets while it waits, + // which would race with the refresh loop's simulatorManager. + await new IOSTargetManager().launchSimulator({ + id: device.id, + name: device.name, + system: device.system ?? "", + isVirtualTarget: true, + isOnline: false, + }); + // `simctl boot` starts the simulator without a window. Xcode 27 renamed Simulator.app to DeviceHub.app. + await this.childProcess + .execFileToString("open", ["-a", "DeviceHub"]) + .catch(() => this.childProcess.execFileToString("open", ["-a", "Simulator"])) + .catch(error => this.logger.warning(error)); + } +} diff --git a/src/extension/deviceStatusIndicator.ts b/src/extension/deviceStatusIndicator.ts deleted file mode 100644 index 31052b078..000000000 --- a/src/extension/deviceStatusIndicator.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for details. - -import { window, Disposable, StatusBarItem, StatusBarAlignment } from "vscode"; - -/** - * Shows the currently connected device/simulator name in the status bar during a debug session. - */ -export class DeviceStatusIndicator implements Disposable { - private static readonly DEVICE_ICON = "$(device-mobile)"; - private static instance: DeviceStatusIndicator | undefined; - - private statusBarItem: StatusBarItem; - - private constructor() { - this.statusBarItem = window.createStatusBarItem(StatusBarAlignment.Left, 9); - } - - private static getInstance(): DeviceStatusIndicator { - if (!DeviceStatusIndicator.instance) { - DeviceStatusIndicator.instance = new DeviceStatusIndicator(); - } - return DeviceStatusIndicator.instance; - } - - public static show(deviceName: string): void { - const instance = DeviceStatusIndicator.getInstance(); - instance.statusBarItem.text = `${DeviceStatusIndicator.DEVICE_ICON} ${deviceName}`; - instance.statusBarItem.tooltip = `Active debug target: ${deviceName}`; - instance.statusBarItem.show(); - } - - public static hide(): void { - DeviceStatusIndicator.instance?.statusBarItem.hide(); - } - - public dispose(): void { - this.statusBarItem.dispose(); - DeviceStatusIndicator.instance = undefined; - } -} diff --git a/src/extension/exponent/exponentHelper.ts b/src/extension/exponent/exponentHelper.ts index 024bbc314..75dfeeb4d 100644 --- a/src/extension/exponent/exponentHelper.ts +++ b/src/extension/exponent/exponentHelper.ts @@ -312,9 +312,10 @@ export class ExponentHelper { this.fs.exists(this.pathToFileInWorkspace(DEFAULT_IOS_INDEX)), this.fs.exists(this.pathToFileInWorkspace(DEFAULT_ANDROID_INDEX)), ]); - return expo - ? this.pathToFileInWorkspace(DEFAULT_EXPONENT_INDEX) - : ios + if (expo) { + return this.pathToFileInWorkspace(DEFAULT_EXPONENT_INDEX); + } + return ios ? this.pathToFileInWorkspace(DEFAULT_IOS_INDEX) : this.pathToFileInWorkspace(DEFAULT_ANDROID_INDEX); } diff --git a/src/extension/exponent/exponentPlatform.ts b/src/extension/exponent/exponentPlatform.ts index b6297b4e4..b85c80176 100644 --- a/src/extension/exponent/exponentPlatform.ts +++ b/src/extension/exponent/exponentPlatform.ts @@ -57,11 +57,9 @@ export class ExponentPlatform extends GeneralPlatform { // https://github.com/expo/expo-cli/blob/1d515d21200841e181518358fd9dc4c7b24c7cd6/packages/xdl/src/Project.ts#L2226-L2370 // we added this to be sure that our Expo launching logic doesn't have any negative side effects - if (this.runOptions.expoHostType === "tunnel") { - await this.prepareExpoTunnels(); - } else { - await XDL.stopAdbReverse(this.projectPath); - } + await (this.runOptions.expoHostType === "tunnel" + ? this.prepareExpoTunnels() + : XDL.stopAdbReverse(this.projectPath)); const isAdbReversed = this.runOptions.expoHostType !== "local" @@ -207,6 +205,6 @@ export class ExponentPlatform extends GeneralPlatform { } } -export function getQRCodeUrl() { +export function getQRCodeUrl(): string { return QRCodeUrl; } diff --git a/src/extension/generalMobilePlatform.ts b/src/extension/generalMobilePlatform.ts index c33f08671..ea4a311c2 100644 --- a/src/extension/generalMobilePlatform.ts +++ b/src/extension/generalMobilePlatform.ts @@ -82,15 +82,13 @@ export abstract class GeneralMobilePlatform extends GeneralPlatform { ), ); cleanupTargetModifications(); - } else { + } else if (this instanceof IOSPlatform || (await this.needToPassTargetToRunArgs())) { // For iOS we should pass exact target id, // because the “react-native run-ios” command does not check booted devices // and just launches the first device - if (this instanceof IOSPlatform || (await this.needToPassTargetToRunArgs())) { - this.addTargetToRunArgs(this.target); - } else { - cleanupTargetModifications(); - } + this.addTargetToRunArgs(this.target); + } else { + cleanupTargetModifications(); } } catch (error) { if ( diff --git a/src/extension/generalPlatform.ts b/src/extension/generalPlatform.ts index 5aa010d98..66164bae2 100644 --- a/src/extension/generalPlatform.ts +++ b/src/extension/generalPlatform.ts @@ -214,7 +214,7 @@ export class GeneralPlatform { if (envFile) { // .env variables never overwrite existing variables const argsFromEnvFile = this.readEnvFile(envFile); - if (argsFromEnvFile != null) { + if (argsFromEnvFile !== null && argsFromEnvFile !== undefined) { // eslint-disable-next-line no-restricted-syntax for (const key in argsFromEnvFile) { if (argsFromEnvFile.hasOwnProperty(key) && !modifyEnv.hasOwnProperty(key)) { @@ -247,7 +247,7 @@ export class GeneralPlatform { } buffer.split("\n").forEach((line: string) => { - const r = line.match(/^\s*([\w.\-]+)\s*=\s*(.*)?\s*$/); + const r = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/); if (r !== null) { const key = r[1]; let value = r[2] || ""; diff --git a/src/extension/ios/iOSTargetManager.ts b/src/extension/ios/iOSTargetManager.ts index 6408c9bf8..36c98b99b 100644 --- a/src/extension/ios/iOSTargetManager.ts +++ b/src/extension/ios/iOSTargetManager.ts @@ -198,7 +198,7 @@ export class IOSTargetManager extends MobileTargetManager { filter as ((el: IMobileTarget) => boolean) | undefined, )) as IDebuggableIOSTarget[]; // If we select only from devices, we should not select system - if (!targets.find(target => target.isVirtualTarget)) { + if (!targets.some(target => target.isVirtualTarget)) { return IOSTargetManager.ANY_SYSTEM; } const names: Set = new Set(targets.map(target => target.system)); @@ -218,7 +218,7 @@ export class IOSTargetManager extends MobileTargetManager { return result?.toString(); } - protected async launchSimulator( + public async launchSimulator( virtualTarget: IDebuggableIOSTarget, ): Promise { return new Promise((resolve, reject) => { diff --git a/src/extension/ios/plistBuddy.ts b/src/extension/ios/plistBuddy.ts index 172400af1..c92ded9b6 100644 --- a/src/extension/ios/plistBuddy.ts +++ b/src/extension/ios/plistBuddy.ts @@ -69,8 +69,9 @@ export class PlistBuddy { simulator: boolean = true, configuration: string = "Debug", productName?: string, - scheme?: string, + initialScheme?: string, ): Promise { + let scheme = initialScheme; const rnVersions = await ProjectVersionHelper.getReactNativeVersions(projectRoot); let productsFolder; if ( @@ -251,10 +252,11 @@ export class PlistBuddy { const nodeModulesRoot: string = AppLauncher.getNodeModulesRootByProjectPath(projectRoot); + const applePlatform = semver.gte(rnVersion, PlistBuddy.RN_VERSION_CLI_CONFIG_APPLE) + ? "cli-config-apple" + : "cli-platform-apple"; const iOSCliPlatform = semver.gte(rnVersion, PlistBuddy.RN_VERSION_CLI_PLATFORM_APPLE) - ? semver.gte(rnVersion, PlistBuddy.RN_VERSION_CLI_CONFIG_APPLE) - ? "cli-config-apple" - : "cli-platform-apple" + ? applePlatform : "cli-platform-ios"; const iOSCliFolderName = semver.gte(rnVersion, PlistBuddy.NEW_RN_IOS_CLI_LOCATION_VERSION) || @@ -270,7 +272,7 @@ export class PlistBuddy { fs.existsSync(pnpmProjectPath) && SettingsHelper.getPackageManager() === "pnpm"; if (isPnpmProject) { const modules = fs.readdirSync(pnpmProjectPath); - const regex = new RegExp(`\@react-native-community\\+${iOSCliFolderName}@`); + const regex = new RegExp(`@react-native-community\\+${iOSCliFolderName}@`); const communityModule = modules.find(module => regex.test(module)); if (communityModule) { findXcodeBase = path.join( diff --git a/src/extension/log/ConsoleLogger.ts b/src/extension/log/ConsoleLogger.ts index 9ffc01e9b..7455f924e 100644 --- a/src/extension/log/ConsoleLogger.ts +++ b/src/extension/log/ConsoleLogger.ts @@ -4,7 +4,8 @@ import { ILogger, LogLevel, LogHelper } from "./LogHelper"; export class ConsoleLogger implements ILogger { - public log(message: string, level: LogLevel): void { + public log(initialMessage: string, level: LogLevel): void { + let message = initialMessage; if (LogHelper.LOG_LEVEL === LogLevel.None) { return; } diff --git a/src/extension/log/OutputChannelLogger.ts b/src/extension/log/OutputChannelLogger.ts index dc0a1efde..5ed104bd5 100644 --- a/src/extension/log/OutputChannelLogger.ts +++ b/src/extension/log/OutputChannelLogger.ts @@ -78,7 +78,8 @@ export class OutputChannelLogger implements ILogger { } } - public log(message: string, level: LogLevel): void { + public log(initialMessage: string, level: LogLevel): void { + let message = initialMessage; if (LogHelper.LOG_LEVEL === LogLevel.None) { return; } @@ -96,7 +97,8 @@ export class OutputChannelLogger implements ILogger { } } - public logWithCustomTag(tag: string, message: string, level: LogLevel): void { + public logWithCustomTag(tag: string, initialMessage: string, level: LogLevel): void { + let message = initialMessage; if (LogHelper.LOG_LEVEL === LogLevel.None) { return; } diff --git a/src/extension/networkInspector/views/inspectorConsoleView.ts b/src/extension/networkInspector/views/inspectorConsoleView.ts index 5f4aa23ed..da41f0d2e 100644 --- a/src/extension/networkInspector/views/inspectorConsoleView.ts +++ b/src/extension/networkInspector/views/inspectorConsoleView.ts @@ -74,11 +74,10 @@ export class InspectorConsoleView extends InspectorView { } private setupConsoleLogsColor(systemColorTheme: SystemColorTheme): void { - if (systemColorTheme === SystemColorTheme.Light) { - this.consoleLogsColor = this.consoleLogsColors.Blue; - } else { - this.consoleLogsColor = this.consoleLogsColors.Orange; - } + this.consoleLogsColor = + systemColorTheme === SystemColorTheme.Light + ? this.consoleLogsColors.Blue + : this.consoleLogsColors.Orange; } private handleRequest(data: Request): void { @@ -169,13 +168,13 @@ export class InspectorConsoleView extends InspectorView { const response: Response = partialResponseEntry.initialResponse; const allChunks: string[] = - response.data != null + response.data !== null && response.data !== undefined ? [ response.data, ...Object.entries(partialResponseEntry.followupChunks) // It's important to parseInt here or it sorts lexicographically .sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10)) - .map(([_k, v]: [string, string]) => v), + .map(([, v]: [string, string]) => v), ] : []; const data = combineBase64Chunks(allChunks); @@ -236,10 +235,11 @@ export class InspectorConsoleView extends InspectorView { } private prepareHeadersViewObject(headers: Header[]): Record { - return headers.reduce((headersViewObject, header) => { + const headersViewObject: Record = {}; + for (const header of headers) { headersViewObject[header.key] = header.value; - return headersViewObject; - }, {} as Record); + } + return headersViewObject; } private printNetworkRequestData(networkRequestData: ConsoleNetworkRequestDataView): void { diff --git a/src/extension/packagerStatusIndicator.ts b/src/extension/packagerStatusIndicator.ts index fad07cae1..74cb2b8d7 100644 --- a/src/extension/packagerStatusIndicator.ts +++ b/src/extension/packagerStatusIndicator.ts @@ -113,10 +113,11 @@ export class PackagerStatusIndicator implements Disposable { } private setupPackagerStatusIndicatorItems( - icon: string, + initialIcon: string, command?: string, tooltip: string = "", ): void { + let icon = initialIcon; this.updateDisplayVersion(); this.togglePackagerItem.command = command; this.togglePackagerItem.tooltip = this.getTooltip(tooltip); diff --git a/src/extension/reactNativeSessionManager.ts b/src/extension/reactNativeSessionManager.ts index 3bb81dbd2..404fbad3c 100644 --- a/src/extension/reactNativeSessionManager.ts +++ b/src/extension/reactNativeSessionManager.ts @@ -23,27 +23,25 @@ export class ReactNativeSessionManager ): vscode.ProviderResult { const rnSession = new RNSession(session); - let debugServer: Net.Server; - if (session.configuration.platform != "expoweb") { - debugServer = Net.createServer(socket => { - const rnDebugSession = - session.type === DEBUG_TYPES.REACT_NATIVE - ? new RNDebugSession(rnSession) - : new DirectDebugSession(rnSession); + const debugServer = + session.configuration.platform !== "expoweb" + ? Net.createServer(socket => { + const rnDebugSession = + session.type === DEBUG_TYPES.REACT_NATIVE + ? new RNDebugSession(rnSession) + : new DirectDebugSession(rnSession); - this.connections.set(session.id, socket); + this.connections.set(session.id, socket); - rnDebugSession.setRunAsServer(true); - rnDebugSession.start(socket, socket); - }); - } else { - debugServer = Net.createServer(socket => { - const webDebugSession = new WebDebugSession(rnSession); - webDebugSession.setRunAsServer(true); - this.connections.set(session.id, socket); - webDebugSession.start(socket, socket); - }); - } + rnDebugSession.setRunAsServer(true); + rnDebugSession.start(socket, socket); + }) + : Net.createServer(socket => { + const webDebugSession = new WebDebugSession(rnSession); + webDebugSession.setRunAsServer(true); + this.connections.set(session.id, socket); + webDebugSession.start(socket, socket); + }); return new Promise((resolve, reject) => { debugServer.once("error", reject); diff --git a/src/extension/rn-extension.ts b/src/extension/rn-extension.ts index 50f7a330b..a954c7644 100644 --- a/src/extension/rn-extension.ts +++ b/src/extension/rn-extension.ts @@ -41,6 +41,7 @@ import { } from "./debuggingConfiguration/launchJsonCompletionProvider"; import { ReactNativeSessionManager } from "./reactNativeSessionManager"; import { ProjectsStorage } from "./projectsStorage"; +import { DeviceSelector } from "./deviceSelector"; import { AppLauncher } from "./appLauncher"; import { LogCatMonitorManager } from "./android/logCatMonitorManager"; import { ExtensionConfigManager } from "./extensionConfigManager"; @@ -204,6 +205,19 @@ export async function activate(context: vscode.ExtensionContext): Promise ), ); + const deviceSelector = new DeviceSelector(EXTENSION_CONTEXT.workspaceState); + EXTENSION_CONTEXT.subscriptions.push( + deviceSelector, + vscode.debug.registerDebugConfigurationProvider( + DEBUG_TYPES.REACT_NATIVE, + deviceSelector, + ), + vscode.debug.registerDebugConfigurationProvider( + DEBUG_TYPES.REACT_NATIVE_DIRECT, + deviceSelector, + ), + ); + EXTENSION_CONTEXT.subscriptions.push( vscode.languages.registerCompletionItemProvider( { language: JsonLanguages.json }, @@ -356,9 +370,9 @@ export async function onFolderAdded(folder: vscode.WorkspaceFolder): Promise val.original !== val.resolved, + ([, val]) => val.original !== val.resolved, )?.[0]; if (valUsesEnv) { diff --git a/src/extension/services/validationService/checks/visualStudio.ts b/src/extension/services/validationService/checks/visualStudio.ts index ed6521689..6d0f51510 100644 --- a/src/extension/services/validationService/checks/visualStudio.ts +++ b/src/extension/services/validationService/checks/visualStudio.ts @@ -15,12 +15,9 @@ const toLocale = nls.loadMessageBundle(); const label = "Compilers, build tools, SDKs and Visual Studio"; async function test(): Promise { - let vswherePath = ``; - if (process.env["ProgramFiles(x86)"]) { - vswherePath = `"${process.env["ProgramFiles(x86)"]}\\Microsoft Visual Studio\\Installer\\vswhere.exe"`; - } else { - vswherePath = `"C:\\Program Files (x86)\\Microsoft Visual Studio\\Installer\\vswhere.exe"`; - } + const vswherePath = process.env["ProgramFiles(x86)"] + ? `"${process.env["ProgramFiles(x86)"]}\\Microsoft Visual Studio\\Installer\\vswhere.exe"` + : `"C:\\Program Files (x86)\\Microsoft Visual Studio\\Installer\\vswhere.exe"`; const components = [ "Microsoft.Component.MSBuild", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", @@ -35,6 +32,7 @@ async function test(): Promise { const valid = (version: string) => semver.gtr(version, "16.5"); if (versions.some(valid)) { for (const comp of components) { + // eslint-disable-next-line no-await-in-loop -- returns early on the first missing component const pathToComponent = await executeCommand( `${vswherePath} -requires ${comp} -property productPath`, ); diff --git a/src/extension/services/validationService/util.ts b/src/extension/services/validationService/util.ts index 6030eb40e..01509e66e 100644 --- a/src/extension/services/validationService/util.ts +++ b/src/extension/services/validationService/util.ts @@ -47,11 +47,8 @@ export const basicCheck = async (arg: { return result; } - result.versionCompare = semver.gtr(version, arg.versionRange) - ? 1 - : semver.ltr(version, arg.versionRange) - ? -1 - : 0; + const lowerCompare = semver.ltr(version, arg.versionRange) ? -1 : 0; + result.versionCompare = semver.gtr(version, arg.versionRange) ? 1 : lowerCompare; return result; }; @@ -73,10 +70,12 @@ export const parseVersion = async ( // change typescript lib to es2019 ? export const fromEntries = ( entries: Iterable, -): Record => - [...entries].reduce((obj, [key, val]) => { +): Record => { + const obj = {} as Record; + for (const [key, val] of entries) { obj[key] = val; - return obj; - }, {} as Record); + } + return obj; +}; // export const flatten = (ary: any[]): unknown[] => diff --git a/src/extension/settingsHelper.ts b/src/extension/settingsHelper.ts index cc09cc56d..4aca8ce49 100644 --- a/src/extension/settingsHelper.ts +++ b/src/extension/settingsHelper.ts @@ -229,7 +229,7 @@ export class SettingsHelper { return []; } - public static getWorkspaceTelemetry(fsPath: string) { + public static getWorkspaceTelemetry(fsPath: string): any { if (!fsPath || !fs.existsSync(fsPath)) { return ""; } diff --git a/test/cdp-proxy/hermesCDPMessageHandler.test.ts b/test/cdp-proxy/hermesCDPMessageHandler.test.ts new file mode 100644 index 000000000..f6e081eaa --- /dev/null +++ b/test/cdp-proxy/hermesCDPMessageHandler.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +import { Connection } from "vscode-cdp-proxy"; +import { HermesCDPMessageHandler } from "../../src/cdp-proxy/CDPMessageHandlers/hermesCDPMessageHandler"; +import { CDP_API_NAMES } from "../../src/cdp-proxy/CDPMessageHandlers/CDPAPINames"; +import { PromiseUtil } from "../../src/common/node/promise"; +import assert = require("assert"); + +suite("hermesCDPMessageHandler", function () { + // Must outlast RELOAD_DEBOUNCE_MS (300) in the handler + const afterDebounce = 500; + + function createHandler(): { + handler: HermesCDPMessageHandler; + sent: Array>; + } { + const handler = new HermesCDPMessageHandler(); + const sent: Array> = []; + handler.setApplicationTarget({ + send: (message: Record) => sent.push(message), + } as unknown as Connection); + return { handler, sent }; + } + + suite("first run reload", function () { + test("reloads the application once the debugger has sent its breakpoints", async () => { + const { handler, sent } = createHandler(); + + handler.processDebuggerCDPMessage({ + method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL, + params: {}, + }); + await PromiseUtil.delay(afterDebounce); + + assert.strictEqual(sent.length, 1); + assert.strictEqual(sent[0].method, CDP_API_NAMES.PAGE_RELOAD); + }); + + test("coalesces a burst of breakpoints into a single reload", async () => { + const { handler, sent } = createHandler(); + + for (let i = 0; i < 5; i++) { + handler.processDebuggerCDPMessage({ + method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL, + params: {}, + }); + } + await PromiseUtil.delay(afterDebounce); + + assert.strictEqual(sent.length, 1); + }); + + test("does not reload again for later breakpoints in the same session", async () => { + const { handler, sent } = createHandler(); + + handler.processDebuggerCDPMessage({ + method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL, + params: {}, + }); + await PromiseUtil.delay(afterDebounce); + + handler.processDebuggerCDPMessage({ + method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL, + params: {}, + }); + await PromiseUtil.delay(afterDebounce); + + assert.strictEqual(sent.length, 1); + }); + + test("does not reload after the debugger disconnects", async () => { + const { handler, sent } = createHandler(); + + handler.processDebuggerCDPMessage({ + method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL, + params: {}, + }); + handler.processDebuggerCDPMessage({ method: CDP_API_NAMES.CLOSE, params: {} }); + await PromiseUtil.delay(afterDebounce); + + assert.strictEqual(sent.length, 0); + }); + + test("leaves breakpoint messages on their way to the application", function () { + const { handler } = createHandler(); + + const processed = handler.processDebuggerCDPMessage({ + method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT, + params: { location: { lineNumber: 1, columnNumber: 2 } }, + }); + + const event = processed.event as Record; + + assert.strictEqual(processed.sendBack, false); + assert.strictEqual(event.method, CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT); + // Hermes rejects breakpoints carrying a column number + assert.strictEqual(event.params.location.columnNumber, undefined); + }); + }); +}); diff --git a/test/extension/android/androidPlatform.test.ts b/test/extension/android/androidPlatform.test.ts index 6a095eb7a..8df1bf2a8 100644 --- a/test/extension/android/androidPlatform.test.ts +++ b/test/extension/android/androidPlatform.test.ts @@ -260,6 +260,28 @@ suite("androidPlatform", function () { }, ); + testWithRecordings( + "runApp reports the run-android failure when the only online device goes offline during the run", + ["react-native/run-android/win10-rn0.21.0/failsDueToNoDevicesConnected"], + async () => { + devices = fillDevices(["Nexus_5"]); + getOnlineTargetsStub.restore(); + let onlineTargetsRequests = 0; + getOnlineTargetsStub = sinon.stub(adbHelper, "getOnlineTargets", async () => + onlineTargetsRequests++ === 0 ? devices : [], + ); + + try { + await androidPlatform.runApp(); + should.assert(false, "runApp should've exited with an error"); + } catch (error) { + (error as Error).message + .startsWith("Unknown error: not all success patterns were matched") + .should.be.true(); + } + }, + ); + testWithRecordings( "runApp launches the app in an online emulator only", ["react-native/run-android/win10-rn0.21.0/succeedsWithFiveVSEmulators"], diff --git a/test/extension/commands/launchExpoWeb.test.ts b/test/extension/commands/launchExpoWeb.test.ts index 0af6d0eb0..401481fcd 100644 --- a/test/extension/commands/launchExpoWeb.test.ts +++ b/test/extension/commands/launchExpoWeb.test.ts @@ -83,7 +83,7 @@ suite("launchExpoWebCommand", function () { const command = launchExpoWeb.formInstance(); (command as any).project = project; - await command.baseFn({}); + await command.baseFn(); assert.strictEqual(isExpoManagedAppStub.calledWithExactly(true), true); assert.strictEqual(getRunOptionsStub.called, false); @@ -107,7 +107,7 @@ suite("launchExpoWebCommand", function () { const command = launchExpoWeb.formInstance(); (command as any).project = project; - await command.baseFn({}); + await command.baseFn(); assert.strictEqual(isExpoManagedAppStub.calledWithExactly(true), true); assert.strictEqual( diff --git a/test/extension/deviceSelector.test.ts b/test/extension/deviceSelector.test.ts new file mode 100644 index 000000000..cf6ad57b5 --- /dev/null +++ b/test/extension/deviceSelector.test.ts @@ -0,0 +1,303 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +import assert = require("assert"); +import * as vscode from "vscode"; +import { + Device, + DeviceItem, + applySelectedDevice, + buildPickerItems, + nextCurrent, + statusBarText, + statusBarTooltip, + toAndroidDevices, +} from "../../src/extension/deviceSelector"; +import { PlatformType } from "../../src/extension/launchArgs"; +import * as sinon from "sinon"; +import { AdbHelper } from "../../src/extension/android/adb"; +import { AndroidTargetManager } from "../../src/extension/android/androidTargetManager"; +import { ChildProcess } from "../../src/common/node/childProcess"; + +const pixel: Device = { + platform: PlatformType.Android, + key: "Pixel_7_API_34", + id: "emulator-5554", + name: "Pixel_7_API_34", + isVirtual: true, + isOnline: true, +}; +const phone: Device = { + platform: PlatformType.Android, + key: "R5CT", + id: "R5CT", + name: "Pixel 8", + isVirtual: false, + isOnline: true, +}; +const offlineAvd: Device = { + platform: PlatformType.Android, + key: "Pixel_4_API_30", + id: "Pixel_4_API_30", + name: "Pixel_4_API_30", + isVirtual: true, + isOnline: false, +}; +const iphone: Device = { + platform: PlatformType.iOS, + key: "UDID-1", + id: "UDID-1", + name: "iPhone 17", + isVirtual: true, + isOnline: true, + system: "iOS 27.0", +}; +const offlineSim: Device = { + platform: PlatformType.iOS, + key: "UDID-2", + id: "UDID-2", + name: "iPhone 17e", + isVirtual: true, + isOnline: false, + system: "iOS 27.0", +}; + +function describeItems(items: DeviceItem[]): string[] { + return items.map(item => + item.kind === vscode.QuickPickItemKind.Separator + ? `-- ${item.label}` + : [item.label, item.description, item.detail, item.coldBoot && "cold"] + .filter(Boolean) + .join(" | "), + ); +} + +suite("DeviceSelector", function () { + suite("statusBarText", function () { + test("shows No Device when nothing is selected", function () { + assert.strictEqual(statusBarText(undefined), "No Device"); + }); + + test("labels emulators, simulators and physical devices", function () { + assert.strictEqual(statusBarText(pixel), "Pixel_7_API_34 (android emulator)"); + assert.strictEqual(statusBarText(phone), "Pixel 8 (android)"); + assert.strictEqual(statusBarText(iphone), "iPhone 17 (ios simulator)"); + }); + }); + + suite("statusBarTooltip", function () { + test("counts only online devices", function () { + assert.strictEqual(statusBarTooltip([]), undefined); + assert.strictEqual(statusBarTooltip([offlineAvd]), undefined); + assert.strictEqual(statusBarTooltip([pixel, offlineAvd]), "1 Device Connected"); + assert.strictEqual(statusBarTooltip([pixel, iphone]), "2 Devices Connected"); + }); + }); + + suite("buildPickerItems", function () { + test("lists the current device first, then offline emulators with cold boot rows", function () { + const items = buildPickerItems([iphone, pixel, offlineAvd, offlineSim], pixel); + assert.deepStrictEqual(describeItems(items), [ + "-- Available Devices", + "$(device-mobile) Pixel_7_API_34 | emulator-5554 - android | Current Device", + "$(device-mobile) iPhone 17 | UDID-1 - ios", + "-- Offline Emulators", + "$(play) Start Pixel_4_API_30 | mobile emulator", + "$(play) Start Pixel_4_API_30 | mobile emulator (cold boot) | cold", + "$(play) Start iPhone 17e | mobile simulator (iOS 27.0)", + ]); + assert.strictEqual(items[1].device, pixel); + assert.strictEqual(items[5].device, offlineAvd); + }); + + test("sorts available devices by name when none is current", function () { + const items = buildPickerItems([pixel, iphone], undefined); + assert.deepStrictEqual(describeItems(items), [ + "-- Available Devices", + "$(device-mobile) iPhone 17 | UDID-1 - ios", + "$(device-mobile) Pixel_7_API_34 | emulator-5554 - android", + ]); + }); + + test("omits empty sections", function () { + assert.deepStrictEqual(buildPickerItems([], undefined), []); + assert.deepStrictEqual(describeItems(buildPickerItems([offlineAvd], undefined)), [ + "-- Offline Emulators", + "$(play) Start Pixel_4_API_30 | mobile emulator", + "$(play) Start Pixel_4_API_30 | mobile emulator (cold boot) | cold", + ]); + }); + }); + + suite("nextCurrent", function () { + test("first refresh picks the saved device when it is online", function () { + assert.strictEqual( + nextCurrent(undefined, undefined, [pixel, iphone], "UDID-1"), + iphone, + ); + }); + + test("first refresh falls back to the first online device", function () { + assert.strictEqual( + nextCurrent(undefined, undefined, [offlineAvd, pixel, iphone], "gone"), + pixel, + ); + assert.strictEqual( + nextCurrent(undefined, undefined, [offlineAvd], undefined), + undefined, + ); + }); + + test("a newly connected device replaces a current device that was not picked", function () { + assert.strictEqual(nextCurrent(pixel, [pixel], [pixel, iphone], undefined), iphone); + }); + + test("a newly connected device does not replace the picked device", function () { + assert.strictEqual( + nextCurrent(pixel, [pixel], [pixel, iphone], "Pixel_7_API_34"), + pixel, + ); + }); + + test("the picked device is re-selected when it reconnects", function () { + assert.strictEqual( + nextCurrent(iphone, [iphone], [iphone, pixel], "Pixel_7_API_34"), + pixel, + ); + }); + + test("an offline AVD coming online counts as newly connected", function () { + const booted: Device = { ...offlineAvd, id: "emulator-5556", isOnline: true }; + assert.strictEqual( + nextCurrent(pixel, [pixel, offlineAvd], [pixel, booted], undefined), + booted, + ); + }); + + test("when the current device disconnects, prefers the saved device, then the first online", function () { + assert.strictEqual( + nextCurrent(iphone, [iphone, pixel, phone], [phone, pixel], "Pixel_7_API_34"), + pixel, + ); + assert.strictEqual(nextCurrent(iphone, [iphone, phone], [phone], undefined), phone); + assert.strictEqual(nextCurrent(iphone, [iphone], [], undefined), undefined); + }); + + test("returns the fresh object from the new device list", function () { + const stale: Device = { ...pixel, name: "old" }; + assert.strictEqual(nextCurrent(stale, [pixel], [pixel], undefined), pixel); + }); + }); + + suite("applySelectedDevice", function () { + const launch = (platform: string, target?: string): vscode.DebugConfiguration => ({ + name: platform === "ios" ? "Debug iOS" : "Debug Android", + type: "reactnative", + request: "launch", + platform, + ...(target === undefined ? {} : { target }), + }); + + test("fills an empty target with the device key", function () { + const config = launch("android"); + assert.strictEqual(applySelectedDevice(config, pixel), undefined); + assert.strictEqual(config.target, "Pixel_7_API_34"); + }); + + test("fills simulator/device targets only when the device type matches", function () { + const simulator = launch("android", "simulator"); + applySelectedDevice(simulator, pixel); + assert.strictEqual(simulator.target, "Pixel_7_API_34"); + + const device = launch("android", "device"); + applySelectedDevice(device, phone); + assert.strictEqual(device.target, "R5CT"); + + const wrongType = launch("android", "simulator"); + assert.strictEqual(applySelectedDevice(wrongType, phone), undefined); + assert.strictEqual(wrongType.target, "simulator"); + + const wrongType2 = launch("ios", "device"); + applySelectedDevice(wrongType2, iphone); + assert.strictEqual(wrongType2.target, "device"); + }); + + test("never overrides an explicit target", function () { + const config = launch("android", "emulator-5556"); + assert.strictEqual(applySelectedDevice(config, pixel), undefined); + assert.strictEqual(config.target, "emulator-5556"); + assert.strictEqual( + applySelectedDevice(launch("android", "emulator-5556"), undefined), + undefined, + ); + }); + + test("returns a note on platform mismatch and leaves the config alone", function () { + const config = launch("ios"); + assert.strictEqual( + applySelectedDevice(config, pixel), + "Selected device 'Pixel_7_API_34' (android) doesn't match 'Debug iOS' (ios); using the default target selection.", + ); + assert.strictEqual(config.target, undefined); + }); + + test("returns a note when no device is selected", function () { + assert.strictEqual( + applySelectedDevice(launch("android"), undefined), + "No device selected; using the default target selection.", + ); + }); + + test("ignores attach requests and other platforms", function () { + const attach: vscode.DebugConfiguration = { ...launch("android"), request: "attach" }; + assert.strictEqual(applySelectedDevice(attach, pixel), undefined); + assert.strictEqual(attach.target, undefined); + + const expo = launch("exponent"); + assert.strictEqual(applySelectedDevice(expo, pixel), undefined); + assert.strictEqual(expo.target, undefined); + }); + }); + + suite("toAndroidDevices", function () { + test("keys running emulators by AVD name, phones by serial, and lists AVDs that are not running", function () { + const devices = toAndroidDevices( + [ + { id: "emulator-5554", isVirtual: true, name: "Pixel_7_API_34" }, + { id: "R5CT", isVirtual: false, name: "Pixel 8" }, + ], + ["Pixel_4_API_30", "Pixel_7_API_34"], + ); + assert.deepStrictEqual(devices, [pixel, phone, offlineAvd]); + }); + }); +}); + +suite("AndroidTargetManager.launchSimulator", function () { + test("passes -no-snapshot-load for a cold boot and resolves with the emulator serial", async function () { + this.timeout(5000); + const spawnStub = sinon.stub(ChildProcess.prototype, "spawn").returns({ + spawnedProcess: { unref: () => undefined }, + outcome: new Promise(() => undefined), + } as any); + const adbHelper = { + getOnlineTargets: async () => [ + { id: "emulator-5554", isOnline: true, isVirtualTarget: true }, + ], + getAvdNameById: async () => "Pixel_4_API_30", + } as unknown as AdbHelper; + try { + const target = await new AndroidTargetManager(adbHelper).launchSimulator( + { name: "Pixel_4_API_30", isOnline: false, isVirtualTarget: true }, + true, + ); + assert.deepStrictEqual(spawnStub.firstCall.args.slice(0, 2), [ + "emulator", + ["-avd", "Pixel_4_API_30", "-no-snapshot-load"], + ]); + assert.strictEqual(target.id, "emulator-5554"); + } finally { + spawnStub.restore(); + } + }); +}); diff --git a/test/extension/deviceStatusIndicator.test.ts b/test/extension/deviceStatusIndicator.test.ts deleted file mode 100644 index 5947a45c4..000000000 --- a/test/extension/deviceStatusIndicator.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for details. - -import assert = require("assert"); -import * as vscode from "vscode"; -import * as sinon from "sinon"; -import { DeviceStatusIndicator } from "../../src/extension/deviceStatusIndicator"; - -function makeFakeStatusBarItem(): vscode.StatusBarItem { - return { - id: "", - name: "", - text: "", - tooltip: undefined, - color: undefined, - backgroundColor: undefined, - command: undefined, - accessibilityInformation: undefined, - alignment: vscode.StatusBarAlignment.Left, - priority: undefined, - show: sinon.stub(), - hide: sinon.stub(), - dispose: sinon.stub(), - } as unknown as vscode.StatusBarItem; -} - -suite("DeviceStatusIndicator", function () { - suite("extensionContext", function () { - let createStatusBarItemStub: Sinon.SinonStub; - let fakeItem: vscode.StatusBarItem; - setup(() => { - fakeItem = makeFakeStatusBarItem(); - createStatusBarItemStub = sinon.stub(vscode.window, "createStatusBarItem"); - createStatusBarItemStub.returns(fakeItem); - }); - teardown(() => { - const instance = (DeviceStatusIndicator as any).instance; - if (instance) { - instance.dispose(); - } - createStatusBarItemStub.restore(); - }); - test("show should set correct icon and device name in text", function () { - DeviceStatusIndicator.show("Test Device"); - assert.strictEqual(fakeItem.text, "$(device-mobile) Test Device"); - }); - test("show should set correct tooltip with device name", function () { - DeviceStatusIndicator.show("Test Device"); - assert.strictEqual(fakeItem.tooltip, "Active debug target: Test Device"); - }); - test("show should call show on the status bar item", function () { - DeviceStatusIndicator.show("Test Device"); - assert.ok((fakeItem.show as Sinon.SinonStub).calledOnce); - }); - test("hide should call hide on the status bar item after show", function () { - DeviceStatusIndicator.show("Test Device"); - DeviceStatusIndicator.hide(); - assert.ok((fakeItem.hide as Sinon.SinonStub).calledOnce); - }); - test("hide before show should not throw", function () { - assert.doesNotThrow(() => DeviceStatusIndicator.hide()); - }); - test("show should reuse singleton instance across multiple calls", function () { - DeviceStatusIndicator.show("Device A"); - DeviceStatusIndicator.show("Device B"); - assert.strictEqual(createStatusBarItemStub.callCount, 1); - assert.strictEqual(fakeItem.text, "$(device-mobile) Device B"); - }); - test("dispose should clear the singleton instance", function () { - DeviceStatusIndicator.show("Test Device"); - const instance = (DeviceStatusIndicator as any).instance as DeviceStatusIndicator; - instance.dispose(); - assert.strictEqual((DeviceStatusIndicator as any).instance, undefined); - }); - }); -}); diff --git a/test/extension/rn-extension.test.ts b/test/extension/rn-extension.test.ts index 18cfe909f..969f0dc55 100644 --- a/test/extension/rn-extension.test.ts +++ b/test/extension/rn-extension.test.ts @@ -203,6 +203,7 @@ suite("rn-extension", function () { "reactNative.runMacOS", "reactNative.runWindows", "reactNative.selectAndInsertDebugConfiguration", + "reactNative.selectDevice", "reactNative.showDevMenu", "reactNative.startLogCatMonitor", "reactNative.startPackager",