├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── BUG_REPORT.md │ ├── DISCUSSION.md │ ├── FEATURE_REQUEST.md │ └── MINOR_CHANGE.md ├── PULL_REQUEST_TEMPLATE.md ├── actions │ ├── package-version │ │ └── action.yml │ ├── setup-project │ │ └── action.yml │ └── test-vscode │ │ └── action.yml └── workflows │ ├── publish.yml │ ├── release.yml │ ├── schema-eas-workflow.yml │ ├── schema-eas.yml │ ├── schema-metadata.yml │ ├── schema-xdl.yml │ └── test.yml ├── .gitignore ├── .prettierrc.js ├── .releaserc.js ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── images ├── feature-autocomplete.gif ├── logo-marketplace.png └── logo-repository.png ├── package-lock.json ├── package.json ├── patches ├── @expo+config-plugins+4.1.5.patch ├── expo-modules-autolinking+0.8.1.patch └── jest-snapshot+28.1.3.patch ├── scripts ├── __tests__ │ ├── __fixtures__ │ │ ├── expo-xdl-39.json │ │ ├── expo-xdl-41.json │ │ └── expo-xdl-45.json │ ├── __snapshots__ │ │ └── schema-expo-xdl.test.js.snap │ ├── schema-eas-workflow.test.js │ ├── schema-expo-xdl.test.js │ └── utils │ │ └── mocha-runner.js ├── schema-eas-workflow.js ├── schema-expo-xdl.js └── utils │ └── expo.js ├── src ├── __tests__ │ ├── commands │ │ ├── __snapshots__ │ │ │ ├── preview-config.e2e.js.snap │ │ │ ├── preview-modifier-json.e2e.js.snap │ │ │ └── preview-modifier.e2e.js.snap │ │ ├── code-provider.e2e.ts │ │ ├── preview-config.e2e.ts │ │ ├── preview-modifier-json.e2e.ts │ │ └── preview-modifier.e2e.ts │ ├── expoDebuggers.e2e.ts │ ├── extension.e2e.ts │ ├── manifestAssetCompletions.e2e.ts │ ├── manifestDiagnostics.e2e.ts │ ├── manifestLinks.e2e.ts │ ├── manifestPluginCompletions.e2e.ts │ ├── schemas │ │ ├── eas-metadata.e2e.ts │ │ ├── eas.e2e.ts │ │ ├── expo-module.e2e.ts │ │ └── expo-xdl.e2e.ts │ └── utils │ │ ├── debugging.ts │ │ ├── fetch.ts │ │ ├── sinon.ts │ │ ├── snapshot.ts │ │ ├── spawn.ts │ │ ├── vscode.ts │ │ └── wait.ts ├── expo │ ├── __tests__ │ │ ├── bundler.test.ts │ │ ├── cli.test.ts │ │ ├── manifest.test.ts │ │ ├── plugin.test.ts │ │ └── project.test.ts │ ├── bundler.ts │ ├── cli.ts │ ├── manifest.ts │ ├── plugin.ts │ └── project.ts ├── expoDebuggers.ts ├── extension.ts ├── manifestAssetCompletions.ts ├── manifestDiagnostics.ts ├── manifestLinks.ts ├── manifestPluginCompletions.ts ├── packages │ ├── config-plugins.ts │ ├── config.ts │ ├── plist.ts │ └── prebuild-config.ts ├── preview │ ├── CodeProvider.ts │ ├── ExpoConfigCodeProvider.ts │ ├── IntrospectCodeProvider.ts │ ├── constants.ts │ └── setupPreview.ts ├── settings.ts ├── types.d.ts └── utils │ ├── __tests__ │ ├── array.test.ts │ ├── debounce.test.ts │ └── file.test.ts │ ├── array.ts │ ├── cache.ts │ ├── debounce.ts │ ├── debug.ts │ ├── file.ts │ ├── json.ts │ ├── module.ts │ ├── spawn.ts │ ├── telemetry.ts │ └── vscode.ts ├── test ├── fixture │ ├── .vscode │ │ └── settings.json │ ├── debugging │ │ ├── .gitignore │ │ ├── App.js │ │ ├── app.json │ │ ├── assets │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── icon.png │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── index.js │ │ └── package.json │ ├── manifest │ │ ├── .gitignore │ │ ├── App.js │ │ ├── app.config.json │ │ ├── app.json │ │ ├── assets │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── icon.png │ │ │ └── splash.png │ │ ├── index.js │ │ ├── package.json │ │ └── plugins │ │ │ ├── invalid.js │ │ │ └── valid.js │ ├── package-lock.json │ ├── package.json │ ├── preview-config-js │ │ ├── .gitignore │ │ ├── App.js │ │ ├── app.config.js │ │ ├── app.json │ │ ├── assets │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── icon.png │ │ │ └── splash.png │ │ ├── index.js │ │ └── package.json │ ├── preview-config-ts │ │ ├── .gitignore │ │ ├── App.js │ │ ├── app.config.ts │ │ ├── app.json │ │ ├── assets │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── icon.png │ │ │ └── splash.png │ │ ├── index.js │ │ └── package.json │ ├── preview │ │ ├── .gitignore │ │ ├── App.js │ │ ├── app.json │ │ ├── assets │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── icon.png │ │ │ └── splash.png │ │ ├── index.js │ │ └── package.json │ ├── schema-eas │ │ ├── .gitignore │ │ ├── App.js │ │ ├── app.json │ │ ├── assets │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── icon.png │ │ │ └── splash.png │ │ ├── eas.json │ │ ├── index.js │ │ ├── package.json │ │ ├── plugins │ │ │ ├── invalid.js │ │ │ └── valid.js │ │ └── store.config.json │ ├── schema-expo-module │ │ ├── android │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── java │ │ │ │ └── expo │ │ │ │ └── vscode.example │ │ │ │ ├── ExampleModuleModule.kt │ │ │ │ └── ExampleModuleView.kt │ │ ├── expo-module.config.json │ │ ├── index.js │ │ ├── ios │ │ │ ├── ExampleModule.podspec │ │ │ ├── ExampleModuleModule.swift │ │ │ └── ExampleModuleView.swift │ │ ├── package.json │ │ ├── src │ │ │ ├── ExampleModule.ts │ │ │ └── ExampleModuleView.tsx │ │ └── tsconfig.json │ └── schema-expo-xdl │ │ ├── .gitignore │ │ ├── App.js │ │ ├── app.json │ │ ├── assets │ │ ├── adaptive-icon.png │ │ ├── favicon.png │ │ ├── icon.png │ │ └── splash.png │ │ ├── eas.json │ │ ├── index.js │ │ ├── package.json │ │ ├── plugins │ │ ├── invalid.js │ │ └── valid.js │ │ └── store.config.json └── mocha │ ├── snapshots.ts │ ├── vscode-runner.ts │ └── vscode.ts ├── tsconfig.json └── webpack.config.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignorePatterns: ['node_modules/**', 'out/**', 'test/fixture/**'], 3 | extends: 'universe/node', 4 | overrides: [ 5 | { 6 | files: ['*.test.ts', '*.e2e.ts'], 7 | rules: { 8 | // Chai uses patterns such as `expect(true).to.be.true` 9 | 'no-unused-expressions': 'off', 10 | // Allow unused parameters starting with `_`, mostly for tests 11 | '@typescript-eslint/no-unused-vars': [ 12 | 'warn', 13 | { 14 | args: 'all', 15 | argsIgnorePattern: '^_', 16 | caughtErrors: 'all', 17 | caughtErrorsIgnorePattern: '^_', 18 | destructuredArrayIgnorePattern: '^_', 19 | varsIgnorePattern: '^_', 20 | ignoreRestSiblings: true, 21 | }, 22 | ], 23 | }, 24 | }, 25 | ], 26 | }; 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | --- 5 | 6 | ### Description of the bug 7 | How would you shortly summarise the issue? 8 | 9 | ### To Reproduce 10 | What steps did you perform which led to this issue? 11 | 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 16 | #### Expected behavior 17 | What did you expect to have happened? 18 | 19 | #### Actual behavior 20 | What did it actually result in? 21 | 22 | ### Additional context 23 | Can you further explain the issue? E.g., information about version/environment or screenshots. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/DISCUSSION.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New discussion 3 | about: Start a discussion about a certain topic 4 | --- 5 | 6 | ### Topic and scope of discussion 7 | How would you summarise and scope the issue? 8 | 9 | ### Motivation 10 | Why should we have this discussion? 11 | 12 | ### Additional context 13 | Can you further explain the purpose of this discussion? E.g., screenshots or real-world examples. 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | --- 5 | 6 | ### Description of the feature 7 | How would you briefly summarise the feature? 8 | 9 | ### Motivation 10 | Why does this feature should be implemented? 11 | 12 | ### Additional context 13 | Can you further explain the feature? E.g., screenshots or real-world examples. 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/MINOR_CHANGE.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Minor changes 3 | about: Suggest a small change, or fix, like typos 4 | --- 5 | 6 | #### Old version 7 | What did you find that should be changed? 8 | 9 | #### New version 10 | How should it look after the suggested change? 11 | 12 | ### Additional context 13 | Can you further clarify the change? E.g., link to a dictionary or real-world examples. 14 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Linked issue 2 | Provide the issue(s) which this pull request relates to or fixes. 3 | 4 | ### Additional context 5 | Are there things the maintainers should be aware of before merging or closing this pull request? 6 | -------------------------------------------------------------------------------- /.github/actions/package-version/action.yml: -------------------------------------------------------------------------------- 1 | name: Package version 2 | description: Resolve the exact npm package version, using a semver 3 | 4 | inputs: 5 | name: 6 | description: Package name to resolve 7 | required: true 8 | 9 | semver: 10 | description: Semver version to resolve the exact version of (e.g. "latest" or "^1.0.0") 11 | default: 'latest' 12 | 13 | outputs: 14 | exact: 15 | description: The exact version of the package, resolved from semver 16 | value: ${{ steps.resolve.outputs.exact }} 17 | 18 | runs: 19 | using: composite 20 | steps: 21 | - name: 🌐 Resolve version 22 | id: resolve 23 | uses: actions/github-script@v7 24 | with: 25 | script: | 26 | const packageRequest = '${{ inputs.name }}@${{ inputs.semver }}' 27 | const { stdout: exactVersion } = await exec.getExecOutput('npm', ['show', packageRequest, 'version']); 28 | core.setOutput('exact', exactVersion.trim()); 29 | console.log('✅ Resolved version for ${{ inputs.name }}', { packageRequest, exactVersion: exactVersion.trim() }); 30 | -------------------------------------------------------------------------------- /.github/actions/setup-project/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup project 2 | description: Prepare the project for any CI action 3 | 4 | inputs: 5 | node-version: 6 | description: Version of Node to install 7 | default: 20.x 8 | 9 | with-fixture: 10 | description: If the setup should install the test/fixture files 11 | type: boolean 12 | 13 | without-cache: 14 | description: If the Node package cache should not be used 15 | type: boolean 16 | 17 | runs: 18 | using: composite 19 | steps: 20 | - name: 🏗 Setup Node with cache 21 | if: ${{ inputs.without-cache != 'true' }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ inputs.node-version }} 25 | cache: npm 26 | cache-dependency-path: | 27 | package-lock.json 28 | test/fixture/package-lock.json 29 | 30 | - name: 🏗 Setup Node without cache 31 | if: ${{ inputs.without-cache == 'true' }} 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: ${{ inputs.node-version }} 35 | 36 | - name: 📦 Install dependencies 37 | run: npm ci 38 | shell: bash 39 | 40 | - name: 📦 Install fixture 41 | if: ${{ inputs.with-fixture == 'true' }} 42 | run: npm ci 43 | working-directory: test/fixture 44 | shell: bash 45 | -------------------------------------------------------------------------------- /.github/actions/test-vscode/action.yml: -------------------------------------------------------------------------------- 1 | name: Test VS Code 2 | description: Run the integration tests within VS Code 3 | 4 | inputs: 5 | command: 6 | description: The test command to execute with xvfb-run 7 | required: true 8 | type: string 9 | 10 | runs: 11 | using: composite 12 | steps: 13 | - name: 🧪 Test on Linux 14 | if: ${{ runner.os == 'Linux' }} 15 | run: xvfb-run -a ${{ inputs.command }} 16 | shell: bash 17 | env: 18 | # Unset wrong Electron setting, 19 | # see: https://github.com/microsoft/vscode-test/issues/127 20 | DBUS_SESSION_BUS_ADDRESS: null 21 | 22 | - name: 🧪 Test on MacOS/Windows 23 | if: ${{ runner.os != 'Linux' }} 24 | run: ${{ inputs.command }} 25 | shell: bash 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | github-release: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | steps: 13 | - name: 🏗 Setup repo 14 | uses: actions/checkout@v4 15 | 16 | - name: 🏗 Setup project 17 | uses: ./.github/actions/setup-project 18 | 19 | - name: 🎁 Package extension 20 | run: npx vsce package --no-dependencies 21 | env: 22 | VSCODE_EXPO_TELEMETRY_KEY: ${{ secrets.VSCODE_TELEMETRY_KEY }} 23 | 24 | - name: 📋 Add package to release 25 | run: gh release upload ${{ github.ref_name }} vscode-expo-tools-${{ github.ref_name }}.vsix 26 | env: 27 | GH_TOKEN: ${{ github.token }} 28 | 29 | vscode-marketplace: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: 🏗 Setup repo from tag 33 | uses: actions/checkout@v4 34 | 35 | - name: 🏗 Setup project 36 | uses: ./.github/actions/setup-project 37 | 38 | - name: 🚀 Publish release to marketplace 39 | if: ${{ !github.event.release.prerelease }} 40 | run: npx vsce publish --no-dependencies 41 | env: 42 | VSCODE_EXPO_TELEMETRY_KEY: ${{ secrets.VSCODE_TELEMETRY_KEY }} 43 | VSCE_PAT: ${{ secrets.VSCODE_MARKETPLACE_TOKEN }} 44 | 45 | - name: 🚀 Publish pre-release to marketplace 46 | if: ${{ github.event.release.prerelease }} 47 | run: npx vsce publish --no-dependencies --pre-release 48 | env: 49 | VSCODE_EXPO_TELEMETRY_KEY: ${{ secrets.VSCODE_TELEMETRY_KEY }} 50 | VSCE_PAT: ${{ secrets.VSCODE_MARKETPLACE_TOKEN }} 51 | 52 | open-vsx: 53 | runs-on: ubuntu-latest 54 | steps: 55 | - name: 🏗 Setup repo from tag 56 | uses: actions/checkout@v4 57 | 58 | - name: 🏗 Setup project 59 | uses: ./.github/actions/setup-project 60 | 61 | - name: 🎁 Package extension 62 | run: npx vsce package --no-dependencies --out ./vscode-expo.vsix 63 | env: 64 | VSCODE_EXPO_TELEMETRY_KEY: ${{ secrets.VSCODE_TELEMETRY_KEY }} 65 | 66 | - name: 🚀 Publish release to open-vsx 67 | if: ${{ !github.event.release.prerelease }} 68 | run: npx ovsx publish --no-dependencies ./vscode-expo.vsix 69 | env: 70 | OVSX_PAT: ${{ secrets.VSCODE_OPENVSX_TOKEN }} 71 | 72 | - name: 🚀 Publish pre-release to open-vsx 73 | if: ${{ github.event.release.prerelease }} 74 | run: npx ovsx publish --no-dependencies --pre-release ./vscode-expo.vsix 75 | env: 76 | OVSX_PAT: ${{ secrets.VSCODE_OPENVSX_TOKEN }} 77 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | workflow_dispatch: 7 | inputs: 8 | release: 9 | description: 'type "release" to create the release (main branch only)' 10 | required: false 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | dryrun: 18 | if: ${{ github.ref != 'refs/heads/main' || github.event.inputs.release != 'release' }} 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: write 22 | issues: read 23 | pull-requests: read 24 | steps: 25 | - name: 🏗 Setup repo 26 | uses: actions/checkout@v4 27 | 28 | - name: 🏗 Setup project 29 | uses: ./.github/actions/setup-project 30 | 31 | - name: 📋 Dry-running release 32 | run: npx semantic-release --dry-run 33 | env: 34 | GITHUB_TOKEN: ${{ github.token }} 35 | 36 | create: 37 | if: ${{ github.ref == 'refs/heads/main' && github.event.inputs.release == 'release' }} 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: 🏗 Setup repo 41 | uses: actions/checkout@v4 42 | with: 43 | token: ${{ secrets.EXPOBOT_GITHUB_TOKEN }} 44 | 45 | - name: 🏗 Setup project 46 | uses: ./.github/actions/setup-project 47 | 48 | - name: 📋 Release code 49 | run: npx semantic-release 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.EXPOBOT_GITHUB_TOKEN }} 52 | GIT_AUTHOR_NAME: Expo CI 53 | GIT_AUTHOR_EMAIL: support+ci@expo.dev 54 | GIT_COMMITTER_NAME: Expo CI 55 | GIT_COMMITTER_EMAIL: support+ci@expo.dev 56 | -------------------------------------------------------------------------------- /.github/workflows/schema-eas-workflow.yml: -------------------------------------------------------------------------------- 1 | name: schema-eas-workflow 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | paths: 7 | - '.github/actions/**' 8 | - .github/workflows/schema-eas-workflow.yml 9 | - 'scripts/**' 10 | - package.json 11 | workflow_dispatch: 12 | inputs: 13 | storage: 14 | description: Update the EAS Workflow schema in storage? 15 | type: choice 16 | default: skip 17 | options: 18 | - skip 19 | - update-schema 20 | schedule: 21 | # daily at 07:00h UTC 22 | - cron: 0 7 * * * 23 | 24 | jobs: 25 | generate: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: 🏗 Setup repo 29 | uses: actions/checkout@v4 30 | 31 | - name: 🏗 Setup project 32 | uses: ./.github/actions/setup-project 33 | 34 | - name: 👷 Generate latest schema 35 | run: node ./scripts/schema-eas-workflow.js 36 | 37 | - name: 📋 Upload schema 38 | uses: actions/upload-artifact@v4 39 | with: 40 | if-no-files-found: error 41 | name: schema-eas-workflow 42 | path: ./schema/eas-workflow.json 43 | 44 | publish: 45 | if: ${{ github.event_name == 'schedule' || github.event.inputs.storage == 'update-schema' }} 46 | runs-on: ubuntu-latest 47 | needs: generate 48 | steps: 49 | - name: 🏗 Setup repo 50 | uses: actions/checkout@v4 51 | with: 52 | ref: schemas 53 | token: ${{ secrets.EXPOBOT_GITHUB_TOKEN }} 54 | 55 | - name: 📋 Download schema 56 | uses: actions/download-artifact@v4 57 | with: 58 | name: schema-eas-workflow 59 | path: ./schema/ 60 | 61 | - name: 🕵️ Latest schema 62 | id: diff 63 | run: | 64 | if [ "$(git diff | wc -l)" -gt "0" ]; then 65 | echo "⚠️ Schema has changed, see changes below" 66 | git diff 67 | exit 1 68 | else 69 | echo "✅ Schema is up-to-date" 70 | fi 71 | 72 | - name: 🗂 Update schema 73 | if: ${{ failure() && steps.diff.conclusion == 'failure' }} 74 | run: | 75 | git config --global user.name 'Expo CI' 76 | git config --global user.email 'support+ci@expo.io' 77 | git add ./schema 78 | git commit -am "chore: generate eas workflow schema for $(date +"%Y-%m-%dT%H-%M-%S%z")" 79 | git push 80 | -------------------------------------------------------------------------------- /.github/workflows/schema-eas.yml: -------------------------------------------------------------------------------- 1 | name: schema-eas 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | paths: 7 | - '.github/actions/**' 8 | - .github/workflows/schema-eas.yml 9 | - 'scripts/**' 10 | - package.json 11 | workflow_dispatch: 12 | inputs: 13 | version: 14 | description: EAS CLI tag or exact version to use (without `v`) 15 | type: string 16 | default: latest 17 | storage: 18 | description: Update the EAS schema in storage? 19 | type: choice 20 | default: skip 21 | options: 22 | - skip 23 | - update-schema 24 | schedule: 25 | # daily at 07:00h UTC 26 | - cron: 0 7 * * * 27 | 28 | jobs: 29 | generate: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: 🏗 Setup repo 33 | uses: actions/checkout@v4 34 | 35 | - name: 🏗 Setup project 36 | uses: ./.github/actions/setup-project 37 | 38 | - name: 🌐 Resolve version 39 | id: version 40 | uses: ./.github/actions/package-version 41 | with: 42 | name: eas-cli 43 | semver: ${{ github.event.inputs.version || 'latest' }} 44 | 45 | - name: 👷 Download schema 46 | run: | 47 | mkdir -p ./schema 48 | rm -f ./schema/eas.json 49 | curl https://raw.githubusercontent.com/expo/eas-cli/v${{ steps.version.outputs.exact }}/packages/eas-json/schema/eas.schema.json -o ./schema/eas.json 50 | 51 | - name: 📋 Upload schema 52 | uses: actions/upload-artifact@v4 53 | with: 54 | if-no-files-found: error 55 | name: schema-eas 56 | path: ./schema/eas.json 57 | 58 | publish: 59 | if: ${{ github.event_name == 'schedule' || github.event.inputs.storage == 'update-schema' }} 60 | runs-on: ubuntu-latest 61 | needs: generate 62 | steps: 63 | - name: 🏗 Setup repo 64 | uses: actions/checkout@v4 65 | with: 66 | ref: schemas 67 | token: ${{ secrets.EXPOBOT_GITHUB_TOKEN }} 68 | 69 | - name: 📋 Download schema 70 | uses: actions/download-artifact@v4 71 | with: 72 | name: schema-eas 73 | path: ./schema/ 74 | 75 | - name: 🕵️ Latest schema 76 | id: diff 77 | run: | 78 | if [ "$(git diff | wc -l)" -gt "0" ]; then 79 | echo "⚠️ Schema has changed, see changes below" 80 | git diff 81 | exit 1 82 | else 83 | echo "✅ Schema is up-to-date" 84 | fi 85 | 86 | - name: 🗂 Update schema 87 | if: ${{ failure() && steps.diff.conclusion == 'failure' }} 88 | run: | 89 | git config --global user.name 'Expo CI' 90 | git config --global user.email 'support+ci@expo.io' 91 | git add ./schema 92 | git commit -am "chore: generate eas schema for $(date +"%Y-%m-%dT%H-%M-%S%z")" 93 | git push 94 | -------------------------------------------------------------------------------- /.github/workflows/schema-metadata.yml: -------------------------------------------------------------------------------- 1 | name: schema-eas-metadata 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | paths: 7 | - '.github/actions/**' 8 | - .github/workflows/schema-metadata.yml 9 | - 'scripts/**' 10 | - package.json 11 | workflow_dispatch: 12 | inputs: 13 | version: 14 | description: EAS CLI tag or exact version to use (without `v`) 15 | type: string 16 | default: latest 17 | storage: 18 | description: Update the EAS metadata schema in storage? 19 | type: choice 20 | default: skip 21 | options: 22 | - skip 23 | - update-schema 24 | schedule: 25 | # daily at 07:00h UTC 26 | - cron: 0 7 * * * 27 | 28 | jobs: 29 | generate: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: 🏗 Setup repo 33 | uses: actions/checkout@v4 34 | 35 | - name: 🏗 Setup project 36 | uses: ./.github/actions/setup-project 37 | 38 | - name: 🌐 Resolve version 39 | id: version 40 | uses: ./.github/actions/package-version 41 | with: 42 | name: eas-cli 43 | semver: ${{ github.event.inputs.version || 'latest' }} 44 | 45 | - name: 👷 Download schema 46 | run: | 47 | mkdir -p ./schema 48 | rm -f ./schema/eas-metadata.json 49 | curl https://raw.githubusercontent.com/expo/eas-cli/v${{ steps.version.outputs.exact }}/packages/eas-cli/schema/metadata-0.json -o ./schema/eas-metadata.json 50 | 51 | - name: 📋 Upload schema 52 | uses: actions/upload-artifact@v4 53 | with: 54 | if-no-files-found: error 55 | name: schema-metadata 56 | path: ./schema/eas-metadata.json 57 | 58 | publish: 59 | if: ${{ github.event_name == 'schedule' || github.event.inputs.storage == 'update-schema' }} 60 | runs-on: ubuntu-latest 61 | needs: generate 62 | steps: 63 | - name: 🏗 Setup repo 64 | uses: actions/checkout@v4 65 | with: 66 | ref: schemas 67 | token: ${{ secrets.EXPOBOT_GITHUB_TOKEN }} 68 | 69 | - name: 📋 Download schema 70 | uses: actions/download-artifact@v4 71 | with: 72 | name: schema-metadata 73 | path: ./schema/ 74 | 75 | - name: 🕵️ Latest schema 76 | id: diff 77 | run: | 78 | if [ "$(git diff | wc -l)" -gt "0" ]; then 79 | echo "⚠️ Schema has changed, see changes below" 80 | git diff 81 | exit 1 82 | else 83 | echo "✅ Schema is up-to-date" 84 | fi 85 | 86 | - name: 🗂 Update schema 87 | if: ${{ failure() && steps.diff.conclusion == 'failure' }} 88 | run: | 89 | git config --global user.name 'Expo CI' 90 | git config --global user.email 'support+ci@expo.io' 91 | git add ./schema 92 | git commit -am "chore: generate eas metadata schema for $(date +"%Y-%m-%dT%H-%M-%S%z")" 93 | git push 94 | -------------------------------------------------------------------------------- /.github/workflows/schema-xdl.yml: -------------------------------------------------------------------------------- 1 | name: schema-expo-xdl 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | paths: 7 | - '.github/actions/**' 8 | - .github/workflows/schema-xdl.yml 9 | - 'scripts/**' 10 | - package.json 11 | workflow_dispatch: 12 | inputs: 13 | version: 14 | description: Expo tag or version to use 15 | type: string 16 | default: latest 17 | storage: 18 | description: Update the Expo XDL schema in storage? 19 | type: choice 20 | default: skip 21 | options: 22 | - skip 23 | - update-schema 24 | schedule: 25 | # daily at 07:00h UTC 26 | - cron: 0 7 * * * 27 | 28 | jobs: 29 | generate: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: 🏗 Setup repo 33 | uses: actions/checkout@v4 34 | 35 | - name: 🏗 Setup project 36 | uses: ./.github/actions/setup-project 37 | 38 | - name: 👷 Generate older schema 39 | if: ${{ github.event_name != 'schedule' && github.event.inputs.version != 'latest' }} 40 | run: node ./scripts/schema-expo-xdl.js --sdk-version="${{ github.event.inputs.version }}" 41 | 42 | - name: 👷 Generate latest schema 43 | if: ${{ github.event_name == 'schedule' || github.event.inputs.version == 'latest' }} 44 | run: node ./scripts/schema-expo-xdl.js --latest 45 | 46 | - name: 📋 Upload schema 47 | uses: actions/upload-artifact@v4 48 | with: 49 | if-no-files-found: error 50 | name: schema-xdl 51 | path: ./schema/expo-xdl*.json 52 | 53 | publish: 54 | if: ${{ github.event_name == 'schedule' || github.event.inputs.storage == 'update-schema' }} 55 | runs-on: ubuntu-latest 56 | needs: generate 57 | steps: 58 | - name: 🏗 Setup repo 59 | uses: actions/checkout@v4 60 | with: 61 | ref: schemas 62 | token: ${{ secrets.EXPOBOT_GITHUB_TOKEN }} 63 | 64 | - name: 📋 Download schema 65 | uses: actions/download-artifact@v4 66 | with: 67 | name: schema-xdl 68 | path: ./schema/ 69 | 70 | - name: 🕵️ Latest schema 71 | id: diff 72 | run: | 73 | if [ "$(git diff | wc -l)" -gt "0" ]; then 74 | echo "⚠️ Schema has changed, see changes below" 75 | git diff 76 | exit 1 77 | else 78 | echo "✅ Schema is up-to-date" 79 | fi 80 | 81 | - name: 🗂 Update schema 82 | if: ${{ failure() && steps.diff.conclusion == 'failure' }} 83 | run: | 84 | git config --global user.name 'Expo CI' 85 | git config --global user.email 'support+ci@expo.io' 86 | git add ./schema 87 | git commit -am "chore: generate xdl schema for $(date +"%Y-%m-%dT%H-%M-%S%z")" 88 | git push 89 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | types: [opened, synchronize] 7 | push: 8 | branches: [main] 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | lint: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: 🏗 Setup repo 19 | uses: actions/checkout@v4 20 | 21 | - name: 🏗 Setup project 22 | uses: ./.github/actions/setup-project 23 | 24 | - name: ✅ Lint project 25 | run: npm run lint -- --max-warnings 0 26 | 27 | scripts: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: 🏗 Setup repo 31 | uses: actions/checkout@v4 32 | 33 | - name: 🏗 Setup project 34 | uses: ./.github/actions/setup-project 35 | 36 | - name: 🧪 Test scripts 37 | run: npm run test:scripts 38 | 39 | bundled: 40 | # This extension is bundled before publishing to the stores, 41 | # it removes the need for vscode to download dependencies, making install fast. 42 | # But this version might cause issues with webpack "hijacking" `require` statements. 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: 🏗 Setup repo 46 | uses: actions/checkout@v4 47 | 48 | - name: 🏗 Setup project 49 | uses: ./.github/actions/setup-project 50 | with: 51 | with-fixture: true 52 | 53 | - name: 👷 Build tests 54 | run: npm run build 55 | 56 | - name: 📦 Bundle extension 57 | run: npm run build:production 58 | 59 | # When bundled, these dependencies aren't available in normal environments. 60 | # This drops all "dependencies" from the project, to only rely on the bundle itself. 61 | - name: 🧹 Remove bundled dependencies 62 | run: | 63 | echo "$(jq 'del(.dependencies)' package.json)" > package.json 64 | npm install --no-package-lock --ignore-scripts 65 | 66 | - name: 🧪 Test project 67 | uses: ./.github/actions/test-vscode 68 | with: 69 | command: npm run test:vscode 70 | env: 71 | VSCODE_VERSION: stable 72 | 73 | vscode: 74 | runs-on: ${{ matrix.os }}-latest 75 | strategy: 76 | fail-fast: false 77 | matrix: 78 | os: [ubuntu, macos, windows] 79 | vscode: [oldest, stable, insiders] 80 | steps: 81 | - name: 🏗 Setup repo 82 | uses: actions/checkout@v4 83 | 84 | - name: 🏗 Setup project 85 | uses: ./.github/actions/setup-project 86 | with: 87 | with-fixture: true 88 | 89 | - name: 👷 Build project 90 | run: npm run build 91 | 92 | # This handles the "oldest" vscode version by looking up our "oldest supported version" 93 | - name: 🕵️ Set vscode version 94 | uses: actions/github-script@v7 95 | with: 96 | script: | 97 | const { engines } = require('./package.json') 98 | const vscode = '${{ matrix.vscode }}' 99 | const target = vscode === 'oldest' 100 | ? engines.vscode.substring(1) 101 | : vscode 102 | core.exportVariable('VSCODE_VERSION', target) 103 | 104 | - name: 🧪 Test project 105 | uses: ./.github/actions/test-vscode 106 | with: 107 | command: npm run test:vscode 108 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build files 2 | out 3 | coverage 4 | tsconfig.*tsbuildinfo 5 | 6 | # schema files 7 | /schema 8 | 9 | # dependencies 10 | node_modules 11 | npm-debug.log* 12 | lerna-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | yarn.lock 16 | 17 | # editors 18 | .idea 19 | 20 | # vscode 21 | .vscode-test/ 22 | *.vsix 23 | 24 | # misc 25 | .DS_Store 26 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | tabWidth: 2, 4 | singleQuote: true, 5 | trailingComma: 'es5', 6 | endOfLine: 'auto', 7 | }; 8 | -------------------------------------------------------------------------------- /.releaserc.js: -------------------------------------------------------------------------------- 1 | const rules = [ 2 | { type: 'feat', release: 'minor', title: 'New features' }, 3 | { type: 'feature', release: 'minor', title: 'New features' }, 4 | { type: 'fix', release: 'patch', title: 'Bug fixes' }, 5 | { type: 'refactor', release: 'patch', title: 'Code changes' }, 6 | { type: 'chore', release: 'patch', title: 'Other chores' }, 7 | { type: 'docs', release: 'patch', title: 'Documentation changes' }, 8 | ]; 9 | 10 | // Simple mapping to order the commit groups 11 | const sortMap = Object.fromEntries(rules.map((rule, index) => [rule.title, index])); 12 | 13 | module.exports = { 14 | branches: ['main'], 15 | tagFormat: '${version}', 16 | plugins: [ 17 | ['@semantic-release/commit-analyzer', { 18 | preset: 'conventionalcommits', 19 | releaseRules: [ 20 | { breaking: true, release: 'major' }, 21 | { revert: true, release: 'patch' }, 22 | ].concat( 23 | rules.map(({ type, release }) => ({ type, release })) 24 | ), 25 | }], 26 | ['@semantic-release/release-notes-generator', { 27 | preset: 'conventionalcommits', 28 | presetConfig: { 29 | types: rules.map(({ type, title }) => ({ type, section: title })), 30 | }, 31 | writerOpts: { 32 | commitGroupsSort: (a, z) => sortMap[a.title] - sortMap[z.title], 33 | }, 34 | }], 35 | '@semantic-release/changelog', 36 | ['@semantic-release/npm', { npmPublish: false }], 37 | ['@semantic-release/git', { 38 | message: 'chore: create new release ${nextRelease.version}\n\n${nextRelease.notes}', 39 | assets: ['package.json', 'CHANGELOG.md'], 40 | }], 41 | ['@semantic-release/github', { 42 | draftRelease: true, 43 | }], 44 | ], 45 | }; 46 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off" 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "watch", 8 | "type": "npm", 9 | "script": "start", 10 | "problemMatcher": "$tsc-watch", 11 | "isBackground": true, 12 | "presentation": { 13 | "reveal": "never" 14 | } 15 | }, 16 | { 17 | "label": "clean", 18 | "type": "npm", 19 | "script": "clean", 20 | "problemMatcher": "$tsc", 21 | "presentation": { 22 | "reveal": "never" 23 | } 24 | }, 25 | { 26 | "label": "install-fixtures", 27 | "type": "shell", 28 | "command": "npm install", 29 | "options": { 30 | "cwd": "${workspaceFolder}/test/fixture" 31 | }, 32 | "presentation": { 33 | "reveal": "never" 34 | } 35 | }, 36 | { 37 | "label": "build-development", 38 | "type": "npm", 39 | "script": "build", 40 | "problemMatcher": "$tsc", 41 | "presentation": { 42 | "reveal": "never" 43 | }, 44 | "group": "build" 45 | }, 46 | { 47 | "label": "build-production", 48 | "type": "npm", 49 | "script": "build:production", 50 | "problemMatcher": "$tsc", 51 | "presentation": { 52 | "reveal": "never" 53 | }, 54 | "group": "build" 55 | }, 56 | { 57 | "label": "build-extension", 58 | "type": "shell", 59 | "command": "npx vsce package", 60 | "group": "build" 61 | }, 62 | { 63 | "label": "launch-start-development", 64 | "dependsOn": ["clean", "build-development", "watch"] 65 | }, 66 | { 67 | "label": "launch-start-production", 68 | "dependsOn": ["clean", "build-production"] 69 | }, 70 | { 71 | "label": "launch-test-development", 72 | "dependsOn": ["clean", "install-fixtures", "build-development"] 73 | }, 74 | { 75 | "label": "launch-test-production", 76 | "dependsOn": ["clean", "install-fixtures", "build-development", "build-production"] 77 | } 78 | ] 79 | } 80 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | # switch to allow-list, ignore everything 2 | ** 3 | 4 | # only include these files 5 | !out/** 6 | !images/logo-marketplace.png 7 | !CHANGELOG.md 8 | !LICENSE.md 9 | !package.json 10 | !README.md 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to vscode-expo 2 | 3 | ## 📦 Download and Setup 4 | 5 | 1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device. (`git remote add upstream git@github.com:expo/vscode-expo.git` 😉) 6 | 2. Make sure you have the following packages globally installed on your system: 7 | - [node](https://nodejs.org/) (active Node LTS or higher is recommended) 8 | - [npm](https://npmjs.com/) 9 | 3. Install the Node packages (`npm install`) 10 | 11 | ## 🚗 Start the plugin in development mode 12 | 13 | In vscode, go to "Run and debug" and pick `Run Extension (development)`. 14 | 15 | This will open up a new vscode window with the plugin loaded from source. 16 | When changing code, it will auto-update and initialize the plugin in this window. 17 | 18 | ## 🏎️ Start the plugin in production mode 19 | 20 | Because we want the plugin to install almost instantaneously, we avoid dependencies. 21 | Everything is compiled to a single JS file and embedded within the plugin. 22 | 23 | To test if the code is working using a single JS file, pick `Run Extension (production)`. 24 | 25 | ## ✅ Testing 26 | 27 | Testing is done using [Jest](https://jestjs.io/https://jestjs.io/) within vscode. 28 | 29 | You can try this locally by running `Extension Tests`. 30 | In CI we are running tests with the oldest supported version ([see test workflow](https://github.com/expo/vscode-expo/blob/main/.github/workflows/test.yml#L10)), latest stable, and latest insider. 31 | 32 | ## 📝 Writing a Commit Message 33 | 34 | > If this is your first time committing to a large public repo, you could look through this neat tutorial: ["How to Write a Git Commit Message"](https://chris.beams.io/posts/git-commit/) 35 | 36 | Commit messages are formatted using the [Conventional Commits](https://www.conventionalcommits.org/) format. 37 | 38 | ``` 39 | docs: fix typo in xxx 40 | feature: add support for SDK 40 41 | chore: add test-case for custom completions 42 | fix: improve logging for errors 43 | refactor: update loading icon 44 | ``` 45 | 46 | ## 🔎 Before Submitting a PR 47 | 48 | To help keep CI green, please make sure of the following: 49 | 50 | - Run `npm run lint -- --fix` to fix the formatting of the code. Ensure that `npm run lint` succeeds without errors or warnings. 51 | - Run `npm run build` to ensure the build runs correctly and without errors or warnings. 52 | - Run `npm run build:production` to ensure the build runs correctly and without errors or warnings, in production mode. 53 | 54 | ## 🚀 Releasing a new version 55 | 56 | We have multiple workflows working together to publish a new release to the vscode marketplace. 57 | 58 | 1. The `Release` workflow generates a new version based on the commits. 59 | - This is a manually triggered workflow. 60 | - This will also update the changelog, package, tags, and publish a new release to github. 61 | 2. The `Publish` workflow builds and submits a new version to vscode marketplace. 62 | - This is triggered once a new release is created on github. 63 | - It builds the project in production mode and sends it to the vscode marketplace. 64 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2020-present Cedric van Putten 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Expo Tools 4 | 5 |
6 | Expo Tools 7 |

8 | 9 |

10 | 11 | Latest release 12 | 13 | 14 | Workflow status 15 | 16 | 17 | Install from VS Code Marketplace 18 | 19 | 20 | Install from Open VSX 21 | 22 |

23 | 24 |

25 | IntelliSense  —  26 | Debug apps  —  27 | Preview prebuild  —  28 | Preview manifest  —  29 | Changelog  —  30 | Contribute 31 |

32 | 33 |
34 | 35 | Expo Tools adds suggestions and docs for all Expo config. It also shows live previews for native files from prebuild, right in your editor! 36 | 37 |
38 | 39 | ## IntelliSense for Expo configs 40 | 41 | Expo config IntelliSense example 42 | 43 | Get suggestions and docs where you need them the most. 44 | 45 | - EAS Build / Submit / Update → [`eas.json`](https://docs.expo.dev/eas/json/) 46 | - EAS Metadata → [`store.config.json`](https://docs.expo.dev/eas/metadata/) 47 | - EAS Workflow → [`.eas/workflows/*.yml`](https://docs.expo.dev/eas/workflows/get-started/) 48 | - Expo Manifest → [`app.json`](https://docs.expo.dev/versions/latest/config/app/) 49 | - Expo Modules → [`expo-module.config.json`](https://docs.expo.dev/modules/overview/) 50 | 51 |
52 |
53 |
54 | 55 | ## Debug Expo apps 56 | 57 | Debug your app, without leaving your editor. The built-in `expo` debugger can connect directly to your simulator or phone, giving you complete insights into what your app is doing. 58 | 59 | - `Expo: Debug ...` → Start debugging with the default settings, with just a single command. 60 | - **.vscode/launch.json** → Fully configure the `expo` debugger through [VS Code launch scripts](https://code.visualstudio.com/docs/editor/debugging). 61 | 62 |
63 | 64 | ## Live preview for native files 65 | 66 | See how your changes in **app.json** or **app.config.js** would affect the native files created by [`npx expo prebuild`](https://docs.expo.dev/workflow/prebuild/). The previews are generated whenever you save the app manifest and won't affect existing files. 67 | 68 | > Open **app.json** or **app.config.js** and run the **`Expo: Preview Modifier`** command. 69 | 70 | ### Supported Android files 71 | 72 | - [`AndroidManifest.xml`](https://developer.android.com/guide/topics/manifest/manifest-intro) → App manifest with settings for build tools, Android, and Google Play. 73 | - [`gradle.properties`](https://developer.android.com/studio/build#properties-files) → Configuration for the Grdle build toolkit itself. 74 | - [`colors.xml`](https://developer.android.com/guide/topics/resources/more-resources#Color) → Color resources defining the color and opacity. 75 | - [`strings.xml`](https://developer.android.com/guide/topics/resources/string-resource) → String resources defining string content, styling, and formatting. 76 | - [`styles.xml`](https://developer.android.com/guide/topics/resources/style-resource) → Style resources defining the format and look for a UI element. 77 | 78 | ### Supported iOS files 79 | 80 | - [`Info.plist`](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/AboutInformationPropertyListFiles.html) → Property list with core config for the app. 81 | - [`[name].entitlements`](https://docs.expo.dev/build-reference/ios-capabilities/#entitlements) → Property list enabling permission to use services. 82 | - [`Expo.plist`](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/AboutInformationPropertyListFiles.html) → Supporting property list with config for Expo. 83 | - [`Podfile.properties.json`](https://github.com/expo/fyi/blob/main/hermes-ios-config.md#create-iospodfilepropertiesjson) → JSON file with install or build config. 84 | 85 |
86 | 87 | ## Live preview for manifest 88 | 89 | Preview the generated manifests for your app. You can do this for the different config types listed below. 90 | 91 | > Open **app.json** or **app.config.js** and run the **`Expo: Preview Config`** command. 92 | 93 | - **prebuild** - The local app manifest when running `npx expo prebuild`. 94 | - **introspect** - The evaluated app manifest result when running `npx expo prebuild`. 95 | - **public** - The hosted manifest when using Expo Updates. 96 | 97 |
98 |
99 | with ❤️  byCedric 100 |
101 |
102 | -------------------------------------------------------------------------------- /images/feature-autocomplete.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/expo/vscode-expo/db0635c40b1a2d8e5e67b341c5c4000ecfda1a5e/images/feature-autocomplete.gif -------------------------------------------------------------------------------- /images/logo-marketplace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/expo/vscode-expo/db0635c40b1a2d8e5e67b341c5c4000ecfda1a5e/images/logo-marketplace.png -------------------------------------------------------------------------------- /images/logo-repository.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/expo/vscode-expo/db0635c40b1a2d8e5e67b341c5c4000ecfda1a5e/images/logo-repository.png -------------------------------------------------------------------------------- /patches/@expo+config-plugins+4.1.5.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@expo/config-plugins/build/utils/plugin-resolver.js b/node_modules/@expo/config-plugins/build/utils/plugin-resolver.js 2 | index e6140b8..13cc6d5 100644 3 | --- a/node_modules/@expo/config-plugins/build/utils/plugin-resolver.js 4 | +++ b/node_modules/@expo/config-plugins/build/utils/plugin-resolver.js 5 | @@ -255,7 +255,7 @@ function resolveConfigPluginExport({ 6 | 7 | function requirePluginFile(filePath) { 8 | try { 9 | - return require(filePath); 10 | + return require(/* webpackIgnore: true */ filePath); 11 | } catch (error) { 12 | // TODO: Improve error messages 13 | throw error; 14 | -------------------------------------------------------------------------------- /patches/jest-snapshot+28.1.3.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/jest-snapshot/build/SnapshotResolver.js b/node_modules/jest-snapshot/build/SnapshotResolver.js 2 | index a3d3b05..69f0132 100644 3 | --- a/node_modules/jest-snapshot/build/SnapshotResolver.js 4 | +++ b/node_modules/jest-snapshot/build/SnapshotResolver.js 5 | @@ -13,7 +13,7 @@ var path = _interopRequireWildcard(require('path')); 6 | 7 | var _chalk = _interopRequireDefault(require('chalk')); 8 | 9 | -var _transform = require('@jest/transform'); 10 | +// var _transform = require('@jest/transform'); 11 | 12 | var _jestUtil = require('jest-util'); 13 | 14 | @@ -81,7 +81,9 @@ const cache = new Map(); 15 | 16 | const buildSnapshotResolver = async ( 17 | config, 18 | - localRequire = (0, _transform.createTranspilingRequire)(config) 19 | + // Default to just `require` instead of the failing transpile require 20 | + localRequire = require, 21 | + // localRequire = (0, _transform.createTranspilingRequire)(config) 22 | ) => { 23 | var _cache$get; 24 | 25 | @@ -133,6 +135,7 @@ async function createCustomSnapshotResolver( 26 | const custom = (0, _jestUtil.interopRequireDefault)( 27 | await localRequire(snapshotResolverPath) 28 | ).default; 29 | + 30 | const keys = [ 31 | ['resolveSnapshotPath', 'function'], 32 | ['resolveTestPath', 'function'], -------------------------------------------------------------------------------- /scripts/__tests__/schema-eas-workflow.test.js: -------------------------------------------------------------------------------- 1 | const { expect, use } = require('chai'); 2 | const sinon = require('sinon'); 3 | const sinonChai = require('sinon-chai'); 4 | 5 | const { createVscodeSchema, fetchWorkflowSchema } = require('../schema-eas-workflow'); 6 | 7 | use(sinonChai); 8 | 9 | describe('createVscodeSchema', () => { 10 | it('adds root description', () => { 11 | const workflowSchema = { scheme: 'test' }; 12 | const schema = createVscodeSchema(workflowSchema); 13 | expect(schema).to.have.property('description'); 14 | expect(schema).to.have.property('markdownDescription'); 15 | }); 16 | }); 17 | 18 | describe('fetchWorkflowSchema', () => { 19 | let fetchStub; 20 | 21 | beforeEach(() => { 22 | fetchStub = sinon.stub(globalThis, 'fetch'); 23 | }); 24 | afterEach(() => { 25 | fetchStub.restore(); 26 | }); 27 | 28 | it('fetches the schema from API', async () => { 29 | const mockSchema = {}; 30 | fetchStub.resolves({ 31 | ok: true, 32 | json: async () => ({ data: mockSchema }), 33 | }); 34 | 35 | const schema = await fetchWorkflowSchema(); 36 | expect(schema).to.deep.equal(mockSchema); 37 | expect(fetchStub).to.have.been.calledWith('https://api.expo.dev/v2/workflows/schema'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /scripts/__tests__/utils/mocha-runner.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const { glob } = require('glob'); 3 | const Mocha = require('mocha'); 4 | const path = require('path'); 5 | 6 | async function run() { 7 | // Find all test files 8 | const files = await glob('**/__tests__/**/*.{e2e,test}.{js,ts}', { 9 | absolute: true, 10 | cwd: path.resolve(__dirname, '../..'), 11 | ignore: 'node_modules/**', 12 | }); 13 | 14 | if (!files.length) { 15 | throw new Error('No test files found'); 16 | } 17 | 18 | // Configure the test runner 19 | const tests = new Mocha({ 20 | reporter: require('mocha-chai-jest-snapshot/reporters/spec'), 21 | }); 22 | 23 | // Globally initialize Chai extensions 24 | tests.globalSetup(() => { 25 | chai.use(require('chai-subset')); 26 | chai.use(require('mocha-chai-jest-snapshot').jestSnapshotPlugin()); 27 | }); 28 | 29 | // Add all tests 30 | for (const file of files) { 31 | tests.addFile(file); 32 | } 33 | 34 | // Execute the tests 35 | return new Promise((resolve, reject) => { 36 | tests.run((failures) => { 37 | if (failures === 0) { 38 | resolve(); 39 | } else { 40 | reject(new Error(`${failures} out of ${files.length} failed tests`)); 41 | } 42 | }); 43 | }); 44 | } 45 | 46 | run().then(() => console.log('✓ All tests passed!')); 47 | -------------------------------------------------------------------------------- /scripts/schema-eas-workflow.js: -------------------------------------------------------------------------------- 1 | const arg = require('arg'); 2 | const fs = require('fs/promises'); 3 | const jsonSchemaTraverse = require('json-schema-traverse'); 4 | const path = require('path'); 5 | 6 | const SCHEMA_PREFIX = 'eas-workflow'; 7 | const SCHEMA_DIR = path.resolve(__dirname, '../schema'); 8 | 9 | // Execute when running in CI 10 | if (process.env.CI) { 11 | generate(arg({ '--latest': Boolean })).then((schemaPath) => 12 | console.log(`✓ Generated EAS Workflow schema!\n ${schemaPath}`) 13 | ); 14 | } 15 | 16 | /** Download and process the EAS Workflow schema for usage in vscode. */ 17 | async function generate(args) { 18 | const workflowSchema = await fetchWorkflowSchema(); 19 | const schema = createVscodeSchema(workflowSchema); 20 | 21 | const schemaPath = path.join(SCHEMA_DIR, `${SCHEMA_PREFIX}.json`); 22 | 23 | await fs.mkdir(path.dirname(schemaPath), { recursive: true }); 24 | await fs.writeFile(schemaPath, JSON.stringify(schema, null, 2), 'utf-8'); 25 | 26 | return schemaPath; 27 | } 28 | 29 | function createVscodeSchema(workflowSchema) { 30 | // Ensure all `description` have a sibling `markdownDescription` property 31 | jsonSchemaTraverse(workflowSchema, (nested) => { 32 | if (nested.description && !nested.markdownDescription) { 33 | nested.markdownDescription = nested.description; 34 | } 35 | }); 36 | 37 | // Add root descroptions to the schema, with links to the docs 38 | workflowSchema.description = `All configurable EAS Workflow properties. Learn more: https://docs.expo.dev/eas/workflows/syntax/`; 39 | workflowSchema.markdownDescription = `All configurable EAS Workflow properties. [Learn more](https://docs.expo.dev/eas/workflows/syntax/)`; 40 | 41 | return workflowSchema; 42 | } 43 | 44 | async function fetchWorkflowSchema() { 45 | const response = await fetch('https://api.expo.dev/v2/workflows/schema'); 46 | if (!response.ok) { 47 | throw new Error(`Unable to fetch EAS Workflow schema, received status: ${response.status}`); 48 | } 49 | 50 | return await response.json().then(({ data }) => data); 51 | } 52 | 53 | // Export all methods for testing 54 | module.exports = { 55 | generate, 56 | createVscodeSchema, 57 | fetchWorkflowSchema, 58 | }; 59 | -------------------------------------------------------------------------------- /scripts/schema-expo-xdl.js: -------------------------------------------------------------------------------- 1 | const arg = require('arg'); 2 | const assert = require('assert'); 3 | const fs = require('fs/promises'); 4 | const jsonSchemaTraverse = require('json-schema-traverse'); 5 | const path = require('path'); 6 | 7 | const { resolveExpoVersion, resolveExpoSchema } = require('./utils/expo'); 8 | 9 | const SCHEMA_PREFIX = 'expo-xdl'; 10 | const SCHEMA_DIR = path.resolve(__dirname, '../schema'); 11 | 12 | // Execute when running in CI 13 | if (process.env.CI) { 14 | generate( 15 | arg({ 16 | '--sdk-version': String, 17 | '--latest': Boolean, 18 | }) 19 | ).then((schemaPath) => console.log(`✓ Generated XDL schema!\n ${schemaPath}`)); 20 | } 21 | 22 | /** Download and process the XDL schema for usage in vscode. */ 23 | async function generate(args) { 24 | if (args['--latest']) { 25 | assert(!args['--sdk-version'], `--latest can't be used with --sdk-version`); 26 | } 27 | 28 | const sdkVersion = await resolveExpoVersion(args['--sdk-version'] || 'latest'); 29 | const sdkSchema = await resolveExpoSchema(sdkVersion); 30 | const schema = createVscodeSchema(sdkVersion, sdkSchema); 31 | 32 | const schemaPath = path.resolve( 33 | SCHEMA_DIR, 34 | args['--latest'] ? `${SCHEMA_PREFIX}.json` : `${SCHEMA_PREFIX}-${sdkVersion}.json` 35 | ); 36 | 37 | await fs.mkdir(path.dirname(schemaPath), { recursive: true }); 38 | await fs.writeFile(schemaPath, JSON.stringify(schema, null, 2), 'utf-8'); 39 | 40 | return schemaPath; 41 | } 42 | 43 | /** 44 | * Pre-process the XDL schema to make it compatible with vscode. 45 | * This modifies the provided `xdlSchema` object. 46 | */ 47 | function createVscodeSchema(xdlVersion, xdlSchema) { 48 | jsonSchemaTraverse( 49 | xdlSchema, 50 | (nested, _nestedPath, _root, _parentPath, parentKey, parent, parentKeyIndex) => { 51 | traverseSchemaRemoveAutoGenerated(nested, parent, parentKey, parentKeyIndex); 52 | traverseSchemaAddBareWorkflowDescription(nested); 53 | traverseSchemaAddMarkdownDescription(nested); 54 | traverseSchemaAddPatternErrorDescription(nested); 55 | } 56 | ); 57 | 58 | // note: we need to move over definitions from the schema and put them into root 59 | const definitions = xdlSchema.definitions ?? {}; 60 | if (xdlSchema.definitions) { 61 | delete xdlSchema.definitions; 62 | } 63 | 64 | // Create the root definition 65 | definitions.VscodeExpoXdl = xdlSchema; 66 | // Add root descriptions to the schema, with links to the docs 67 | definitions.VscodeExpoXdl.description = `All configurable Expo manifest properties. Learn more: https://docs.expo.dev/versions/v${xdlVersion}.0.0/config/app/`; 68 | definitions.VscodeExpoXdl.markdownDescription = `All configurable Expo manifest properties. [Learn more](https://docs.expo.dev/versions/v${xdlVersion}.0.0/config/app/)`; 69 | 70 | return { 71 | description: 'The Expo manifest (app.json) validation and documentation.', 72 | version: `${xdlVersion}.0.0`, 73 | $schema: 'http://json-schema.org/draft-07/schema#', 74 | definitions, 75 | // Allow everything under `expo.*` to also be defined at the root level. 76 | // This is supported in `@expo/config`, if `expo` is NOT defined. 77 | oneOf: [ 78 | { 79 | type: 'object', 80 | required: ['expo'], 81 | // Do not warn about additional properties for plain React Native apps 82 | additionalProperties: true, 83 | properties: { 84 | expo: { $ref: '#/definitions/VscodeExpoXdl' }, 85 | }, 86 | }, 87 | { $ref: '#/definitions/VscodeExpoXdl' }, 88 | ], 89 | }; 90 | } 91 | 92 | /** Remove auto-generated properties from the schema, they aren't configurable by the user. */ 93 | function traverseSchemaRemoveAutoGenerated( 94 | nested, 95 | parent = undefined, 96 | parentKey = undefined, 97 | parentKeyIndex = undefined 98 | ) { 99 | // Only edit nodes with parents that are autogenerated 100 | if (nested.meta?.autogenerated && parent && parentKey && parentKeyIndex) { 101 | delete parent[parentKey][parentKeyIndex]; 102 | } 103 | } 104 | 105 | /** Move bare workflow notes to the property descriptions, if available. */ 106 | function traverseSchemaAddBareWorkflowDescription(schema) { 107 | if (schema.meta?.bareWorkflow) { 108 | const description = schema.description || ''; 109 | const bareNotes = schema.meta.bareWorkflow; 110 | 111 | schema.description = `${description}\n\n**Bare workflow** - ${bareNotes}`.trim(); 112 | } 113 | } 114 | 115 | /** 116 | * Add a `markdownDescription` property based on the `description`, if not defined. 117 | * Only the `markdownDescription` property allows rendering markdown in vscode. 118 | */ 119 | function traverseSchemaAddMarkdownDescription(schema) { 120 | if (schema.description && !schema.markdownDescription) { 121 | schema.markdownDescription = schema.description; 122 | } 123 | } 124 | 125 | /** 126 | * Add a human-readable error message to pattern validation. 127 | * Only (sub)schemas with both a `pattern` and `meta.regexHuman` will get this. 128 | * @see https://github.com/microsoft/vscode-json-languageservice/blob/12275e448a91973777c94a2e5d92c961f281231a/src/jsonSchema.ts#L79 129 | */ 130 | function traverseSchemaAddPatternErrorDescription(schema) { 131 | if (schema.pattern && schema.meta?.regexHuman) { 132 | schema.patternErrorMessage = schema.meta.regexHuman; 133 | } 134 | } 135 | 136 | // Export all methods for testing 137 | module.exports = { 138 | generate, 139 | resolveExpoVersion, 140 | resolveExpoSchema, 141 | createVscodeSchema, 142 | traverseSchemaRemoveAutoGenerated, 143 | traverseSchemaAddBareWorkflowDescription, 144 | traverseSchemaAddMarkdownDescription, 145 | traverseSchemaAddPatternErrorDescription, 146 | }; 147 | -------------------------------------------------------------------------------- /scripts/utils/expo.js: -------------------------------------------------------------------------------- 1 | const execa = require('execa'); 2 | const { major } = require('semver'); 3 | 4 | /** Find the major SDK version from the `expo` package. */ 5 | async function resolveExpoVersion(tagOrVersion = 'latest') { 6 | let stdout = ''; 7 | 8 | try { 9 | ({ stdout } = await execa('npm', ['info', `expo@${tagOrVersion}`, '--json', 'version'])); 10 | } catch (error) { 11 | throw new Error(`Could not resolve expo@${tagOrVersion}, reason:\n${error.message || error}`); 12 | } 13 | 14 | // thanks npm, for returning a "" json string value for invalid versions 15 | if (!stdout) { 16 | throw new Error(`Could not resolve expo@${tagOrVersion}, reason:\nInvalid version`); 17 | } 18 | 19 | // thanks npm, for returning a "x.x.x" json value... 20 | if (stdout.startsWith('"')) { 21 | stdout = `[${stdout}]`; 22 | } 23 | 24 | return major(JSON.parse(stdout).at(-1)); 25 | } 26 | 27 | /** Download the latest XDL schema by major Expo SDK version. */ 28 | async function resolveExpoSchema(sdkVersion) { 29 | return fetch(`https://exp.host/--/api/v2/project/configuration/schema/${sdkVersion}.0.0`) 30 | .then((response) => response.json()) 31 | .then((json) => json.data.schema); 32 | } 33 | 34 | module.exports = { 35 | resolveExpoSchema, 36 | resolveExpoVersion, 37 | }; 38 | -------------------------------------------------------------------------------- /src/__tests__/commands/code-provider.e2e.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as jsonc from 'jsonc-parser'; 3 | import vscode from 'vscode'; 4 | 5 | import { PreviewCommand, PreviewModProvider } from '../../preview/constants'; 6 | import { 7 | closeAllEditors, 8 | getWorkspaceUri, 9 | replaceEditorContent, 10 | storeOriginalContent, 11 | waitForEditorOpen, 12 | } from '../utils/vscode'; 13 | import { waitForFalse, waitForTrue } from '../utils/wait'; 14 | 15 | describe('CodeProvider', () => { 16 | let app: vscode.TextEditor; 17 | let restoreContent: ReturnType; 18 | 19 | before(async () => { 20 | app = await vscode.window.showTextDocument(getWorkspaceUri('preview', 'app.json')); 21 | restoreContent = storeOriginalContent(app); 22 | }); 23 | 24 | after(async () => { 25 | await restoreContent(); 26 | await closeAllEditors(); 27 | }); 28 | 29 | it('updates preview on added and removed content', async () => { 30 | await vscode.commands.executeCommand( 31 | PreviewCommand.OpenExpoFilePrebuild, 32 | PreviewModProvider.androidManifest 33 | ); 34 | 35 | const preview = await waitForEditorOpen('AndroidManifest.xml'); 36 | const addition = jsonc.modify( 37 | app.document.getText(), 38 | ['expo', 'updates', 'url'], 39 | 'https://example.com/updates/url', 40 | { formattingOptions: { insertSpaces: true } } 41 | ); 42 | 43 | const EXPECTED_UPDATES_URL = 44 | ''; 45 | 46 | await replaceEditorContent(app, jsonc.applyEdits(app.document.getText(), addition)); 47 | await app.document.save(); 48 | 49 | const includesChange = await waitForTrue(() => 50 | preview?.document.getText().includes(EXPECTED_UPDATES_URL) 51 | ); 52 | 53 | expect(includesChange).to.equal(true); 54 | 55 | const removal = jsonc.modify(app.document.getText(), ['expo', 'updates'], undefined, { 56 | formattingOptions: { insertSpaces: true }, 57 | }); 58 | 59 | await replaceEditorContent(app, jsonc.applyEdits(app.document.getText(), removal)); 60 | await app.document.save(); 61 | 62 | const excludesChange = await waitForFalse(() => 63 | preview?.document.getText().includes(EXPECTED_UPDATES_URL) 64 | ); 65 | 66 | expect(excludesChange).to.equal(true); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/__tests__/commands/preview-config.e2e.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import vscode from 'vscode'; 3 | 4 | import { ExpoConfigType, PreviewCommand } from '../../preview/constants'; 5 | import { sanitizeSnapshotValues } from '../utils/snapshot'; 6 | import { closeAllEditors, getWorkspaceUri, waitForEditorOpen } from '../utils/vscode'; 7 | 8 | describe(PreviewCommand.OpenExpoConfigPrebuild, () => { 9 | beforeEach(async () => { 10 | await vscode.window.showTextDocument(getWorkspaceUri('preview', 'app.json')); 11 | }); 12 | 13 | afterEach(async () => { 14 | await closeAllEditors(); 15 | }); 16 | 17 | it(`runs for ${ExpoConfigType.INTROSPECT}`, async () => { 18 | await vscode.commands.executeCommand( 19 | PreviewCommand.OpenExpoConfigPrebuild, 20 | ExpoConfigType.INTROSPECT 21 | ); 22 | 23 | const preview = await waitForEditorOpen('_app.config.json'); 24 | expect(preview).to.exist; 25 | 26 | const content = sanitizeSnapshotValues(preview!.document.getText()); 27 | expect(content).toMatchSnapshot(); 28 | }); 29 | 30 | it(`runs for ${ExpoConfigType.PREBUILD}`, async () => { 31 | await vscode.commands.executeCommand( 32 | PreviewCommand.OpenExpoConfigPrebuild, 33 | ExpoConfigType.PREBUILD 34 | ); 35 | 36 | const preview = await waitForEditorOpen('_app.config.json'); 37 | expect(preview).to.exist; 38 | 39 | const content = sanitizeSnapshotValues(preview!.document.getText()); 40 | expect(content).toMatchSnapshot(); 41 | }); 42 | 43 | it(`runs for ${ExpoConfigType.PUBLIC}`, async () => { 44 | await vscode.commands.executeCommand( 45 | PreviewCommand.OpenExpoConfigPrebuild, 46 | ExpoConfigType.PUBLIC 47 | ); 48 | 49 | const preview = await waitForEditorOpen('exp.json'); 50 | expect(preview).to.exist; 51 | 52 | const content = sanitizeSnapshotValues(preview!.document.getText()); 53 | expect(content).toMatchSnapshot(); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/__tests__/commands/preview-modifier-json.e2e.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import vscode from 'vscode'; 3 | 4 | import { PreviewCommand, PreviewModProvider } from '../../preview/constants'; 5 | import { sanitizeSnapshotValues } from '../utils/snapshot'; 6 | import { closeAllEditors, getWorkspaceUri, waitForEditorOpen } from '../utils/vscode'; 7 | 8 | describe(PreviewCommand.OpenExpoFileJsonPrebuild, () => { 9 | beforeEach(async () => { 10 | await vscode.window.showTextDocument(getWorkspaceUri('preview', 'app.json')); 11 | }); 12 | 13 | afterEach(async () => { 14 | await closeAllEditors(); 15 | }); 16 | 17 | it(`runs for ${PreviewModProvider.androidColors} in json format`, async () => { 18 | await vscode.commands.executeCommand( 19 | PreviewCommand.OpenExpoFileJsonPrebuild, 20 | PreviewModProvider.androidColors 21 | ); 22 | 23 | const preview = await waitForEditorOpen('colors.xml.json'); 24 | expect(preview).to.exist; 25 | 26 | const content = sanitizeSnapshotValues(preview!.document.getText()); 27 | expect(content).toMatchSnapshot(); 28 | }); 29 | 30 | it(`runs for ${PreviewModProvider.androidColorsNight} in json format`, async () => { 31 | await vscode.commands.executeCommand( 32 | PreviewCommand.OpenExpoFileJsonPrebuild, 33 | PreviewModProvider.androidColorsNight 34 | ); 35 | 36 | const preview = await waitForEditorOpen('colors.xml.json'); 37 | expect(preview).to.exist; 38 | 39 | const content = sanitizeSnapshotValues(preview!.document.getText()); 40 | expect(content).toMatchSnapshot(); 41 | }); 42 | 43 | it(`runs for ${PreviewModProvider.androidGradleProperties} in json format`, async () => { 44 | await vscode.commands.executeCommand( 45 | PreviewCommand.OpenExpoFileJsonPrebuild, 46 | PreviewModProvider.androidGradleProperties 47 | ); 48 | 49 | const preview = await waitForEditorOpen('gradle.properties.json'); 50 | expect(preview).to.exist; 51 | 52 | const content = sanitizeSnapshotValues(preview!.document.getText()); 53 | expect(content).toMatchSnapshot(); 54 | }); 55 | 56 | it(`runs for ${PreviewModProvider.androidManifest} in json format`, async () => { 57 | await vscode.commands.executeCommand( 58 | PreviewCommand.OpenExpoFileJsonPrebuild, 59 | PreviewModProvider.androidManifest 60 | ); 61 | 62 | const preview = await waitForEditorOpen('AndroidManifest.xml.json'); 63 | expect(preview).to.exist; 64 | 65 | const content = sanitizeSnapshotValues(preview!.document.getText()); 66 | expect(content).toMatchSnapshot(); 67 | }); 68 | 69 | it(`runs for ${PreviewModProvider.androidStrings} in json format`, async () => { 70 | await vscode.commands.executeCommand( 71 | PreviewCommand.OpenExpoFileJsonPrebuild, 72 | PreviewModProvider.androidStrings 73 | ); 74 | 75 | const preview = await waitForEditorOpen('strings.xml.json'); 76 | expect(preview).to.exist; 77 | 78 | const content = sanitizeSnapshotValues(preview!.document.getText()); 79 | expect(content).toMatchSnapshot(); 80 | }); 81 | 82 | it(`runs for ${PreviewModProvider.androidStyles} in json format`, async () => { 83 | await vscode.commands.executeCommand( 84 | PreviewCommand.OpenExpoFileJsonPrebuild, 85 | PreviewModProvider.androidStyles 86 | ); 87 | 88 | const preview = await waitForEditorOpen('styles.xml.json'); 89 | expect(preview).to.exist; 90 | 91 | const content = sanitizeSnapshotValues(preview!.document.getText()); 92 | expect(content).toMatchSnapshot(); 93 | }); 94 | 95 | it(`runs for ${PreviewModProvider.iosEntitlements} in json format`, async () => { 96 | await vscode.commands.executeCommand( 97 | PreviewCommand.OpenExpoFileJsonPrebuild, 98 | PreviewModProvider.iosEntitlements 99 | ); 100 | 101 | const preview = await waitForEditorOpen('Example.entitlements.json'); 102 | expect(preview).to.exist; 103 | 104 | const content = sanitizeSnapshotValues(preview!.document.getText()); 105 | expect(content).toMatchSnapshot(); 106 | }); 107 | 108 | it(`runs for ${PreviewModProvider.iosExpoPlist} in json format`, async () => { 109 | await vscode.commands.executeCommand( 110 | PreviewCommand.OpenExpoFileJsonPrebuild, 111 | PreviewModProvider.iosExpoPlist 112 | ); 113 | 114 | const preview = await waitForEditorOpen('Expo.plist.json'); 115 | expect(preview).to.exist; 116 | 117 | const content = sanitizeSnapshotValues(preview!.document.getText()); 118 | expect(content).toMatchSnapshot(); 119 | }); 120 | 121 | it(`runs for ${PreviewModProvider.iosInfoPlist} in json format`, async () => { 122 | await vscode.commands.executeCommand( 123 | PreviewCommand.OpenExpoFileJsonPrebuild, 124 | PreviewModProvider.iosInfoPlist 125 | ); 126 | 127 | const preview = await waitForEditorOpen('Info.plist.json'); 128 | expect(preview).to.exist; 129 | 130 | const content = sanitizeSnapshotValues(preview!.document.getText()); 131 | expect(content).toMatchSnapshot(); 132 | }); 133 | 134 | it(`runs for ${PreviewModProvider.iosPodfileProperties} in json format`, async () => { 135 | await vscode.commands.executeCommand( 136 | PreviewCommand.OpenExpoFileJsonPrebuild, 137 | PreviewModProvider.iosPodfileProperties 138 | ); 139 | 140 | const preview = await waitForEditorOpen('Podfile.properties.json'); 141 | expect(preview).to.exist; 142 | 143 | const content = sanitizeSnapshotValues(preview!.document.getText()); 144 | expect(content).toMatchSnapshot(); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /src/__tests__/extension.e2e.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { extensions } from 'vscode'; 3 | 4 | describe('extension', () => { 5 | it('is activated', () => { 6 | expect(extensions.getExtension(process.env.EXTENSION_ID)?.isActive).to.equal(true); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/__tests__/manifestAssetCompletions.e2e.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import vscode from 'vscode'; 3 | 4 | import { closeActiveEditor, findContentRange, getWorkspaceUri } from './utils/vscode'; 5 | 6 | describe('ManifestAssetCompletionsProvider', () => { 7 | // Test for both app.json and app.config.json formats 8 | ['app.json', 'app.config.json'].forEach((manifestFile) => { 9 | describe(`manifest: ${manifestFile}`, () => { 10 | let app: vscode.TextEditor; 11 | 12 | beforeEach(async () => { 13 | app = await vscode.window.showTextDocument(getWorkspaceUri('manifest', manifestFile)); 14 | }); 15 | 16 | afterEach(() => closeActiveEditor()); 17 | 18 | it('suggests folders from project', async () => { 19 | const range = findContentRange(app, './assets/icon.png'); 20 | await app.edit((builder) => builder.replace(range, './')); 21 | 22 | const suggestions = await vscode.commands.executeCommand( 23 | 'vscode.executeCompletionItemProvider', 24 | app.document.uri, 25 | range.start 26 | ); 27 | 28 | // Make sure only these two folders are suggested, it might trigger false positives 29 | expect(suggestions.items).to.have.length(2); 30 | expect(suggestions.items).to.containSubset([{ label: 'assets/' }, { label: 'plugins/' }]); 31 | }); 32 | 33 | it('suggests image asset files from project', async () => { 34 | const range = findContentRange(app, './assets/icon.png'); 35 | await app.edit((builder) => builder.replace(range, './assets/')); 36 | 37 | const suggestions = await vscode.commands.executeCommand( 38 | 'vscode.executeCompletionItemProvider', 39 | app.document.uri, 40 | range.start 41 | ); 42 | 43 | // Make sure only these files are suggested, it might trigger false positives 44 | expect(suggestions.items).to.have.length(4); 45 | expect(suggestions.items).to.containSubset([ 46 | { label: 'adaptive-icon.png' }, 47 | { label: 'favicon.png' }, 48 | { label: 'icon.png' }, 49 | { label: 'splash.png' }, 50 | ]); 51 | }); 52 | 53 | it('does not suggest for non-asset key properties', async () => { 54 | const range = findContentRange(app, 'portrait'); 55 | await app.edit((builder) => builder.replace(range, './')); 56 | 57 | const suggestions = await vscode.commands.executeCommand( 58 | 'vscode.executeCompletionItemProvider', 59 | app.document.uri, 60 | range.start 61 | ); 62 | 63 | expect(suggestions.items).to.not.include.deep.property('label', 'assets/'); 64 | }); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/__tests__/manifestLinks.e2e.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import vscode from 'vscode'; 3 | 4 | import { 5 | closeAllEditors, 6 | findContentRange, 7 | getWorkspaceUri, 8 | waitForActiveTabNameOpen, 9 | } from './utils/vscode'; 10 | 11 | describe('ManifestLinksProvider', () => { 12 | // Based on: https://github.com/microsoft/vscode/blob/6cf68a1f23ee09d13e7e2bc4f7e8e2de1c5ef714/extensions/markdown-language-features/src/test/documentLink.test.ts#L171 13 | 14 | // Test for both app.json and app.config.json formats 15 | ['app.json', 'app.config.json'].forEach((manifestFile) => { 16 | describe(`manifest: ${manifestFile}`, () => { 17 | let app: vscode.TextEditor; 18 | 19 | beforeEach(async () => { 20 | app = await vscode.window.showTextDocument(getWorkspaceUri('manifest', manifestFile)); 21 | }); 22 | 23 | afterEach(() => closeAllEditors()); 24 | 25 | describe('assets', () => { 26 | it('opens valid asset link', async () => { 27 | const links = await vscode.commands.executeCommand( 28 | 'vscode.executeLinkProvider', 29 | app.document.uri 30 | ); 31 | 32 | const range = findContentRange(app, './assets/icon.png'); 33 | const link = links.find((link) => link.range.contains(range)); 34 | 35 | await vscode.commands.executeCommand('vscode.open', link?.target); 36 | expect(await waitForActiveTabNameOpen('icon.png')).to.equal(true); 37 | }); 38 | }); 39 | 40 | describe('plugins', () => { 41 | it('opens valid plugin from package', async () => { 42 | const links = await vscode.commands.executeCommand( 43 | 'vscode.executeLinkProvider', 44 | app.document.uri 45 | ); 46 | 47 | const range = findContentRange(app, 'expo-system-ui'); 48 | const link = links.find((link) => link.range.contains(range)); 49 | 50 | await vscode.commands.executeCommand('vscode.open', link?.target); 51 | expect(await waitForActiveTabNameOpen('app.plugin.js')).to.equal(true); 52 | }); 53 | 54 | it('opens valid plugin from package with options', async () => { 55 | const links = await vscode.commands.executeCommand( 56 | 'vscode.executeLinkProvider', 57 | app.document.uri 58 | ); 59 | 60 | const range = findContentRange(app, 'expo-camera'); 61 | const link = links.find((link) => link.range.contains(range)); 62 | 63 | await vscode.commands.executeCommand('vscode.open', link?.target); 64 | expect(await waitForActiveTabNameOpen('app.plugin.js')).to.equal(true); 65 | }); 66 | 67 | it('opens valid plugin from local file', async () => { 68 | const links = await vscode.commands.executeCommand( 69 | 'vscode.executeLinkProvider', 70 | app.document.uri 71 | ); 72 | 73 | const range = findContentRange(app, './plugins/valid'); 74 | const link = links.find((link) => link.range.contains(range)); 75 | 76 | await vscode.commands.executeCommand('vscode.open', link?.target); 77 | expect(await waitForActiveTabNameOpen('valid.js')).to.equal(true); 78 | }); 79 | }); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/__tests__/manifestPluginCompletions.e2e.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import vscode from 'vscode'; 3 | 4 | import { closeActiveEditor, findContentRange, getWorkspaceUri } from './utils/vscode'; 5 | 6 | describe('ManifestPluginCompletionsProvider', () => { 7 | // Test for both app.json and app.config.json formats 8 | ['app.json', 'app.config.json'].forEach((manifestFile) => { 9 | describe(`manifest: ${manifestFile}`, () => { 10 | let app: vscode.TextEditor; 11 | 12 | beforeEach(async () => { 13 | app = await vscode.window.showTextDocument(getWorkspaceUri('manifest', manifestFile)); 14 | }); 15 | 16 | afterEach(() => closeActiveEditor()); 17 | 18 | it('suggests plugins from installed packages', async () => { 19 | const range = findContentRange(app, 'expo-system-ui'); 20 | await app.edit((builder) => builder.replace(range, '')); 21 | 22 | const suggestions = await vscode.commands.executeCommand( 23 | 'vscode.executeCompletionItemProvider', 24 | app.document.uri, 25 | range.start 26 | ); 27 | 28 | expect(suggestions.items).to.containSubset([ 29 | { label: 'expo-camera' }, 30 | { label: 'expo-system-ui' }, 31 | ]); 32 | }); 33 | 34 | it('suggests folders from local project', async () => { 35 | const range = findContentRange(app, './plugins/valid'); 36 | await app.edit((builder) => builder.replace(range, './')); 37 | 38 | const suggestions = await vscode.commands.executeCommand( 39 | 'vscode.executeCompletionItemProvider', 40 | app.document.uri, 41 | range.start 42 | ); 43 | 44 | expect(suggestions.items).to.containSubset([ 45 | // `node_modules` are disabled by default in `src/settings.ts` 46 | { label: 'assets/', command: { command: 'editor.action.triggerSuggest' } }, 47 | { label: 'plugins/', command: { command: 'editor.action.triggerSuggest' } }, 48 | ]); 49 | }); 50 | 51 | it('suggests plugins from local project', async () => { 52 | const range = findContentRange(app, './plugins/valid'); 53 | await app.edit((builder) => builder.replace(range, './plugins/')); 54 | 55 | const suggestions = await vscode.commands.executeCommand( 56 | 'vscode.executeCompletionItemProvider', 57 | app.document.uri, 58 | range.start 59 | ); 60 | 61 | expect(suggestions.items).to.containSubset([{ label: 'valid.js' }]); 62 | }); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/__tests__/schemas/eas-metadata.e2e.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import vscode from 'vscode'; 3 | 4 | import { 5 | closeAllEditors, 6 | findContentRange, 7 | getWorkspaceUri, 8 | storeOriginalContent, 9 | } from '../utils/vscode'; 10 | import { waitForTrue } from '../utils/wait'; 11 | 12 | describe('eas-metadata', () => { 13 | let app: vscode.TextEditor; 14 | let restoreContent: ReturnType; 15 | 16 | before(async () => { 17 | app = await vscode.window.showTextDocument(getWorkspaceUri('schema-eas', 'store.config.json')); 18 | restoreContent = storeOriginalContent(app); 19 | }); 20 | 21 | after(async () => { 22 | await restoreContent(); 23 | await closeAllEditors(); 24 | }); 25 | 26 | it('provides autocomplete for store.config.json `apple.info.en-US.description`', async () => { 27 | const range = findContentRange(app, 'description'); 28 | 29 | await app.edit((builder) => builder.replace(range, 'descr')); 30 | 31 | // Retry the suggestions a couple of times, the schema might still need to be downloaded 32 | const result = await waitForTrue(async () => { 33 | const suggestions = await vscode.commands.executeCommand( 34 | 'vscode.executeCompletionItemProvider', 35 | app.document.uri, 36 | range.start 37 | ); 38 | 39 | return suggestions.items.some((item) => item.label === 'description'); 40 | }); 41 | 42 | expect(result).to.equal(true); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/__tests__/schemas/eas.e2e.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import vscode from 'vscode'; 3 | 4 | import { 5 | closeAllEditors, 6 | findContentRange, 7 | getWorkspaceUri, 8 | storeOriginalContent, 9 | } from '../utils/vscode'; 10 | import { waitForTrue } from '../utils/wait'; 11 | 12 | describe('eas', () => { 13 | let app: vscode.TextEditor; 14 | let restoreContent: ReturnType; 15 | 16 | before(async () => { 17 | app = await vscode.window.showTextDocument(getWorkspaceUri('schema-eas', 'eas.json')); 18 | restoreContent = storeOriginalContent(app); 19 | }); 20 | 21 | after(async () => { 22 | await restoreContent(); 23 | await closeAllEditors(); 24 | }); 25 | 26 | it('provides autocomplete for eas.json `build.development.developmentClient`', async () => { 27 | const range = findContentRange(app, 'developmentClient'); 28 | 29 | await app.edit((builder) => builder.replace(range, 'developmentCl')); 30 | 31 | // Retry the suggestions a couple of times, the schema might still need to be downloaded 32 | const result = await waitForTrue(async () => { 33 | const suggestions = await vscode.commands.executeCommand( 34 | 'vscode.executeCompletionItemProvider', 35 | app.document.uri, 36 | range.start 37 | ); 38 | 39 | return suggestions.items.some((item) => item.label === 'developmentClient'); 40 | }); 41 | 42 | expect(result).to.equal(true); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/__tests__/schemas/expo-module.e2e.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import vscode from 'vscode'; 3 | 4 | import { 5 | closeAllEditors, 6 | findContentRange, 7 | getWorkspaceUri, 8 | storeOriginalContent, 9 | } from '../utils/vscode'; 10 | import { waitForTrue } from '../utils/wait'; 11 | 12 | describe('expo-module', () => { 13 | let app: vscode.TextEditor; 14 | let restoreContent: ReturnType; 15 | 16 | before(async () => { 17 | app = await vscode.window.showTextDocument( 18 | getWorkspaceUri('schema-expo-module', 'expo-module.config.json') 19 | ); 20 | restoreContent = storeOriginalContent(app); 21 | }); 22 | 23 | after(async () => { 24 | await restoreContent(); 25 | await closeAllEditors(); 26 | }); 27 | 28 | it('provides autocomplete for expo-module.config.json `ios.debugOnly`', async () => { 29 | const range = findContentRange(app, 'debugOnly'); 30 | 31 | await app.edit((builder) => builder.replace(range, 'debug')); 32 | 33 | // Retry the suggestions a couple of times, the schema might still need to be downloaded 34 | const result = await waitForTrue(async () => { 35 | const suggestions = await vscode.commands.executeCommand( 36 | 'vscode.executeCompletionItemProvider', 37 | app.document.uri, 38 | range.start 39 | ); 40 | 41 | return suggestions.items.some((item) => item.label === 'debugOnly'); 42 | }); 43 | 44 | expect(result).to.equal(true); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/__tests__/schemas/expo-xdl.e2e.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import vscode from 'vscode'; 3 | 4 | import { 5 | closeAllEditors, 6 | findContentRange, 7 | getWorkspaceUri, 8 | storeOriginalContent, 9 | } from '../utils/vscode'; 10 | import { waitForTrue } from '../utils/wait'; 11 | 12 | describe('expo-xdl', () => { 13 | let app: vscode.TextEditor; 14 | let restoreContent: ReturnType; 15 | 16 | before(async () => { 17 | app = await vscode.window.showTextDocument(getWorkspaceUri('schema-expo-xdl', 'app.json')); 18 | restoreContent = storeOriginalContent(app); 19 | }); 20 | 21 | after(async () => { 22 | await restoreContent(); 23 | await closeAllEditors(); 24 | }); 25 | 26 | it('provides autocomplete for app.json `expo.android`', async () => { 27 | const range = findContentRange(app, 'android'); 28 | 29 | await app.edit((builder) => builder.replace(range, 'andr')); 30 | 31 | // Retry the suggestions a couple of times, the schema might still need to be downloaded 32 | const result = await waitForTrue(async () => { 33 | const suggestions = await vscode.commands.executeCommand( 34 | 'vscode.executeCompletionItemProvider', 35 | app.document.uri, 36 | range.start 37 | ); 38 | 39 | return suggestions.items.some((item) => item.label === 'android'); 40 | }); 41 | 42 | expect(result).to.equal(true); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/__tests__/utils/debugging.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import http from 'http'; 3 | import { stub, type SinonStub } from 'sinon'; 4 | import { URL } from 'url'; 5 | import { WebSocketServer } from 'ws'; 6 | 7 | import { type InspectableDevice } from '../../expo/bundler'; 8 | 9 | type StubInspectorProxyApp = http.RequestListener< 10 | typeof http.IncomingMessage, 11 | typeof http.ServerResponse 12 | >; 13 | 14 | export type StubInspectorProxy = { 15 | app: SinonStub, ReturnType>; 16 | sockets: WebSocketServer; 17 | server: http.Server; 18 | serverUrl: URL; 19 | }; 20 | 21 | /** Create and start a fake inspector proxy server */ 22 | export async function stubInspectorProxy() { 23 | const app: StubInspectorProxy['app'] = stub(); 24 | const server = http.createServer(app); 25 | const sockets = new WebSocketServer({ server }); 26 | 27 | return new Promise((resolve, reject) => { 28 | server.once('error', reject); 29 | 30 | server.on('upgrade', (request, socket, head) => { 31 | sockets.handleUpgrade(request, socket, head, (ws) => { 32 | ws.on('error', console.error); 33 | sockets.emit('connection', ws); 34 | }); 35 | }); 36 | 37 | server.listen(() => { 38 | server.off('error', reject); 39 | 40 | const serverUrl = new URL(getServerAddress(server)); 41 | 42 | resolve({ 43 | app, 44 | sockets, 45 | server, 46 | serverUrl, 47 | async [Symbol.asyncDispose]() { 48 | await new Promise((resolve) => sockets.close(resolve)); 49 | await new Promise((resolve) => server.close(resolve)); 50 | }, 51 | }); 52 | }); 53 | }); 54 | } 55 | 56 | function getServerAddress(server: http.Server) { 57 | const address = server.address(); 58 | assert(address && typeof address === 'object' && address.port, 'Server is not listening'); 59 | return `http://localhost:${address.port}`; 60 | } 61 | 62 | export function mockDevice( 63 | properties: Partial = {}, 64 | proxy?: Pick 65 | ): InspectableDevice { 66 | const device: InspectableDevice = { 67 | id: 'device1', 68 | description: 'description1', 69 | title: 'React Native Experimental (Improved Chrome Reloads)', // Magic title, do not change 70 | faviconUrl: 'https://example.com/favicon.ico', 71 | devtoolsFrontendUrl: 'devtools://devtools/example', 72 | type: 'node', 73 | webSocketDebuggerUrl: 'ws://example.com', 74 | vm: 'hermes', 75 | deviceName: 'iPhone 15 Pro', 76 | ...properties, 77 | }; 78 | 79 | if (proxy?.serverUrl) { 80 | const url = new URL(proxy.serverUrl.toString()); 81 | 82 | url.protocol = 'ws:'; 83 | url.pathname = '/inspector/debug'; 84 | url.searchParams.set('device', device.id); 85 | url.searchParams.set('page', '1'); 86 | 87 | device.webSocketDebuggerUrl = url.toString(); 88 | } 89 | 90 | return device; 91 | } 92 | -------------------------------------------------------------------------------- /src/__tests__/utils/fetch.ts: -------------------------------------------------------------------------------- 1 | import * as nodeFetch from 'node-fetch'; 2 | import { type SinonStub } from 'sinon'; 3 | 4 | import { disposedStub } from './sinon'; 5 | 6 | /** Mock fetch with a default empty device list response */ 7 | export function stubFetch(data: any = []) { 8 | return withFetchResponse(disposedStub(nodeFetch, 'default'), data); 9 | } 10 | 11 | /** Add a valid response to the stubbed fetch, returning the response as json data */ 12 | export function withFetchResponse(fetch: T, response: any) { 13 | fetch.returns( 14 | Promise.resolve({ 15 | ok: true, 16 | json: () => Promise.resolve(response), 17 | }) 18 | ); 19 | return fetch; 20 | } 21 | 22 | /** Add an invalid response to the stubbed fetch, throwing an error when json is loaded */ 23 | export function withFetchError( 24 | fetch: T, 25 | error = new Error('JSON parse error') 26 | ) { 27 | fetch.returns( 28 | Promise.resolve({ 29 | ok: false, 30 | json: () => Promise.reject(error), 31 | }) 32 | ); 33 | return fetch; 34 | } 35 | -------------------------------------------------------------------------------- /src/__tests__/utils/sinon.ts: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | 3 | /** Create an auto-disposable stub that can be created with `using` */ 4 | export function disposedStub any }, P extends keyof T>( 5 | api: T, 6 | method: P 7 | ) { 8 | const stub = sinon.stub(api, method); 9 | // @ts-expect-error 10 | stub[Symbol.dispose] = () => stub.restore(); 11 | return stub as sinon.SinonStub, ReturnType> & Disposable; 12 | } 13 | 14 | /** Create an auto-disposable spy, still executing the implementation, that can be created with `using` */ 15 | export function disposedSpy any }, P extends keyof T>( 16 | api: T, 17 | method: P 18 | ) { 19 | const spy = sinon.spy(api, method); 20 | // @ts-expect-error 21 | spy[Symbol.dispose] = () => spy.restore(); 22 | return spy as sinon.SinonSpy, ReturnType> & Disposable; 23 | } 24 | -------------------------------------------------------------------------------- /src/__tests__/utils/snapshot.ts: -------------------------------------------------------------------------------- 1 | const DISALLOWED_LINES = [ 2 | 'projectRoot', 3 | 'dynamicConfigPath', 4 | 'staticConfigPath', 5 | 'packageJsonPath', 6 | 'currentFullName', 7 | 'originalFullName', 8 | ]; 9 | 10 | /** 11 | * We can't store non-idempotent values in snapshots. 12 | * Else we would run into this issue quite a lot: 13 | * - "packageJsonPath": "d:\\projects\\expo\\vscode-expo\\test\\fixture\\expo-app\\package.json", 14 | * + "packageJsonPath": "/home/runner/work/vscode-expo/vscode-expo/test/fixture/expo-app/package.json", 15 | * Or this one: 16 | * - \"currentFullName\": \"@anonymous/preview\",", 17 | * + \"originalFullName\": \"@bycedric/preview\"", 18 | */ 19 | export function sanitizeSnapshotValues(content = '') { 20 | const lines = content 21 | .split(/[\n\r?]/) 22 | // Filter absolute path properties 23 | .filter((line) => !DISALLOWED_LINES.some((property) => line.includes(property))); 24 | 25 | return lines.join('\n'); 26 | } 27 | -------------------------------------------------------------------------------- /src/__tests__/utils/spawn.ts: -------------------------------------------------------------------------------- 1 | import { type SinonStub } from 'sinon'; 2 | 3 | import { disposedStub } from './sinon'; 4 | import * as spawn from '../../utils/spawn'; 5 | 6 | /** Mock spawn with a default empty device list response */ 7 | export function stubSpawn(result?: Partial) { 8 | return withSpawnResult(disposedStub(spawn, 'spawn'), result); 9 | } 10 | 11 | export function withSpawnResult( 12 | spawnStub: T, 13 | result: Partial = {} 14 | ) { 15 | spawnStub.returns(Promise.resolve(result)); 16 | return spawnStub; 17 | } 18 | -------------------------------------------------------------------------------- /src/__tests__/utils/vscode.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import path from 'path'; 3 | import vscode from 'vscode'; 4 | 5 | import { type WaitForValueOptions, waitForTrue, waitForValue } from './wait'; 6 | 7 | /** 8 | * Get the URI to a file or folder within the workspace. 9 | */ 10 | export function getWorkspaceUri(...relativePath: string[]) { 11 | const workspace = vscode.workspace.workspaceFolders?.[0]; 12 | assert(workspace, `(First) workspace not found, can't create path to ${relativePath}`); 13 | return vscode.Uri.joinPath(workspace.uri, ...relativePath); 14 | } 15 | 16 | /** 17 | * Wait until vscode has activated this extension. 18 | */ 19 | export function waitForExtension() { 20 | return waitForTrue(() => vscode.extensions.getExtension(process.env.EXTENSION_ID)?.isActive); 21 | } 22 | 23 | /** 24 | * Wait until vscode has opened a visible file. 25 | */ 26 | export function waitForEditorOpen(fileName: string, options?: WaitForValueOptions) { 27 | return waitForValue( 28 | () => 29 | vscode.window.visibleTextEditors.find( 30 | (editor) => 31 | path.basename(editor.document.fileName) === fileName && !!editor.document.getText() 32 | ), 33 | options 34 | ); 35 | } 36 | 37 | /** 38 | * Wait until the active tab name is opened. 39 | * This can by any type of file, from text editor to asset. 40 | */ 41 | export function waitForActiveTabNameOpen(tabName: string, delay = 500) { 42 | return waitForTrue(() => tabName === vscode.window.tabGroups.activeTabGroup.activeTab?.label, { 43 | delay, 44 | }); 45 | } 46 | 47 | /** 48 | * Close all (currently open) text editors. 49 | * @see https://github.com/microsoft/vscode/blob/2980862817f29911a2231f4c88bfc783bd763cab/extensions/vscode-api-tests/src/utils.ts#L50 50 | */ 51 | export function closeAllEditors() { 52 | return vscode.commands.executeCommand('workbench.action.closeAllEditors'); 53 | } 54 | 55 | /** 56 | * Close the current active editor. 57 | * Usually `closeAllEditors` should be used instead, but for 1 document tests this can be used when running into errors. 58 | */ 59 | export function closeActiveEditor() { 60 | return vscode.commands.executeCommand('workbench.action.closeActiveEditor'); 61 | } 62 | 63 | /** 64 | * Find the editor content position by search string. 65 | */ 66 | export function findContentPosition(editor: vscode.TextEditor, search: string) { 67 | const content = editor.document.getText(); 68 | const offset = content.indexOf(search); 69 | 70 | assert(offset >= 0, `Could not find offset of "${search}"`); 71 | 72 | return editor.document.positionAt(offset); 73 | } 74 | 75 | /** 76 | * Find the editor content range by search string. 77 | */ 78 | export function findContentRange(editor: vscode.TextEditor, search: string) { 79 | const start = findContentPosition(editor, search); 80 | const end = new vscode.Position(start.line, start.character + search.length); 81 | return new vscode.Range(start, end); 82 | } 83 | 84 | /** 85 | * Replace the contents of an open editor. 86 | */ 87 | export async function replaceEditorContent(editor: vscode.TextEditor, content: string) { 88 | const range = editor.document.validateRange(new vscode.Range(0, 0, editor.document.lineCount, 0)); 89 | await editor.edit((builder) => builder.replace(range, content)); 90 | } 91 | 92 | /** 93 | * Remember the original content of an editor. 94 | * This returns a function to restore the original content, even when it was saved. 95 | */ 96 | export function storeOriginalContent(editor: vscode.TextEditor): () => Promise { 97 | const content = editor.document.getText(); 98 | 99 | return async function restoreOriginalContent() { 100 | await replaceEditorContent(editor, content); 101 | await editor.document.save(); 102 | }; 103 | } 104 | -------------------------------------------------------------------------------- /src/__tests__/utils/wait.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wait for the total amount of milliseconds. 3 | */ 4 | export function waitFor(delay: number = 500) { 5 | return new Promise((resolve) => setTimeout(resolve, delay)); 6 | } 7 | 8 | type SyncOrAsync = T | Promise; 9 | 10 | export type WaitForValueOptions = { 11 | /** The delay in milliseconds to wait for next attempt */ 12 | delay?: number; 13 | /** Total retries allowed until returning undefined */ 14 | retries?: number; 15 | /** Current total attempts made */ 16 | attempts?: number; 17 | }; 18 | 19 | /** 20 | * Wait until the action return a value, e.g. to poll certain values. 21 | */ 22 | export async function waitForValue( 23 | action: () => T, 24 | { delay = 250, retries = 25_000 / 250, attempts = 0 }: WaitForValueOptions = {} 25 | ): Promise | undefined> { 26 | const value = await action(); 27 | 28 | if (value === undefined && attempts <= retries) { 29 | await waitFor(delay); 30 | return waitForValue(action, { delay, retries, attempts: attempts + 1 }); 31 | } 32 | 33 | return value; 34 | } 35 | 36 | export function waitForTrue>( 37 | action: () => T, 38 | options?: WaitForValueOptions 39 | ) { 40 | return waitForValue(async () => ((await action()) === true ? true : undefined), options); 41 | } 42 | 43 | export async function waitForFalse>( 44 | action: () => T, 45 | options: WaitForValueOptions = {} 46 | ) { 47 | return waitForValue(async () => ((await action()) === false ? true : undefined), options); 48 | } 49 | -------------------------------------------------------------------------------- /src/expo/__tests__/bundler.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { mockDevice } from '../../__tests__/utils/debugging'; 4 | import { stubFetch } from '../../__tests__/utils/fetch'; 5 | import { fetchDevicesToInspect, findDeviceByName, inferDevicePlatform } from '../bundler'; 6 | 7 | const host = '127.0.0.2'; 8 | const port = '1337'; 9 | 10 | describe('fetchDevicesToInspect', () => { 11 | it('fetches devices from the bundler', async () => { 12 | using fetch = stubFetch(); 13 | await fetchDevicesToInspect({ host, port }); 14 | expect(fetch).to.have.been.calledWith(`http://${host}:${port}/json/list`); 15 | }); 16 | 17 | // TODO: find out why the stubbing isnt working for this fetch 18 | xit('filters by page id', async () => { 19 | using _fetch = stubFetch([ 20 | mockDevice({ 21 | title: 'React Native Bridgeless [C++ connection]', 22 | deviceName: 'iPhone 15 Pro', 23 | webSocketDebuggerUrl: 'ws://localhost:8081/inspector/device?page=1', 24 | }), 25 | mockDevice({ 26 | title: 'React Native Bridgeless [C++ connection]', 27 | deviceName: 'iPhone 15 Pro', 28 | webSocketDebuggerUrl: 'ws://localhost:8081/inspector/device?page=2', 29 | }), 30 | ]); 31 | 32 | const devices = await fetchDevicesToInspect({ host, port }); 33 | 34 | expect(devices).to.have.length(1); 35 | expect(devices).to.deep.equal([ 36 | mockDevice({ 37 | deviceName: 'iPhone 15 Pro', 38 | webSocketDebuggerUrl: 'ws://localhost:8081/inspector/device?page=2', 39 | }), 40 | ]); 41 | }); 42 | }); 43 | 44 | describe('findDeviceByName', () => { 45 | it('returns first device by its name', () => { 46 | const target = mockDevice({ deviceName: 'iPhone 15 Pro', id: 'page1' }); 47 | const devices = [ 48 | mockDevice({ deviceName: 'Pixel 7 Pro', id: 'page1' }), 49 | mockDevice({ deviceName: 'Pixel 7 Pro', id: 'page2' }), 50 | target, 51 | mockDevice({ deviceName: 'iPhone 15 Pro', id: 'page2' }), 52 | ]; 53 | 54 | expect(findDeviceByName(devices, 'iPhone 15 Pro')).to.equal(target); 55 | }); 56 | }); 57 | 58 | describe('inferDevicePlatform', () => { 59 | it('returns ios for standard simulator device names', () => { 60 | expect(inferDevicePlatform({ deviceName: 'iPhone 15 pro' })).to.equal('ios'); 61 | expect(inferDevicePlatform({ deviceName: 'iPhone 12 mini' })).to.equal('ios'); 62 | expect(inferDevicePlatform({ deviceName: 'iPhone 15' })).to.equal('ios'); 63 | expect(inferDevicePlatform({ deviceName: 'iPad Air' })).to.equal('ios'); 64 | expect(inferDevicePlatform({ deviceName: 'iPad mini' })).to.equal('ios'); 65 | expect(inferDevicePlatform({ deviceName: 'iPadPro 12.9"' })).to.equal('ios'); 66 | }); 67 | 68 | it('returns android for standard emulator device names', () => { 69 | expect(inferDevicePlatform({ deviceName: 'sdk_gphone64_arm64' })).to.equal('android'); 70 | expect(inferDevicePlatform({ deviceName: 'Pixel 8 API 31' })).to.equal('android'); 71 | }); 72 | 73 | it('returns windows for standard Windows desktop device names', () => { 74 | expect(inferDevicePlatform({ deviceName: 'Cedrics-Desktop' })).to.equal('windows'); 75 | expect(inferDevicePlatform({ deviceName: 'Windows 10' })).to.equal('windows'); 76 | }); 77 | 78 | it('returns macos for standard MacOS device names', () => { 79 | expect(inferDevicePlatform({ deviceName: 'Cedric’s MacBook Pro' })).to.equal('macos'); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/expo/__tests__/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { match } from 'sinon'; 3 | 4 | import { stubSpawn } from '../../__tests__/utils/spawn'; 5 | import { spawnExpoCli } from '../cli'; 6 | 7 | describe('spawnExpoCli', () => { 8 | it('executes spawn with `npx expo` command', async () => { 9 | using spawn = stubSpawn(); 10 | 11 | await spawnExpoCli('whoami', ['--json'], { stdio: 'inherit' }); 12 | 13 | expect(spawn).to.be.calledWith( 14 | 'npx', 15 | match(['expo', 'whoami', '--json']), 16 | match({ stdio: 'inherit' }) 17 | ); 18 | }); 19 | 20 | it('returns the output of spawned process', async () => { 21 | using _spawn = stubSpawn({ stdout: 'testuser' }); 22 | 23 | expect(await spawnExpoCli('whoami')).to.equal('testuser'); 24 | }); 25 | 26 | it('forces expo in non-interactive mode', async () => { 27 | using spawn = stubSpawn(); 28 | 29 | await spawnExpoCli('whoami'); 30 | 31 | expect(spawn).to.be.calledWith( 32 | 'npx', 33 | match(['expo', 'whoami']), 34 | match({ env: match({ CI: 'true' }) }) 35 | ); 36 | }); 37 | 38 | it('forces expo without telemetry', async () => { 39 | using spawn = stubSpawn(); 40 | 41 | await spawnExpoCli('whoami'); 42 | 43 | expect(spawn).to.be.calledWith( 44 | 'npx', 45 | match(['expo', 'whoami']), 46 | match({ env: match({ EXPO_NO_TELEMETRY: 'true' }) }) 47 | ); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/expo/__tests__/manifest.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import picomatch from 'picomatch'; 3 | 4 | import { type ExpoConfig } from '../../packages/config'; 5 | import { manifestPattern, getFileReferences } from '../manifest'; 6 | 7 | describe('manifestPattern', () => { 8 | it('scheme is set to files', () => { 9 | expect(manifestPattern.scheme).to.equal('file'); 10 | }); 11 | 12 | it('language is set to json with comments', () => { 13 | expect(manifestPattern.language).to.equal('jsonc'); 14 | }); 15 | 16 | it('pattern includes all json variations of the Expo manifest', () => { 17 | const pattern = manifestPattern.pattern as string; 18 | const matcher = picomatch(pattern); 19 | 20 | expect(matcher('my-app/app.json')).to.be.true; 21 | expect(matcher('my-app/app.config.json')).to.be.true; 22 | }); 23 | }); 24 | 25 | describe('getFileReferences', () => { 26 | it('returns all local file references from manifest', () => { 27 | const manifest: ExpoConfig = { 28 | name: 'my-app', 29 | slug: 'my-app', 30 | icon: './assets/icon.png', 31 | splash: { 32 | image: '../assets/splash.png', 33 | backgroundColor: '#FFFFFF', 34 | resizeMode: 'cover', 35 | }, 36 | android: { 37 | adaptiveIcon: { 38 | foregroundImage: './assets/adaptive-icon.png', 39 | backgroundColor: '#FFFFFF', 40 | }, 41 | }, 42 | plugins: ['./plugins/local-plugin.js'], 43 | }; 44 | 45 | expect(getFileReferences(JSON.stringify({ expo: manifest }, null, 2))).to.deep.include.members([ 46 | { filePath: './assets/icon.png', fileRange: { length: 17, offset: 71 } }, 47 | { filePath: '../assets/splash.png', fileRange: { length: 20, offset: 123 } }, 48 | { filePath: './assets/adaptive-icon.png', fileRange: { length: 26, offset: 286 } }, 49 | { filePath: './plugins/local-plugin.js', fileRange: { length: 25, offset: 391 } }, 50 | ]); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/expo/__tests__/plugin.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { findNodeAtLocation, parseTree } from 'jsonc-parser'; 3 | 4 | import { type ExpoConfig } from '../../packages/config'; 5 | import { getPluginDefinition } from '../plugin'; 6 | 7 | const manifest: ExpoConfig = { 8 | name: 'test-app', 9 | slug: 'test-app', 10 | plugins: [ 11 | 'expo-plugin', 12 | ['expo-plugin-list'], 13 | ['expo-plugin-properties', { some: 'property' }], 14 | './plugins/local-plugin.js', 15 | ['./plugins/local-plugin-list.js'], 16 | ['./plugins/local-plugin-properties.js', { some: 'property' }], 17 | ], 18 | }; 19 | 20 | describe('getPluginDefinition', () => { 21 | it('returns all plugin definitions from parsed manifest', () => { 22 | const json = parseTree(JSON.stringify({ expo: manifest }, null, 2))!; 23 | const plugins = findNodeAtLocation(json, ['expo', 'plugins'])!; 24 | 25 | expect(plugins.children).to.have.length(6); // manifest.plugins.length 26 | expect(plugins.children?.map((plugin) => getPluginDefinition(plugin))).to.deep.include.members([ 27 | { nameValue: 'expo-plugin', nameRange: { length: 11, offset: 86 } }, 28 | { nameValue: 'expo-plugin-list', nameRange: { length: 16, offset: 117 } }, 29 | { nameValue: 'expo-plugin-properties', nameRange: { length: 22, offset: 161 } }, 30 | { nameValue: './plugins/local-plugin.js', nameRange: { length: 25, offset: 251 } }, 31 | { nameValue: './plugins/local-plugin-list.js', nameRange: { length: 30, offset: 296 } }, 32 | { nameValue: './plugins/local-plugin-properties.js', nameRange: { length: 36, offset: 354 } }, 33 | ]); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/expo/__tests__/project.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { findNodeAtLocation } from 'jsonc-parser'; 3 | import vscode from 'vscode'; 4 | 5 | import { getWorkspaceUri } from '../../__tests__/utils/vscode'; 6 | import { readWorkspaceFile } from '../../utils/file'; 7 | import { ExpoProjectCache, findProjectFromWorkspaces } from '../project'; 8 | 9 | describe('ExpoProjectCache', () => { 10 | it('adds disposable to extension context', () => { 11 | const subscriptions: any[] = []; 12 | using _projects = stubProjectCache(subscriptions); 13 | 14 | expect(subscriptions).to.have.length(1); 15 | }); 16 | }); 17 | 18 | describe('findProjectFromWorkspaces', () => { 19 | it('returns projct from workspace using relative path', () => { 20 | using projects = stubProjectCache(); 21 | const project = findProjectFromWorkspaces(projects, './manifest'); 22 | 23 | expect(project).to.exist; 24 | }); 25 | 26 | it('returned project contains parsed package file', async () => { 27 | using projects = stubProjectCache(); 28 | const project = await findProjectFromWorkspaces(projects, './manifest'); 29 | 30 | expect(project?.package.tree).to.exist; 31 | expect(findNodeAtLocation(project!.package.tree, ['name'])!.value).to.equal('manifest'); 32 | }); 33 | 34 | it('returned project contains parsed expo manifest file', async () => { 35 | using projects = stubProjectCache(); 36 | const project = await findProjectFromWorkspaces(projects, './manifest'); 37 | 38 | expect(project?.manifest!.tree).to.exist; 39 | expect(findNodeAtLocation(project!.manifest!.tree, ['name'])!.value).to.equal('manifest'); 40 | }); 41 | }); 42 | 43 | describe('ExpoProject', () => { 44 | it('returns expo version from package file', async () => { 45 | using projects = stubProjectCache(); 46 | 47 | const project = await findProjectFromWorkspaces(projects, './manifest'); 48 | const packageFile = JSON.parse( 49 | await readWorkspaceFile(getWorkspaceUri('manifest', 'package.json')) 50 | ); 51 | 52 | expect(project?.expoVersion).to.equal(packageFile.dependencies.expo); 53 | }); 54 | }); 55 | 56 | function stubProjectCache(subscriptions: vscode.ExtensionContext['subscriptions'] = []) { 57 | const stubProjectCache = new ExpoProjectCache({ subscriptions }); 58 | 59 | // @ts-expect-error 60 | stubProjectCache[Symbol.dispose] = () => stubProjectCache.dispose(); 61 | 62 | return stubProjectCache as Disposable & typeof stubProjectCache; 63 | } 64 | -------------------------------------------------------------------------------- /src/expo/bundler.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import vscode from 'vscode'; 3 | 4 | import { truthy } from '../utils/array'; 5 | 6 | const INSPECTABLE_DEVICE_TITLE = 'React Native Experimental (Improved Chrome Reloads)'; 7 | 8 | export interface InspectableDevice { 9 | id: string; 10 | description: string; 11 | title: string; 12 | faviconUrl: string; 13 | devtoolsFrontendUrl: string; 14 | type: 'node'; 15 | webSocketDebuggerUrl: string; 16 | vm: string; 17 | /** Added in Metro +0.75.x */ 18 | deviceName: string; 19 | /** Added in React Native 0.74+ */ 20 | reactNative?: Partial<{ 21 | logicalDeviceId: string; 22 | capabilities: Record; 23 | }>; 24 | } 25 | 26 | function getDeviceName(device: InspectableDevice) { 27 | return device.deviceName ?? 'Unknown device'; 28 | } 29 | 30 | /** Get a list of unique device names */ 31 | function getDeviceNames(devices: InspectableDevice[]) { 32 | return devices 33 | .map((device) => device.deviceName ?? 'Unknown device') 34 | .filter((deviceName, index, self) => self.indexOf(deviceName) === index); 35 | } 36 | 37 | /** Fetch a list of all devices to inspect, filtered by known inspectable targets */ 38 | export async function fetchDevicesToInspect({ 39 | host = '127.0.0.1', 40 | port = '8081', 41 | }: { 42 | host?: string; 43 | port?: string; 44 | }) { 45 | const response = await fetch(`http://${host}:${port}/json/list`); 46 | if (!response.ok) throw response; 47 | 48 | const devices = (await response.json()) as InspectableDevice[]; 49 | const reloadable = devices.filter( 50 | (device) => 51 | device.title === INSPECTABLE_DEVICE_TITLE || // SDK <51 52 | device.reactNative?.capabilities?.nativePageReloads // SDK 52+ 53 | ); 54 | 55 | // Manual filter for Expo Go, we really need to fix this 56 | const inspectable = reloadable.filter((device, index, list) => { 57 | // Only apply this to SDK 52+ 58 | if (device.title !== 'React Native Bridgeless [C++ connection]') return true; 59 | // If there are multiple inspectable pages, only use highest page number 60 | const devicesByPageNumber = list 61 | .filter((other) => device.title === other.title) 62 | .sort((a, b) => getDevicePageNumber(b) - getDevicePageNumber(a)); 63 | // Only use the highest page number 64 | return devicesByPageNumber[0] === device; 65 | }); 66 | 67 | return inspectable.filter(truthy); 68 | } 69 | 70 | function getDevicePageNumber(device: InspectableDevice) { 71 | return parseInt(new URL(device.webSocketDebuggerUrl).searchParams.get('page') ?? '0', 10); 72 | } 73 | 74 | export function findDeviceByName(devices: InspectableDevice[], deviceName: string) { 75 | return devices.find((devices) => getDeviceName(devices) === deviceName); 76 | } 77 | 78 | export async function askDeviceByName(devices: InspectableDevice[]) { 79 | const deviceName = await vscode.window.showQuickPick(getDeviceNames(devices), { 80 | placeHolder: 'Select a device to debug', 81 | }); 82 | 83 | if (!deviceName) { 84 | throw new Error('No device selected'); 85 | } 86 | 87 | const device = findDeviceByName(devices, deviceName); 88 | if (!device) { 89 | throw new Error('Could not find device with name: ' + deviceName); 90 | } 91 | 92 | return device; 93 | } 94 | 95 | /** Try to infer the device platform, by device name */ 96 | export function inferDevicePlatform(device: Pick) { 97 | const name = device.deviceName?.toLowerCase(); 98 | if (!name) return null; 99 | if (name.includes('iphone')) return 'ios'; 100 | if (name.includes('ipad')) return 'ios'; 101 | if (name.includes('gphone')) return 'android'; 102 | if (name.includes('desktop')) return 'windows'; 103 | if (name.includes('mac')) return 'macos'; 104 | 105 | // Android usually adds `XXX API 31` to the device name 106 | if (name.match(/api\s+[0-9]+/)) return 'android'; 107 | // Windows might include the windows name 108 | if (name.includes('windows')) return 'windows'; 109 | 110 | return null; 111 | } 112 | -------------------------------------------------------------------------------- /src/expo/cli.ts: -------------------------------------------------------------------------------- 1 | import { spawn, SpawnOptions } from '../utils/spawn'; 2 | 3 | /** 4 | * Execute an Expo CLI command using the `npx expo <...argsOrFlags>` syntax. 5 | * This is useful to ask the project for data, like introspection. 6 | */ 7 | export async function spawnExpoCli( 8 | command: string, 9 | argsOrFlags: string[] = [], 10 | options: SpawnOptions = {} 11 | ) { 12 | const result = await spawn('npx', ['expo', command, ...argsOrFlags], { 13 | ...options, 14 | env: { 15 | ...process.env, 16 | CI: 'true', // Force Expo CLI into non-interactive mode 17 | EXPO_NO_TELEMETRY: 'true', // Don't wait for external services, reducing process time 18 | }, 19 | }); 20 | 21 | return result.stdout; 22 | } 23 | -------------------------------------------------------------------------------- /src/expo/manifest.ts: -------------------------------------------------------------------------------- 1 | import { Range } from 'jsonc-parser'; 2 | import { DocumentFilter } from 'vscode'; 3 | 4 | export type FileReference = { 5 | filePath: string; 6 | fileRange: Range; 7 | }; 8 | 9 | /** 10 | * Select documents matching `app.json` or `app.config.json`. 11 | * Note, language is set to JSONC instead of JSON(5) to enable comments. 12 | */ 13 | export const manifestPattern: DocumentFilter = { 14 | scheme: 'file', 15 | language: 'jsonc', 16 | pattern: '**/*/app{,.config}.json', 17 | }; 18 | 19 | /** 20 | * Find all (sub)strings that might be a file path. 21 | * This returns all strings matching `"./"` or "../". 22 | */ 23 | export function getFileReferences(manifest: string): FileReference[] { 24 | const references = []; 25 | const matches = manifest.matchAll(/"(\.\.?\/.*)"/g); 26 | 27 | for (const match of matches) { 28 | if (!match.index) continue; 29 | 30 | references.push({ 31 | filePath: match[1], 32 | fileRange: { 33 | // Match index starts at the first quote, 34 | // offset it by 1 character to exclude from range. 35 | offset: match.index + 1, 36 | length: match[1].length, 37 | }, 38 | }); 39 | } 40 | 41 | return references; 42 | } 43 | -------------------------------------------------------------------------------- /src/expo/plugin.ts: -------------------------------------------------------------------------------- 1 | import { findNodeAtLocation, getNodeValue, Node, Range } from 'jsonc-parser'; 2 | 3 | import { ExpoProject } from './project'; 4 | import { 5 | resolveConfigPluginFunction, 6 | resolveConfigPluginFunctionWithInfo, 7 | } from '../packages/config-plugins'; 8 | import { truthy } from '../utils/array'; 9 | import { resetModuleFrom } from '../utils/module'; 10 | 11 | export type PluginDefiniton = { 12 | nameValue: string; 13 | nameRange: Range; 14 | }; 15 | 16 | export type PluginInfo = NonNullable>; 17 | export type PluginFunction = ReturnType; 18 | 19 | /** 20 | * Get the plugin definition from manifest node. 21 | * Both the `name` and it's `range` include the quotes. 22 | * This supports different plugin definitions: 23 | * - `"plugins": ["./my-plguin.js"]` 24 | * - `"plugins": ["expo-camera"]` 25 | * - `"plugins": [["expo-camera", [...]]` 26 | */ 27 | export function getPluginDefinition(plugin: Node): PluginDefiniton { 28 | const name = plugin.children?.length ? plugin.children[0] : plugin; 29 | 30 | return { 31 | nameValue: name.value, 32 | nameRange: { 33 | // Exclude the quotes from the range 34 | offset: name.offset + 1, 35 | length: name.length - 2, 36 | }, 37 | }; 38 | } 39 | 40 | /** 41 | * Try to resolve the config plugin information. 42 | * This resets previously imported modules to reload this information. 43 | * When it fails to resolve the config plugin, undefined is returned. 44 | */ 45 | export function resolvePluginInfo(dir: string, name: string): PluginInfo | undefined { 46 | resetModuleFrom(dir, name); 47 | 48 | try { 49 | return resolveConfigPluginFunctionWithInfo(dir, name); 50 | } catch { 51 | return undefined; 52 | } 53 | } 54 | 55 | /** 56 | * Try to resolve the actual config plugin function. 57 | * When it fails to resolve the config plugin, an error is thrown. 58 | */ 59 | export function resolvePluginFunctionUnsafe(dir: string, name: string): PluginFunction { 60 | return resolveConfigPluginFunction(dir, name); 61 | } 62 | 63 | /** 64 | * Resolve all installed plugin info from the project. 65 | * This uses the `package.json` to find all installed plugins. 66 | * 67 | * @todo Investigate potential issues with monorepos 68 | */ 69 | export function resolveInstalledPluginInfo( 70 | project: ExpoProject, 71 | search?: string, 72 | maxResults?: number 73 | ): PluginInfo[] { 74 | const dependenciesNode = findNodeAtLocation(project.package.tree, ['dependencies']); 75 | if (!dependenciesNode) { 76 | return []; 77 | } 78 | 79 | let dependencies = Object.keys(getNodeValue(dependenciesNode)); 80 | 81 | if (search) dependencies = dependencies.filter((name) => name.startsWith(search)); 82 | if (maxResults !== null) dependencies = dependencies.slice(0, maxResults); 83 | 84 | return dependencies.map((name) => resolvePluginInfo(project.root.fsPath, name)).filter(truthy); 85 | } 86 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | 3 | import { ExpoProjectCache } from './expo/project'; 4 | import { ExpoDebuggersProvider } from './expoDebuggers'; 5 | import { ManifestAssetCompletionsProvider } from './manifestAssetCompletions'; 6 | import { ManifestDiagnosticsProvider } from './manifestDiagnostics'; 7 | import { ManifestLinksProvider } from './manifestLinks'; 8 | import { ManifestPluginCompletionsProvider } from './manifestPluginCompletions'; 9 | import { setupPreview } from './preview/setupPreview'; 10 | import { reporter, setupTelemetry, TelemetryEvent } from './utils/telemetry'; 11 | 12 | // The contained provider classes are self-registering required subscriptions. 13 | // It helps grouping this code and keeping it maintainable, so disable the eslint rule. 14 | /* eslint-disable no-new */ 15 | 16 | export async function activate(context: vscode.ExtensionContext) { 17 | try { 18 | const projects = new ExpoProjectCache(context); 19 | 20 | setupTelemetry(context); 21 | setupPreview(context); 22 | 23 | new ExpoDebuggersProvider(context, projects); 24 | 25 | new ManifestLinksProvider(context, projects); 26 | new ManifestDiagnosticsProvider(context, projects); 27 | new ManifestPluginCompletionsProvider(context, projects); 28 | new ManifestAssetCompletionsProvider(context, projects); 29 | 30 | reporter?.sendTelemetryEvent(TelemetryEvent.ACTIVATED); 31 | } catch (error) { 32 | vscode.window.showErrorMessage( 33 | `Oops, looks like we can't fully activate Expo Tools: ${error.message}` 34 | ); 35 | 36 | reporter?.sendTelemetryErrorEvent(TelemetryEvent.ACTIVATED, { 37 | message: error.message, 38 | stack: error.stack, 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/manifestAssetCompletions.ts: -------------------------------------------------------------------------------- 1 | import { findNodeAtLocation, findNodeAtOffset, getNodeValue } from 'jsonc-parser'; 2 | import path from 'path'; 3 | import vscode from 'vscode'; 4 | 5 | import { manifestPattern } from './expo/manifest'; 6 | import { ExpoProjectCache } from './expo/project'; 7 | import { 8 | changedManifestFileReferencesEnabled, 9 | changedManifestFileReferencesExcludedFiles, 10 | getManifestFileReferencesExcludedFiles, 11 | isManifestFileReferencesEnabled, 12 | } from './settings'; 13 | import { truthy } from './utils/array'; 14 | import { debug } from './utils/debug'; 15 | import { fileIsExcluded, fileIsHidden, getDirectoryPath } from './utils/file'; 16 | import { findKeyStringNode, getDocumentRange, isKeyNode } from './utils/json'; 17 | import { ExpoCompletionsProvider, withCancelToken } from './utils/vscode'; 18 | 19 | const log = debug.extend('manifest-asset-completions'); 20 | 21 | /** The allowed asset extensions to provide file completions for */ 22 | const ASSET_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp']; 23 | /** Regex that matches all known asset properties within the XDL schema */ 24 | const ASSET_PROPERTIES = 25 | /^((?:x?x?x?(?:h|m)dpi)|(tablet|foreground|background)?[iI]mage|(?:fav)?icon)/; 26 | 27 | export class ManifestAssetCompletionsProvider extends ExpoCompletionsProvider { 28 | private isEnabled = true; 29 | private excludedFiles: Record; 30 | 31 | constructor(extension: vscode.ExtensionContext, projects: ExpoProjectCache) { 32 | super(extension, projects, manifestPattern, ['/']); 33 | 34 | this.isEnabled = isManifestFileReferencesEnabled(); 35 | this.excludedFiles = getManifestFileReferencesExcludedFiles(); 36 | 37 | extension.subscriptions.push( 38 | vscode.workspace.onDidChangeConfiguration((event) => { 39 | if (changedManifestFileReferencesEnabled(event)) { 40 | this.isEnabled = isManifestFileReferencesEnabled(); 41 | } 42 | 43 | if (changedManifestFileReferencesExcludedFiles(event)) { 44 | this.excludedFiles = getManifestFileReferencesExcludedFiles(); 45 | } 46 | }) 47 | ); 48 | } 49 | 50 | public async provideCompletionItems( 51 | document: vscode.TextDocument, 52 | position: vscode.Position, 53 | token: vscode.CancellationToken 54 | ) { 55 | if (!this.isEnabled) return null; 56 | 57 | const project = await this.projects.fromManifest(document); 58 | if (!project?.manifest) { 59 | log('Could not resolve project from manifest "%s"', document.fileName); 60 | return []; 61 | } 62 | 63 | // Abort if the cursor is within the plugins section 64 | const plugins = findNodeAtLocation(project.manifest.tree, ['plugins']); 65 | if (plugins && getDocumentRange(document, plugins).contains(position)) return null; 66 | 67 | // Abort if the cursor is on a JSON key property 68 | const positionNode = findNodeAtOffset(project.manifest.tree, document.offsetAt(position)); 69 | if (!positionNode || isKeyNode(positionNode)) return null; 70 | 71 | // Abort if the path is not relative, or if there is an extension already 72 | const positionValue = getNodeValue(positionNode); 73 | if (!positionValue?.startsWith('.') || path.extname(positionValue)) { 74 | return null; 75 | } 76 | 77 | // Abort if the property's key node is not a known asset node 78 | const positionKeyNode = findKeyStringNode(positionNode); 79 | const positionKeyValue = positionKeyNode && getNodeValue(positionKeyNode); 80 | if (!isAssetProperty(positionKeyValue)) return null; 81 | 82 | // Search entities within the user-provided directory 83 | const positionDir = getDirectoryPath(positionValue) ?? ''; 84 | const entities = await withCancelToken(token, () => 85 | vscode.workspace.fs.readDirectory(vscode.Uri.joinPath(project.root, positionDir)) 86 | ); 87 | 88 | return entities 89 | ?.map(([entityName, entityType]) => { 90 | if (fileIsHidden(entityName) || fileIsExcluded(entityName, this.excludedFiles)) { 91 | return null; 92 | } 93 | 94 | if (entityType === vscode.FileType.Directory) { 95 | return createFolder(entityName); 96 | } 97 | 98 | if ( 99 | entityType === vscode.FileType.File && 100 | ASSET_EXTENSIONS.includes(path.extname(entityName)) 101 | ) { 102 | return createFile(entityName); 103 | } 104 | 105 | return null; 106 | }) 107 | .filter(truthy); 108 | } 109 | } 110 | 111 | function isAssetProperty(name: string): boolean { 112 | return ASSET_PROPERTIES.test(name); 113 | } 114 | 115 | function createFile(filePath: string): vscode.CompletionItem { 116 | const item = new vscode.CompletionItem(filePath, vscode.CompletionItemKind.File); 117 | 118 | item.sortText = `d_${path.basename(filePath)}`; 119 | 120 | return item; 121 | } 122 | 123 | /** 124 | * Create a new completion item for a folder. 125 | * Note, this adds a trailing `/` to the folder and triggers the next suggestion automatically. 126 | * While this makes it harder to type `./folder`, `./folder/` is a valid shorthand for `./folder/index.js`. 127 | */ 128 | function createFolder(folderPath: string): vscode.CompletionItem { 129 | const item = new vscode.CompletionItem(folderPath + '/', vscode.CompletionItemKind.Folder); 130 | 131 | item.sortText = `d_${path.basename(folderPath)}`; 132 | item.command = { 133 | title: '', 134 | command: 'editor.action.triggerSuggest', 135 | }; 136 | 137 | return item; 138 | } 139 | -------------------------------------------------------------------------------- /src/manifestDiagnostics.ts: -------------------------------------------------------------------------------- 1 | import { findNodeAtLocation, Node } from 'jsonc-parser'; 2 | import vscode from 'vscode'; 3 | 4 | import { type FileReference, getFileReferences, manifestPattern } from './expo/manifest'; 5 | import { getPluginDefinition, resolvePluginFunctionUnsafe } from './expo/plugin'; 6 | import { ExpoProject, ExpoProjectCache } from './expo/project'; 7 | import { 8 | changedManifesPluginValidationEnabled, 9 | isManifestPluginValidationEnabled, 10 | } from './settings'; 11 | import { debug } from './utils/debug'; 12 | import { getDocumentRange } from './utils/json'; 13 | import { resetModuleFrom } from './utils/module'; 14 | import { ExpoDiagnosticsProvider } from './utils/vscode'; 15 | 16 | const log = debug.extend('manifest-diagnostics'); 17 | 18 | enum AssetIssueCode { 19 | notFound = 'FILE_NOT_FOUND', 20 | isDirectory = 'FILE_IS_DIRECTORY', 21 | } 22 | 23 | enum PluginIssueCode { 24 | definitionInvalid = 'PLUGIN_DEFINITION_INVALID', 25 | functionInvalid = 'PLUGIN_FUNCTION_INVALID', 26 | } 27 | 28 | export class ManifestDiagnosticsProvider extends ExpoDiagnosticsProvider { 29 | private isEnabled = true; 30 | 31 | constructor(extension: vscode.ExtensionContext, projects: ExpoProjectCache) { 32 | super(extension, projects, manifestPattern, 'expo-manifest'); 33 | this.isEnabled = isManifestPluginValidationEnabled(); 34 | 35 | extension.subscriptions.push( 36 | vscode.workspace.onDidChangeConfiguration((event) => { 37 | if (changedManifesPluginValidationEnabled(event)) { 38 | this.isEnabled = isManifestPluginValidationEnabled(); 39 | } 40 | }) 41 | ); 42 | } 43 | 44 | public async provideDiagnostics(document: vscode.TextDocument): Promise { 45 | const issues: vscode.Diagnostic[] = []; 46 | 47 | if (!this.isEnabled) return issues; 48 | 49 | const project = await this.projects.fromManifest(document); 50 | if (!project?.manifest) { 51 | log('Could not resolve project from manifest "%s"', document.fileName); 52 | return issues; 53 | } 54 | 55 | const plugins = findNodeAtLocation(project.manifest.tree, ['plugins']); 56 | const pluginsRange = plugins && getDocumentRange(document, plugins); 57 | 58 | // Diagnose each defined plugin, if any 59 | for (const pluginNode of plugins?.children ?? []) { 60 | const issue = diagnosePlugin(document, project, pluginNode); 61 | if (issue) issues.push(issue); 62 | } 63 | 64 | // Diagnose each defined asset, if any 65 | for (const reference of getFileReferences(project.manifest.content)) { 66 | const range = getDocumentRange(document, reference.fileRange); 67 | 68 | if (!pluginsRange?.contains(range)) { 69 | const issue = await diagnoseAsset(document, project, reference); 70 | if (issue) issues.push(issue); 71 | } 72 | } 73 | 74 | return issues; 75 | } 76 | } 77 | 78 | function diagnosePlugin(document: vscode.TextDocument, project: ExpoProject, plugin: Node) { 79 | const { nameValue, nameRange } = getPluginDefinition(plugin); 80 | 81 | if ((plugin.children && plugin.children.length === 0) || !nameValue) { 82 | const issue = new vscode.Diagnostic( 83 | getDocumentRange(document, nameRange ?? plugin), 84 | `Plugin definition is empty, expected a file or dependency name`, 85 | vscode.DiagnosticSeverity.Warning 86 | ); 87 | issue.code = PluginIssueCode.definitionInvalid; 88 | return issue; 89 | } 90 | 91 | try { 92 | resetModuleFrom(project.root.fsPath, nameValue); 93 | resolvePluginFunctionUnsafe(project.root.fsPath, nameValue); 94 | } catch (error) { 95 | const issue = new vscode.Diagnostic( 96 | getDocumentRange(document, nameRange), 97 | error.message, 98 | vscode.DiagnosticSeverity.Warning 99 | ); 100 | 101 | issue.code = error.code; 102 | 103 | if (error.code === 'PLUGIN_NOT_FOUND') { 104 | issue.message = `Plugin not found: ${nameValue}`; 105 | } 106 | 107 | if (error.name === 'TypeError' && error.message?.includes(`null (reading 'default')`)) { 108 | issue.message = `Plugin exports null, expected a plugin function: ${nameValue}`; 109 | issue.code = PluginIssueCode.functionInvalid; 110 | } 111 | 112 | return issue; 113 | } 114 | } 115 | 116 | async function diagnoseAsset( 117 | document: vscode.TextDocument, 118 | project: ExpoProject, 119 | reference: FileReference 120 | ) { 121 | try { 122 | const uri = vscode.Uri.joinPath(project.root, reference.filePath); 123 | const asset = await vscode.workspace.fs.stat(uri); 124 | 125 | if (asset.type === vscode.FileType.Directory) { 126 | const issue = new vscode.Diagnostic( 127 | getDocumentRange(document, reference.fileRange), 128 | `File is a directory: ${reference.filePath}`, 129 | vscode.DiagnosticSeverity.Warning 130 | ); 131 | 132 | issue.code = AssetIssueCode.isDirectory; 133 | return issue; 134 | } 135 | } catch (error) { 136 | const issue = new vscode.Diagnostic( 137 | getDocumentRange(document, reference.fileRange), 138 | error.message, 139 | vscode.DiagnosticSeverity.Warning 140 | ); 141 | 142 | if (error.code === 'FileNotFound') { 143 | issue.message = `File not found: ${reference.filePath}`; 144 | issue.code = AssetIssueCode.notFound; 145 | } 146 | 147 | return issue; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/manifestLinks.ts: -------------------------------------------------------------------------------- 1 | import { findNodeAtLocation } from 'jsonc-parser'; 2 | import vscode from 'vscode'; 3 | 4 | import { getFileReferences, manifestPattern } from './expo/manifest'; 5 | import { getPluginDefinition, resolvePluginInfo } from './expo/plugin'; 6 | import { ExpoProjectCache } from './expo/project'; 7 | import { changedManifestFileReferencesEnabled, isManifestFileReferencesEnabled } from './settings'; 8 | import { debug } from './utils/debug'; 9 | import { getDocumentRange } from './utils/json'; 10 | import { ExpoLinkProvider } from './utils/vscode'; 11 | 12 | const log = debug.extend('manifest-links'); 13 | 14 | export class ManifestLinksProvider extends ExpoLinkProvider { 15 | private isEnabled = true; 16 | 17 | constructor(extension: vscode.ExtensionContext, projects: ExpoProjectCache) { 18 | super(extension, projects, manifestPattern); 19 | this.isEnabled = isManifestFileReferencesEnabled(); 20 | 21 | extension.subscriptions.push( 22 | vscode.workspace.onDidChangeConfiguration((event) => { 23 | if (changedManifestFileReferencesEnabled(event)) { 24 | this.isEnabled = isManifestFileReferencesEnabled(); 25 | } 26 | }) 27 | ); 28 | } 29 | 30 | async provideDocumentLinks(document: vscode.TextDocument, token: vscode.CancellationToken) { 31 | const links: vscode.DocumentLink[] = []; 32 | 33 | if (!this.isEnabled) return links; 34 | 35 | const project = await this.projects.fromManifest(document); 36 | if (!project?.manifest) { 37 | log('Could not resolve project from manifest "%s"', document.fileName); 38 | return links; 39 | } 40 | 41 | const plugins = findNodeAtLocation(project.manifest.tree, ['plugins']); 42 | const pluginsRange = plugins && getDocumentRange(document, plugins); 43 | 44 | // Create links for each defined plugins, if any 45 | for (const pluginNode of plugins?.children ?? []) { 46 | if (token.isCancellationRequested) return links; 47 | 48 | const { nameValue, nameRange } = getPluginDefinition(pluginNode); 49 | const plugin = resolvePluginInfo(project.root.fsPath, nameValue); 50 | 51 | if (plugin) { 52 | const link = new vscode.DocumentLink( 53 | getDocumentRange(document, nameRange), 54 | vscode.Uri.file(plugin.pluginFile) 55 | ); 56 | 57 | link.tooltip = 'Go to plugin'; 58 | links.push(link); 59 | } 60 | } 61 | 62 | // Create links for each defined assets, if any 63 | for (const reference of getFileReferences(project.manifest.content)) { 64 | if (token.isCancellationRequested) return links; 65 | 66 | const range = getDocumentRange(document, reference.fileRange); 67 | 68 | if (!pluginsRange?.contains(range)) { 69 | const link = new vscode.DocumentLink( 70 | range, 71 | vscode.Uri.joinPath(project.root, reference.filePath) 72 | ); 73 | 74 | link.tooltip = 'Go to asset'; 75 | links.push(link); 76 | } 77 | } 78 | 79 | return links; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/packages/config-plugins.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/first,import/order */ 2 | 3 | /** 4 | * Reuse the `ModPlatform` type from the Config Plugins package. 5 | * This outlines the platforms we can expect from the Config Plugins. 6 | */ 7 | export type { ModPlatform } from '@expo/config-plugins/build/Plugin.types'; 8 | 9 | /** 10 | * Use a bundled version of the Gradle properties formatter. 11 | * This is used when previewing the project's Gradle properties. 12 | * The library itself likely won't change much, so it's safe to only rely on the bundled version. 13 | */ 14 | export { propertiesListToString as formatGradleProperties } from '@expo/config-plugins/build/android/Properties'; 15 | 16 | /** 17 | * Use a bundled version of the XML formatter. 18 | * This is used when previewing XML files within the project. 19 | * The library itself likely won't change much, so it's safe to only rely on the bundled version. 20 | */ 21 | export { format as formatXml } from '@expo/config-plugins/build/utils/XML'; 22 | 23 | /** 24 | * Use a bundled version of the Config Plugins mod compiler. 25 | * This is used to "compile" or run the plugins on a manifest. 26 | * 27 | * @note This bundled package is slightly outdated, attempt to load from `npx expo config` instead. 28 | */ 29 | export { compileModsAsync } from '@expo/config-plugins/build/plugins/mod-compiler'; 30 | 31 | /** 32 | * Use a bundled version of the Config Plugins resolver. 33 | * This is used when validating the project's app manifest. 34 | * 35 | * @note This bundled package is slightly outdated and should be loaded from the project where possible. 36 | */ 37 | export { 38 | resolveConfigPluginFunction, 39 | resolveConfigPluginFunctionWithInfo, 40 | } from '@expo/config-plugins/build/utils/plugin-resolver'; 41 | -------------------------------------------------------------------------------- /src/packages/config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/first,import/order */ 2 | 3 | /** 4 | * Use a bundled version of the Expo Config package. 5 | * This is used to retrieve the app manifest from a project. 6 | * 7 | * @note This bundled package is slightly outdated, attempt to load from `npx expo config` instead. 8 | */ 9 | export { type ExpoConfig, getConfig } from '@expo/config/build/Config'; 10 | -------------------------------------------------------------------------------- /src/packages/plist.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/first,import/order */ 2 | 3 | /** 4 | * Use a bundled version of the plist formatter. 5 | * This is used when previewing iOS plist files within the project. 6 | * The library itself likely won't change much, so it's safe to only rely on the bundled version. 7 | */ 8 | import plist from '@expo/plist'; 9 | export const formatPlist = plist.build; 10 | -------------------------------------------------------------------------------- /src/packages/prebuild-config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/first,import/order */ 2 | 3 | /** 4 | * Use a bundled version of the prebuild config generator. 5 | * This is used to retrieve the config for Config Plugins. 6 | * 7 | * @note This bundled package is slightly outdated, attempt to load from `npx expo config` instead. 8 | */ 9 | export { getPrebuildConfigAsync } from '@expo/prebuild-config/build/getPrebuildConfig'; 10 | -------------------------------------------------------------------------------- /src/preview/ExpoConfigCodeProvider.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | 3 | import { CodeProvider, type BasicCodeProviderOptions, CodeProviderLanguage } from './CodeProvider'; 4 | import { ExpoConfigType } from './constants'; 5 | import { spawnExpoCli } from '../expo/cli'; 6 | import { type ExpoConfig, getConfig } from '../packages/config'; 7 | import { compileModsAsync } from '../packages/config-plugins'; 8 | import { getPrebuildConfigAsync } from '../packages/prebuild-config'; 9 | 10 | export abstract class ExpoConfigCodeProvider extends CodeProvider { 11 | readonly defaultLanguage: CodeProviderLanguage = 'json'; 12 | 13 | constructor( 14 | public configType: ExpoConfigType, 15 | document: vscode.TextDocument, 16 | options: BasicCodeProviderOptions 17 | ) { 18 | super(document, { 19 | ...options, 20 | type: configType, 21 | }); 22 | } 23 | 24 | /** Get the generated contents of the file being previewed */ 25 | abstract getFileContents(): Promise; 26 | 27 | getFileName() { 28 | // TODO: Maybe manifest.json is better? 29 | 30 | // Use _app.config.json to disable all features like auto complete and intellisense on the file. 31 | const name = this.configType === ExpoConfigType.PUBLIC ? 'exp.json' : '_app.config.json'; 32 | return name; 33 | } 34 | } 35 | 36 | export class IntrospectExpoConfigCodeProvider extends ExpoConfigCodeProvider { 37 | constructor(document: vscode.TextDocument, options: BasicCodeProviderOptions) { 38 | super(ExpoConfigType.INTROSPECT, document, options); 39 | } 40 | 41 | async getExpoConfigAsync() { 42 | return await getPrebuildConfigAsync(this.projectRoot, { platforms: ['ios', 'android'] }).then( 43 | (config) => config.exp 44 | ); 45 | } 46 | 47 | async getFileContents() { 48 | let config: ExpoConfig; 49 | 50 | try { 51 | const result = await spawnExpoCli('config', ['--json', '--type', 'introspect'], { 52 | cwd: this.projectRoot, 53 | }); 54 | 55 | config = JSON.parse(result); 56 | } catch (error: any) { 57 | console.warn( 58 | 'Cannot load the introspected config from project, using bundled package instead.' 59 | ); 60 | console.warn(`Reason: ${error.message} (${error.code})`); 61 | 62 | config = await compileModsAsync(await this.getExpoConfigAsync(), { 63 | projectRoot: this.projectRoot, 64 | platforms: ['android', 'ios'], 65 | introspect: true, 66 | }); 67 | } 68 | 69 | return config; 70 | } 71 | } 72 | 73 | export class PublicExpoConfigCodeProvider extends ExpoConfigCodeProvider { 74 | constructor(document: vscode.TextDocument, options: BasicCodeProviderOptions) { 75 | super(ExpoConfigType.PUBLIC, document, options); 76 | } 77 | 78 | async getFileContents() { 79 | let config: ExpoConfig; 80 | 81 | try { 82 | const result = await spawnExpoCli('config', ['--json', '--type', 'public'], { 83 | cwd: this.projectRoot, 84 | }); 85 | 86 | config = JSON.parse(result); 87 | } catch (error: any) { 88 | console.warn('Cannot load the public config from project, using bundled package instead.'); 89 | console.warn(`Reason: ${error.message} (${error.code})`); 90 | 91 | config = getConfig(this.projectRoot, { 92 | isPublicConfig: true, 93 | skipSDKVersionRequirement: true, 94 | }).exp; 95 | } 96 | 97 | return config; 98 | } 99 | } 100 | 101 | export class PrebuildExpoConfigCodeProvider extends ExpoConfigCodeProvider { 102 | constructor(document: vscode.TextDocument, options: BasicCodeProviderOptions) { 103 | super(ExpoConfigType.PREBUILD, document, options); 104 | } 105 | 106 | async getFileContents() { 107 | let config: ExpoConfig; 108 | 109 | try { 110 | const result = await spawnExpoCli('config', ['--json', '--type', 'prebuild'], { 111 | cwd: this.projectRoot, 112 | }); 113 | 114 | config = JSON.parse(result); 115 | } catch (error: any) { 116 | console.warn('Cannot load the prebuild config from project, using bundled package instead.'); 117 | console.warn(`Reason: ${error.message} (${error.code})`); 118 | 119 | config = await getPrebuildConfigAsync(this.projectRoot, { 120 | platforms: ['ios', 'android'], 121 | }).then((prebuildConfig) => prebuildConfig.exp); 122 | } 123 | 124 | return config; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/preview/constants.ts: -------------------------------------------------------------------------------- 1 | export enum ExpoConfigType { 2 | PREBUILD = 'prebuild', 3 | INTROSPECT = 'introspect', 4 | PUBLIC = 'public', 5 | } 6 | 7 | export enum PreviewCommand { 8 | OpenExpoFilePrebuild = 'expo.config.prebuild.preview', 9 | OpenExpoFileJsonPrebuild = 'expo.config.prebuild.preview.json', 10 | OpenExpoConfigPrebuild = 'expo.config.preview', 11 | } 12 | 13 | export enum PreviewModProvider { 14 | iosInfoPlist = 'ios.infoPlist', 15 | iosEntitlements = 'ios.entitlements', 16 | iosExpoPlist = 'ios.expoPlist', 17 | iosPodfileProperties = 'ios.podfileProperties', 18 | androidManifest = 'android.manifest', 19 | androidStrings = 'android.strings', 20 | androidColors = 'android.colors', 21 | androidColorsNight = 'android.colorsNight', 22 | androidStyles = 'android.styles', 23 | androidGradleProperties = 'android.gradleProperties', 24 | } 25 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | 3 | /** 4 | * Determine if we should validate the config plugins within app manifests. 5 | * This uses the `expo.appManifest.pluginValidation` setting from the configuration scope. 6 | */ 7 | export function isManifestPluginValidationEnabled(scope?: vscode.ConfigurationScope) { 8 | return vscode.workspace 9 | .getConfiguration('expo.appManifest', scope) 10 | .get('pluginValidation', true); 11 | } 12 | 13 | export function changedManifesPluginValidationEnabled(event: vscode.ConfigurationChangeEvent) { 14 | return event.affectsConfiguration('expo.appManifest.pluginValidation'); 15 | } 16 | 17 | /** 18 | * Determine if we should show file references auto-complete in app manifests. 19 | * This uses the `expo.appManifest.fileReferences` setting from the configuration scope. 20 | */ 21 | export function isManifestFileReferencesEnabled(scope?: vscode.ConfigurationScope) { 22 | return vscode.workspace 23 | .getConfiguration('expo.appManifest', scope) 24 | .get('fileReferences', true); 25 | } 26 | 27 | export function changedManifestFileReferencesEnabled(event: vscode.ConfigurationChangeEvent) { 28 | return event.affectsConfiguration('expo.appManifest.fileReferences'); 29 | } 30 | 31 | /** 32 | * Get the excluded files configuration, used to filter out path-based suggestions. 33 | * This is a combination of multiple glob patterns: 34 | * - `files.exclude` - main vscode file exclusion 35 | * - `expo.appManifest.fileReferences.excludeGlobPatterns` - expo-specific file exclusion 36 | */ 37 | export function getManifestFileReferencesExcludedFiles(scope?: vscode.ConfigurationScope) { 38 | const config = vscode.workspace.getConfiguration(undefined, scope); 39 | 40 | return { 41 | ...config.get>('files.exclude', {}), 42 | ...config.get>('expo.appManifest.fileReferences.excludeGlobPatterns', { 43 | '**/node_modules': true, 44 | }), 45 | }; 46 | } 47 | 48 | export function changedManifestFileReferencesExcludedFiles(event: vscode.ConfigurationChangeEvent) { 49 | return ( 50 | event.affectsConfiguration('files.exclude') || 51 | event.affectsConfiguration('expo.appManifest.fileReferences.excludeGlobPatterns') 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | export interface ProcessEnv { 3 | EXTENSION_NAME: string; 4 | EXTENSION_VERSION: string; 5 | EXTENSION_ID: string; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/__tests__/array.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { uniqueBy } from '../array'; 4 | 5 | describe('uniqueBy', () => { 6 | it('returns unique numbers', () => { 7 | expect([1, 2, 3, 3].filter(uniqueBy(String))).to.deep.equal([1, 2, 3]); 8 | }); 9 | 10 | it('returns unique strings', () => { 11 | expect(['hello', 'world', 'hello'].filter(uniqueBy(String))).to.deep.equal(['hello', 'world']); 12 | }); 13 | 14 | it('returns unique mixed values', () => { 15 | expect([1, 'hello', 2, 2, 'world', 'hello', 3].filter(uniqueBy(String))).to.deep.equal([ 16 | 1, 17 | 'hello', 18 | 2, 19 | 'world', 20 | 3, 21 | ]); 22 | }); 23 | 24 | it('returns unique strings from objects', () => { 25 | const list = [ 26 | { id: 1, name: 'hello' }, 27 | { id: 2, name: 'world' }, 28 | { id: 3, name: 'hello' }, 29 | ]; 30 | 31 | expect(list.filter(uniqueBy((item) => item.name))).to.deep.equal([ 32 | { id: 1, name: 'hello' }, 33 | { id: 2, name: 'world' }, 34 | ]); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/utils/__tests__/debounce.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { fake } from 'sinon'; 3 | 4 | import { waitFor } from '../../__tests__/utils/wait'; 5 | import { debounce } from '../debounce'; 6 | 7 | describe('debounce', () => { 8 | it('executes function after debounce delay', async () => { 9 | const mock = fake(); 10 | const debounced = debounce(mock, 10); 11 | 12 | debounced(); 13 | await waitFor(10); 14 | 15 | expect(mock).to.have.property('calledOnce', true); 16 | }); 17 | 18 | it('delays execution after multiple calls', async () => { 19 | const mock = fake(); 20 | const debounced = debounce(mock, 10); 21 | 22 | debounced(); 23 | debounced(); 24 | debounced(); 25 | await waitFor(10); 26 | 27 | expect(mock).to.have.property('calledOnce', true); 28 | }); 29 | 30 | it('uses the provided arguments', async () => { 31 | const mock = fake((name: string) => `hello ${name}`); 32 | const debounced = debounce(mock, 10); 33 | 34 | debounced('world'); 35 | await waitFor(10); 36 | 37 | expect(mock).to.have.property('calledOnce', true); 38 | expect(mock.args[0]).to.deep.include('world'); 39 | }); 40 | 41 | it('uses the provided arguments of last delayed call', async () => { 42 | const mock = fake((name: string) => `hello ${name}`); 43 | const debounced = debounce(mock, 10); 44 | 45 | debounced('moon'); 46 | debounced('earth'); 47 | debounced('world'); 48 | await waitFor(10); 49 | 50 | expect(mock).to.have.property('calledOnce', true); 51 | expect(mock.args[0]).to.deep.include('world'); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/utils/__tests__/file.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { fileIsExcluded, fileIsHidden, getDirectoryPath } from '../file'; 4 | 5 | describe('getDirectoryPath', () => { 6 | it('returns root (.) for paths without directories', () => { 7 | expect(getDirectoryPath('')).to.be.null; 8 | expect(getDirectoryPath('.')).to.be.null; 9 | expect(getDirectoryPath('./some-file')).to.be.null; 10 | expect(getDirectoryPath('./some-file.jpg')).to.be.null; 11 | }); 12 | 13 | it('returns directory name for paths with directories', () => { 14 | expect(getDirectoryPath('./some-dir/this')).to.equal('./some-dir'); 15 | expect(getDirectoryPath('./other-dir/some-file')).to.equal('./other-dir'); 16 | expect(getDirectoryPath('./this-dir/some-file.jpg')).to.equal('./this-dir'); 17 | }); 18 | }); 19 | 20 | describe('fileIsHidden', () => { 21 | it('returns true for dotfiles', () => { 22 | expect(fileIsHidden('.gitignore')).to.be.true; 23 | expect(fileIsHidden('.npmrc')).to.be.true; 24 | expect(fileIsHidden('.env')).to.be.true; 25 | }); 26 | 27 | it('returns false for other files', () => { 28 | expect(fileIsHidden('App.js')).to.be.false; 29 | expect(fileIsHidden('splash.jpg')).to.be.false; 30 | expect(fileIsHidden('package.json')).to.be.false; 31 | }); 32 | }); 33 | 34 | describe('fileIsExcluded', () => { 35 | it('returns false without exclusion', () => { 36 | expect(fileIsExcluded('App.js')).to.be.false; 37 | expect(fileIsExcluded('.gitignore')).to.be.false; 38 | }); 39 | 40 | it('returns true for files matching exclusion', () => { 41 | expect(fileIsExcluded('App.js', { 'App.js': true })).to.be.true; 42 | expect(fileIsExcluded('my-app/package.json', { '**/package.json': true })).to.be.true; 43 | }); 44 | 45 | it('returns false for files not matching exclusion', () => { 46 | expect(fileIsExcluded('App.tsx', { 'App.js': true })).to.be.false; 47 | expect(fileIsExcluded('my-app/package.json', { '**/App.js': true })).to.be.false; 48 | expect(fileIsExcluded('my-app/package.json', { '**/package.json': false })).to.be.false; 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/utils/array.ts: -------------------------------------------------------------------------------- 1 | /** A predicate to filter arrays on truthy values, returning a type-safe array. */ 2 | export function truthy(value: T | null | undefined): value is T { 3 | return !!value; 4 | } 5 | 6 | /** Create a predicate to filter on first occurence of a generated value within an array. */ 7 | export function uniqueBy(key: (value: T) => string) { 8 | return (value: T, index: number, list: T[]) => { 9 | const identifier = key(value); 10 | return list.findIndex((item) => identifier === key(item)) === index; 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/cache.ts: -------------------------------------------------------------------------------- 1 | import type { Disposable, ExtensionContext } from 'vscode'; 2 | 3 | export abstract class MapCacheProvider implements Disposable { 4 | protected cache: Map = new Map(); 5 | 6 | constructor({ subscriptions }: Pick) { 7 | subscriptions.push(this); 8 | } 9 | 10 | dispose() { 11 | this.cache.clear(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | type Debounced any> = { 2 | /** The debounced function, without any return value */ 3 | (...args: Parameters): void; 4 | /** Cancel the debounced timer and prevent the function being called */ 5 | cancel(): void; 6 | }; 7 | 8 | /** 9 | * Create a function that which is called after some delay. 10 | * The delay timer is reset every time the function is called. 11 | * It's possible to fully cancel the function by using `debounce(...).cancel()`. 12 | */ 13 | export function debounce any>(action: T, delay = 500): Debounced { 14 | let timerId: NodeJS.Timeout | null = null; 15 | 16 | const cancel = () => { 17 | if (timerId) { 18 | clearTimeout(timerId); 19 | timerId = null; 20 | } 21 | }; 22 | 23 | const debounced = (...args: any[]) => { 24 | if (timerId) clearTimeout(timerId); 25 | timerId = setTimeout(() => action(...args), delay); 26 | }; 27 | 28 | debounced.cancel = cancel; 29 | 30 | return debounced; 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/debug.ts: -------------------------------------------------------------------------------- 1 | import Debug from 'debug'; 2 | 3 | /** The namespace tag used for logging */ 4 | const TAG = 'vscode-expo'; 5 | 6 | /** The root debug logger for the extension, use `debug.extend` to create sub-loggers */ 7 | export const debug = Debug(TAG); 8 | 9 | // vscode doesn't pipe the `DEBUG` environment variable, 10 | // so we use `VSCODE_EXPO_DEBUG` instead. 11 | if (process.env.VSCODE_EXPO_DEBUG) { 12 | Debug.enable(process.env.VSCODE_EXPO_DEBUG); 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/file.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import picomatch from 'picomatch'; 3 | import vscode from 'vscode'; 4 | 5 | /** 6 | * Get the directory path from a user-provided file path. 7 | * This slightly differs from the `path.dirname` function: 8 | * - ./ -> null (path.dirname: '.') 9 | * - ./abc -> null (path.dirname: '.') 10 | * - ./abc/ -> './abc' (path.dirname: '.') 11 | * - ./abc/def -> './abc' (path.dirname: './abc') 12 | */ 13 | export function getDirectoryPath(filePath: string) { 14 | const dir = path.dirname(filePath); 15 | if (dir === '.') { 16 | if (filePath.endsWith('/')) { 17 | return path.basename(filePath); 18 | } 19 | return null; 20 | } 21 | return dir; 22 | } 23 | 24 | export function fileIsHidden(filePath: string) { 25 | return filePath.startsWith('.'); 26 | } 27 | 28 | export function fileIsExcluded(filePath: string, filesExcluded?: Record) { 29 | if (!filesExcluded) { 30 | return false; 31 | } 32 | 33 | return Object.entries(filesExcluded).some( 34 | ([pattern, isExcluded]) => isExcluded && picomatch(pattern)(filePath) 35 | ); 36 | } 37 | 38 | /** Read a workspace file through vscode's workspace API and return the string equivalent */ 39 | export async function readWorkspaceFile(uri: vscode.Uri) { 40 | return Buffer.from(await vscode.workspace.fs.readFile(uri)).toString('utf-8'); 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/json.ts: -------------------------------------------------------------------------------- 1 | import { Node, Range } from 'jsonc-parser'; 2 | import vscode from 'vscode'; 3 | 4 | export function getDocumentRange(document: vscode.TextDocument, range: Range): vscode.Range { 5 | return new vscode.Range( 6 | document.positionAt(range.offset), 7 | document.positionAt(range.offset + range.length) 8 | ); 9 | } 10 | 11 | /** 12 | * Determine if the node is a JSON key node. 13 | * For that, the node must be either: 14 | * - type of `property` 15 | * - type of `string` and have a parent of type `property` 16 | * 17 | * @example `{ "some": "value" }` where `some` is a key node 18 | */ 19 | export function isKeyNode(node: Node) { 20 | if (node.type === 'property') { 21 | return true; 22 | } 23 | 24 | return ( 25 | node.type === 'string' && node.parent?.type === 'property' && node.parent.children?.[0] === node 26 | ); 27 | } 28 | 29 | /** 30 | * Find the JSON property string node, from a value node. 31 | * This searches the `parent.children` list and returns the first `string` node. 32 | */ 33 | export function findKeyStringNode(node: Node) { 34 | if (node.parent?.type !== 'property') { 35 | return null; 36 | } 37 | 38 | if (node.parent?.children?.[0].type === 'string') { 39 | return node.parent.children[0]; 40 | } 41 | 42 | return null; 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/module.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Reset a single resolved module from the cache registry. 3 | */ 4 | function resetModule(module: NodeModule) { 5 | // Delete this module from possible parents 6 | for (const cached of Object.values(require.cache)) { 7 | if (!cached) continue; 8 | 9 | const index = cached.children.indexOf(module); 10 | if (index >= 0) { 11 | cached.children.splice(index, 1); 12 | } 13 | } 14 | 15 | // Delete itself from the cache 16 | delete require.cache[module.id]; 17 | 18 | // Delete all children of this module from cache 19 | for (const child of module.children) { 20 | resetModule(child); 21 | } 22 | } 23 | 24 | /** 25 | * Reset an imported file or module from the module registry. 26 | * Both the directory and imported module or file are required. 27 | */ 28 | export function resetModuleFrom(dir: string, moduleOrFile: string) { 29 | try { 30 | const moduleId = require.resolve(moduleOrFile, { paths: [dir] }); 31 | const module = require.cache[moduleId]; 32 | 33 | if (module) { 34 | resetModule(module); 35 | } 36 | 37 | return moduleId; 38 | } catch { 39 | return undefined; 40 | } 41 | } 42 | 43 | /** 44 | * Reset all imported files or modules from the module registry, by directory. 45 | */ 46 | export function resetModulesFrom(dir: string) { 47 | // Find all cached modules matching the directory 48 | const cachedIds = Object.keys(require.cache).filter( 49 | (cacheId) => cacheId.startsWith(dir) && !/node_modules/.test(cacheId) 50 | ); 51 | 52 | for (const cachedId of cachedIds) { 53 | const module = require.cache[cachedId]; 54 | if (module) { 55 | resetModule(module); 56 | } 57 | } 58 | 59 | return cachedIds; 60 | } 61 | -------------------------------------------------------------------------------- /src/utils/spawn.ts: -------------------------------------------------------------------------------- 1 | import spawnAsync from '@expo/spawn-async'; 2 | 3 | /** Re-export all important types */ 4 | export type { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/spawn-async'; 5 | 6 | /** Re-export `spawnAsync` as a stub-able property */ 7 | export const spawn = spawnAsync; 8 | -------------------------------------------------------------------------------- /src/utils/telemetry.ts: -------------------------------------------------------------------------------- 1 | import TelemetryReporter, { 2 | type TelemetryEventMeasurements, 3 | type TelemetryEventProperties, 4 | } from '@vscode/extension-telemetry'; 5 | import { type ExtensionContext } from 'vscode'; 6 | 7 | import { debug } from './debug'; 8 | 9 | const log = debug.extend('telemetry'); 10 | 11 | /** The telemetry instrumentation key */ 12 | const telemetryKey = process.env.VSCODE_EXPO_TELEMETRY_KEY; 13 | 14 | /** The telemetry singleton instance */ 15 | export let reporter: TelemetryReporter | null = null; 16 | 17 | /** The different telemetry event types */ 18 | export enum TelemetryEvent { 19 | ACTIVATED = 'activated', 20 | PREVIEW_CONFIG = 'previewConfig', 21 | PREVIEW_PREBUILD = 'previewPrebuild', 22 | } 23 | 24 | /** 25 | * Initialize the telemetry for error reporting and extension improvements. 26 | * This data is anonymous and does not contain any personal information. 27 | * You can opt-out to telemetry by disabling telemetry in vscode. 28 | * 29 | * @see https://code.visualstudio.com/docs/getstarted/telemetry 30 | */ 31 | export function setupTelemetry(context: ExtensionContext) { 32 | if (!telemetryKey) { 33 | return log('Telemetry key is not set, skipping telemetry setup'); 34 | } 35 | 36 | if (!reporter) { 37 | reporter = new TelemetryReporter(telemetryKey); 38 | context.subscriptions.push(reporter); 39 | } 40 | 41 | return reporter; 42 | } 43 | 44 | export function featureTelemetry( 45 | feature: 'command' | 'debugger', 46 | command: string, 47 | properties?: TelemetryEventProperties, 48 | measurements?: TelemetryEventMeasurements 49 | ) { 50 | return reporter?.sendTelemetryEvent(`${feature}/${command}`, properties, measurements); 51 | } 52 | -------------------------------------------------------------------------------- /src/utils/vscode.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | 3 | import { debounce } from './debounce'; 4 | import { ExpoProjectCache } from '../expo/project'; 5 | 6 | /** 7 | * Perform an asynchronous task with an "improvised" canellation token. 8 | * When cancelled, the task is either not started or the results aren't result. 9 | */ 10 | export async function withCancelToken( 11 | token: vscode.CancellationToken, 12 | action: (token: vscode.CancellationToken) => Thenable 13 | ) { 14 | if (token.isCancellationRequested) return null; 15 | const result = await action(token); 16 | return token.isCancellationRequested ? null : result; 17 | } 18 | 19 | export abstract class ExpoLinkProvider implements vscode.DocumentLinkProvider { 20 | constructor( 21 | { subscriptions }: vscode.ExtensionContext, 22 | protected projects: ExpoProjectCache, 23 | selector: vscode.DocumentSelector 24 | ) { 25 | subscriptions.push(vscode.languages.registerDocumentLinkProvider(selector, this)); 26 | } 27 | 28 | abstract provideDocumentLinks( 29 | document: vscode.TextDocument, 30 | token: vscode.CancellationToken 31 | ): vscode.ProviderResult; 32 | } 33 | 34 | export abstract class ExpoDiagnosticsProvider { 35 | protected diagnostics: vscode.DiagnosticCollection; 36 | 37 | constructor( 38 | { subscriptions }: vscode.ExtensionContext, 39 | protected projects: ExpoProjectCache, 40 | private selector: vscode.DocumentSelector, 41 | diagnosticsName?: string 42 | ) { 43 | this.diagnostics = vscode.languages.createDiagnosticCollection(diagnosticsName); 44 | subscriptions.push(this.diagnostics); 45 | this.listenToEvents(subscriptions); 46 | } 47 | 48 | protected listenToEvents(subscriptions: vscode.Disposable[]) { 49 | // Listen to active editor changes that should trigger a new diagnosis 50 | subscriptions.push( 51 | vscode.window.onDidChangeActiveTextEditor((editor) => this.diagnose(editor?.document)) 52 | ); 53 | 54 | // Listen to save events that should trigger a new diagnosis 55 | subscriptions.push( 56 | vscode.workspace.onDidSaveTextDocument((document) => this.diagnose(document)) 57 | ); 58 | 59 | // Listen to dirty documents of change events that should trigger a new diagnosis, after some delay 60 | subscriptions.push( 61 | vscode.workspace.onDidChangeTextDocument((document) => 62 | this.debouncedDiagnose(document.document) 63 | ) 64 | ); 65 | } 66 | 67 | public debouncedDiagnose = debounce(this.diagnose.bind(this)); 68 | 69 | public async diagnose(document?: vscode.TextDocument) { 70 | this.debouncedDiagnose.cancel(); 71 | 72 | if (document && vscode.languages.match(this.selector, document)) { 73 | this.diagnostics.set(document.uri, await this.provideDiagnostics(document)); 74 | } else if (document) { 75 | this.diagnostics.delete(document.uri); 76 | } 77 | } 78 | 79 | public abstract provideDiagnostics( 80 | document: vscode.TextDocument 81 | ): vscode.Diagnostic[] | Promise; 82 | } 83 | 84 | export abstract class ExpoCompletionsProvider implements vscode.CompletionItemProvider { 85 | constructor( 86 | { subscriptions }: vscode.ExtensionContext, 87 | protected projects: ExpoProjectCache, 88 | selector: vscode.DocumentSelector, 89 | triggerCharacters: string[] 90 | ) { 91 | subscriptions.push( 92 | vscode.languages.registerCompletionItemProvider(selector, this, ...triggerCharacters) 93 | ); 94 | } 95 | 96 | abstract provideCompletionItems( 97 | document: vscode.TextDocument, 98 | position: vscode.Position, 99 | token: vscode.CancellationToken, 100 | context: vscode.CompletionContext 101 | ): vscode.ProviderResult>; 102 | } 103 | -------------------------------------------------------------------------------- /test/fixture/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Turn off the parent workspace prompt 3 | "git.openRepositoryInParentFolders": "never", 4 | // Never save or remember the opened/edited files 5 | "files.hotExit": "off" 6 | } 7 | -------------------------------------------------------------------------------- /test/fixture/debugging/.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | 11 | # Native 12 | *.orig.* 13 | *.jks 14 | *.p8 15 | *.p12 16 | *.key 17 | *.mobileprovision 18 | 19 | # Metro 20 | .metro-health-check* 21 | 22 | # debug 23 | npm-debug.* 24 | yarn-debug.* 25 | yarn-error.* 26 | 27 | # macOS 28 | .DS_Store 29 | *.pem 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | -------------------------------------------------------------------------------- /test/fixture/debugging/App.js: -------------------------------------------------------------------------------- 1 | import { StatusBar } from 'expo-status-bar'; 2 | import { Button, StyleSheet, Text, View } from 'react-native'; 3 | 4 | function onButtonPress() { 5 | // Breakpoints should work here 6 | const hello = 'world'; 7 | console.log('This should be visible in Vscodes debug console', { hello }); 8 | return true; 9 | } 10 | 11 | export function App() { 12 | return ( 13 | 14 | Open up App.js to start working on your app! 15 |