From 3cc442a2e9c83de5269e3d484db9eeddcb31fa1f Mon Sep 17 00:00:00 2001 From: Cedric van Putten Date: Sun, 10 Mar 2024 13:38:29 +0100 Subject: [PATCH] test: add tests for debugging (#255) * test: add tests for debugging quick start command * test: add tests for debugging session * test: add test for device selection when debugging multiple devices * test: replace `stubFetch` with actual response `node-fetch` gets bundled in production builds, so we cant stub that in e2e tests. * chore: add single test run for production builds * docs: add missing `disposedSpy` dockblock --- .vscode/launch.json | 23 +++++ package-lock.json | 61 +++++++++++- package.json | 6 +- src/__tests__/expoDebuggers.e2e.ts | 150 +++++++++++++++++++++++++++++ src/__tests__/utils/debugging.ts | 91 +++++++++++++++++ src/__tests__/utils/fetch.ts | 11 +-- src/__tests__/utils/sinon.ts | 23 +++++ src/__tests__/utils/spawn.ts | 8 +- src/expo/__tests__/bundler.test.ts | 22 +---- src/expoDebuggers.ts | 2 +- test/mocha/vscode-runner.ts | 1 + 11 files changed, 364 insertions(+), 34 deletions(-) create mode 100644 src/__tests__/expoDebuggers.e2e.ts create mode 100644 src/__tests__/utils/debugging.ts create mode 100644 src/__tests__/utils/sinon.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 91028fa..840bce4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -108,6 +108,29 @@ ], "preLaunchTask": "launch-test-production" }, + { + "name": "Test: current file (production)", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "${workspaceFolder}/test/fixture", + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionTestsPath=${workspaceFolder}/out/test/mocha/vscode-runner", + "--disable-extensions" + ], + "env": { + "CI": "true", // Force snapshots into "no update" mode + "UPDATE_SNAPSHOT": null, // Force snapshots into "no update" mode + "VSCODE_EXPO_DEBUG": "vscode-expo*", + "VSCODE_EXPO_TELEMETRY_KEY": null, + "VSCODE_EXPO_TEST_PATTERN": "${fileBasenameNoExtension}" + }, + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ], + "preLaunchTask": "launch-test-production" + }, { "name": "Test (no-build)", "type": "extensionHost", diff --git a/package-lock.json b/package-lock.json index 12a4707..4c6bae7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-expo-tools", - "version": "1.3.0", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-expo-tools", - "version": "1.3.0", + "version": "1.4.1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -29,6 +29,7 @@ "@sucrase/webpack-loader": "^2.0.0", "@tsconfig/node18": "^18.2.1", "@types/chai": "^4.3.5", + "@types/chai-as-promised": "^7.1.8", "@types/chai-subset": "^1.3.3", "@types/debug": "^4.1.7", "@types/mocha": "^10.0.1", @@ -38,10 +39,12 @@ "@types/sinon": "^10.0.16", "@types/sinon-chai": "^3.2.12", "@types/vscode": "^1.86.0", + "@types/ws": "^8.5.10", "@vscode/test-electron": "^2.3.9", "@vscode/vsce": "^2.21.0", "arg": "^5.0.2", "chai": "^4.3.8", + "chai-as-promised": "^7.1.1", "chai-subset": "^1.6.0", "conventional-changelog-conventionalcommits": "^6.1.0", "eslint": "^8.48.0", @@ -65,7 +68,8 @@ "sucrase": "^3.20.3", "typescript": "^5.2.2", "webpack": "^5.76.0", - "webpack-cli": "^5.1.4" + "webpack-cli": "^5.1.4", + "ws": "^8.16.0" }, "engines": { "vscode": "^1.86.0" @@ -3182,6 +3186,15 @@ "integrity": "sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==", "dev": true }, + "node_modules/@types/chai-as-promised": { + "version": "7.1.8", + "resolved": "https://registry.npmjs.org/@types/chai-as-promised/-/chai-as-promised-7.1.8.tgz", + "integrity": "sha512-ThlRVIJhr69FLlh6IctTXFkmhtP3NpMZ2QGq69StYLyKZFp/HOp1VdKZj7RvfNWYYcJ1xlbLGLLWj1UvP5u/Gw==", + "dev": true, + "dependencies": { + "@types/chai": "*" + } + }, "node_modules/@types/chai-subset": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.3.tgz", @@ -3371,6 +3384,15 @@ "integrity": "sha512-y3yYJV2esWr8LNjp3VNbSMWG7Y43jC8pCldG8YwiHGAQbsymkkMMt0aDT1xZIOFM2eFcNiUc+dJMx1+Z0UT8fg==", "dev": true }, + "node_modules/@types/ws": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.24", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", @@ -4811,6 +4833,18 @@ "node": ">=4" } }, + "node_modules/chai-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", + "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", + "dev": true, + "dependencies": { + "check-error": "^1.0.2" + }, + "peerDependencies": { + "chai": ">= 2.1.2 < 5" + } + }, "node_modules/chai-subset": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/chai-subset/-/chai-subset-1.6.0.tgz", @@ -17320,6 +17354,27 @@ "signal-exit": "^3.0.2" } }, + "node_modules/ws": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xcode": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", diff --git a/package.json b/package.json index 7d34047..fcf2f84 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@sucrase/webpack-loader": "^2.0.0", "@tsconfig/node18": "^18.2.1", "@types/chai": "^4.3.5", + "@types/chai-as-promised": "^7.1.8", "@types/chai-subset": "^1.3.3", "@types/debug": "^4.1.7", "@types/mocha": "^10.0.1", @@ -66,10 +67,12 @@ "@types/sinon": "^10.0.16", "@types/sinon-chai": "^3.2.12", "@types/vscode": "^1.86.0", + "@types/ws": "^8.5.10", "@vscode/test-electron": "^2.3.9", "@vscode/vsce": "^2.21.0", "arg": "^5.0.2", "chai": "^4.3.8", + "chai-as-promised": "^7.1.1", "chai-subset": "^1.6.0", "conventional-changelog-conventionalcommits": "^6.1.0", "eslint": "^8.48.0", @@ -93,7 +96,8 @@ "sucrase": "^3.20.3", "typescript": "^5.2.2", "webpack": "^5.76.0", - "webpack-cli": "^5.1.4" + "webpack-cli": "^5.1.4", + "ws": "^8.16.0" }, "publisher": "expo", "icon": "images/logo-marketplace.png", diff --git a/src/__tests__/expoDebuggers.e2e.ts b/src/__tests__/expoDebuggers.e2e.ts new file mode 100644 index 0000000..51f3fab --- /dev/null +++ b/src/__tests__/expoDebuggers.e2e.ts @@ -0,0 +1,150 @@ +import { expect } from 'chai'; +import { match } from 'sinon'; +import vscode from 'vscode'; + +import { mockDevice, stubInspectorProxy } from './utils/debugging'; +import { disposedSpy, disposedStub } from './utils/sinon'; +import { getWorkspaceUri } from './utils/vscode'; + +describe('ExpoDebuggersProvider', () => { + describe('command', () => { + it('prompts for project root if no workspace is found', async () => { + using input = disposedStub(vscode.window, 'showInputBox'); + await vscode.commands.executeCommand('expo.debug.start'); + expect(input).to.be.calledWith(match({ prompt: 'Enter the path to the Expo project' })); + }); + + it('fails when project root doesnt exist', async () => { + using input = disposedStub(vscode.window, 'showInputBox'); + using error = disposedStub(vscode.window, 'showErrorMessage'); + + input.returns(Promise.resolve('./')); + await vscode.commands.executeCommand('expo.debug.start'); + + expect(error).to.be.calledWith(match('Could not find any Expo projects in')); + }); + + it('aborts when no path was entered', async () => { + using input = disposedStub(vscode.window, 'showInputBox'); + using debug = disposedStub(vscode.debug, 'startDebugging'); + + input.returns(Promise.resolve('')); + await vscode.commands.executeCommand('expo.debug.start'); + + expect(debug).not.to.be.called; + }); + + it('starts debugging session when project is found', async () => { + using input = disposedStub(vscode.window, 'showInputBox'); + using debug = disposedStub(vscode.debug, 'startDebugging'); + + input.returns(Promise.resolve('./debugging')); + await vscode.commands.executeCommand('expo.debug.start'); + + expect(debug).to.be.calledWith( + undefined, + match({ + type: 'expo', + request: 'attach', + name: 'Inspect Expo app', + projectRoot: getWorkspaceUri('debugging').fsPath, + }) + ); + }); + }); + + describe('debugger', () => { + it('fails when using "type: launch"', async () => { + const action = () => + vscode.debug.startDebugging(undefined, { + type: 'expo', + request: 'launch', + name: 'Inspect Expo app', + projectRoot: getWorkspaceUri('debugging').fsPath, + }); + + await expect(action()).to.eventually.rejected; + }); + + it('starts debug session with device url', async () => { + await using proxy = await stubInspectorProxy(); + using upgrade = disposedSpy(proxy.sockets, 'handleUpgrade'); + const device = mockDevice({ deviceName: 'Fake target' }, proxy); + + // Return the devices when requested, without stubbing fetch. + // Note, stubbing fetch doesn't work when testing production build as `node-fetch` gets bundled. + proxy.app.callsFake((req, res) => { + if (req.url === '/json/list') return res.end(JSON.stringify([device])); + throw new Error('Invalid request: ' + req.url); + }); + + await vscode.debug.startDebugging(undefined, { + type: 'expo', + request: 'attach', + name: 'Inspect Expo app', + bundlerHost: proxy.serverUrl.hostname, + bundlerPort: proxy.serverUrl.port, + projectRoot: getWorkspaceUri('debugging').fsPath, + }); + + await vscode.commands.executeCommand('workbench.action.debug.stop'); + + expect(proxy.app).to.be.called; + expect(upgrade).to.be.called; + + // Ensure the debug URL is correct, it should look like: + // /inspector/debug?device=DEVICE_ID&page=PAGE_ID&userAgent=USER_AGENT + const request = upgrade.getCall(1).args[0]; + expect(request.url).to.include('/inspector/debug'); + expect(request.url).to.include(`?device=${device.id}`); + expect(request.url).to.include(`&page=`); + expect(request.url).to.include(`&userAgent=vscode`); + }); + + it('starts debug session with user-picked device url', async () => { + await using proxy = await stubInspectorProxy(); + using upgrade = disposedSpy(proxy.sockets, 'handleUpgrade'); + using quickPick = disposedStub(vscode.window, 'showQuickPick'); + const devices = [ + mockDevice({ deviceName: 'Another target' }, proxy), + mockDevice({ deviceName: 'Fake target', id: 'the-one' }, proxy), + mockDevice({ deviceName: 'Yet another target' }, proxy), + ]; + + // Return the devices when requested, without stubbing fetch. + // Note, stubbing fetch doesn't work when testing production build as `node-fetch` gets bundled. + proxy.app.callsFake((req, res) => { + if (req.url === '/json/list') return res.end(JSON.stringify(devices)); + throw new Error('Invalid request: ' + req.url); + }); + + // @ts-expect-error - We are using string return values, not quickpick items + quickPick.returns(Promise.resolve('Fake target')); + + await vscode.debug.startDebugging(undefined, { + type: 'expo', + request: 'attach', + name: 'Inspect Expo app', + bundlerHost: proxy.serverUrl.hostname, + bundlerPort: proxy.serverUrl.port, + projectRoot: getWorkspaceUri('debugging').fsPath, + }); + + await vscode.commands.executeCommand('workbench.action.debug.stop'); + + expect(proxy.app).to.be.called; + expect(upgrade).to.be.called; + expect(quickPick).to.be.calledWith( + match.array.deepEquals(['Another target', 'Fake target', 'Yet another target']), + match({ + placeHolder: 'Select a device to debug', + }) + ); + + // Ensure the debug URL is correct, it should use the "Fake target" device ID + const request = upgrade.getCall(1).args[0]; + expect(request.url).to.include('/inspector/debug'); + expect(request.url).to.include(`?device=the-one`); + }); + }); +}); diff --git a/src/__tests__/utils/debugging.ts b/src/__tests__/utils/debugging.ts new file mode 100644 index 0000000..32b85b1 --- /dev/null +++ b/src/__tests__/utils/debugging.ts @@ -0,0 +1,91 @@ +import assert from 'assert'; +import http from 'http'; +import { stub, type SinonStub } from 'sinon'; +import { URL } from 'url'; +import { WebSocketServer } from 'ws'; + +import { type InspectableDevice } from '../../expo/bundler'; + +type StubInspectorProxyApp = http.RequestListener< + typeof http.IncomingMessage, + typeof http.ServerResponse +>; + +export type StubInspectorProxy = { + app: SinonStub, ReturnType>; + sockets: WebSocketServer; + server: http.Server; + serverUrl: URL; +}; + +/** Create and start a fake inspector proxy server */ +export async function stubInspectorProxy() { + const app: StubInspectorProxy['app'] = stub(); + const server = http.createServer(app); + const sockets = new WebSocketServer({ server }); + + return new Promise((resolve, reject) => { + server.once('error', reject); + + server.on('upgrade', (request, socket, head) => { + sockets.handleUpgrade(request, socket, head, (ws) => { + ws.on('error', console.error); + sockets.emit('connection', ws); + }); + }); + + server.listen(() => { + server.off('error', reject); + + const serverUrl = new URL(getServerAddress(server)); + + resolve({ + app, + sockets, + server, + serverUrl, + async [Symbol.asyncDispose]() { + await new Promise((resolve) => sockets.close(resolve)); + await new Promise((resolve) => server.close(resolve)); + }, + }); + }); + }); +} + +function getServerAddress(server: http.Server) { + const address = server.address(); + assert(address && typeof address === 'object' && address.port, 'Server is not listening'); + return `http://localhost:${address.port}`; +} + +export function mockDevice( + properties: Partial = {}, + proxy?: Pick +): InspectableDevice { + const device: InspectableDevice = { + id: 'device1', + description: 'description1', + title: 'React Native Experimental (Improved Chrome Reloads)', // Magic title, do not change + faviconUrl: 'https://example.com/favicon.ico', + devtoolsFrontendUrl: 'devtools://devtools/example', + type: 'node', + webSocketDebuggerUrl: 'ws://example.com', + vm: 'hermes', + deviceName: 'iPhone 15 Pro', + ...properties, + }; + + if (proxy?.serverUrl) { + const url = new URL(proxy.serverUrl.toString()); + + url.protocol = 'ws:'; + url.pathname = '/inspector/debug'; + url.searchParams.set('device', device.id); + url.searchParams.set('page', '1'); + + device.webSocketDebuggerUrl = url.toString(); + } + + return device; +} diff --git a/src/__tests__/utils/fetch.ts b/src/__tests__/utils/fetch.ts index 1b8d224..c4ac8b8 100644 --- a/src/__tests__/utils/fetch.ts +++ b/src/__tests__/utils/fetch.ts @@ -1,12 +1,11 @@ import * as nodeFetch from 'node-fetch'; -import { stub, type SinonStub } from 'sinon'; +import { type SinonStub } from 'sinon'; + +import { disposedStub } from './sinon'; /** Mock fetch with a default empty device list response */ -export function stubFetch() { - const fetch = withFetchResponse(stub(nodeFetch, 'default'), []); - // @ts-expect-error - fetch[Symbol.dispose] = () => fetch.restore(); - return fetch as Disposable & typeof fetch; +export function stubFetch(data: any = []) { + return withFetchResponse(disposedStub(nodeFetch, 'default'), data); } /** Add a valid response to the stubbed fetch, returning the response as json data */ diff --git a/src/__tests__/utils/sinon.ts b/src/__tests__/utils/sinon.ts new file mode 100644 index 0000000..bbe8ae2 --- /dev/null +++ b/src/__tests__/utils/sinon.ts @@ -0,0 +1,23 @@ +import sinon from 'sinon'; + +/** Create an auto-disposable stub that can be created with `using` */ +export function disposedStub any }, P extends keyof T>( + api: T, + method: P +) { + const stub = sinon.stub(api, method); + // @ts-expect-error + stub[Symbol.dispose] = () => stub.restore(); + return stub as sinon.SinonStub, ReturnType> & Disposable; +} + +/** Create an auto-disposable spy, still executing the implementation, that can be created with `using` */ +export function disposedSpy any }, P extends keyof T>( + api: T, + method: P +) { + const spy = sinon.spy(api, method); + // @ts-expect-error + spy[Symbol.dispose] = () => spy.restore(); + return spy as sinon.SinonSpy, ReturnType> & Disposable; +} diff --git a/src/__tests__/utils/spawn.ts b/src/__tests__/utils/spawn.ts index f4403b2..1b652be 100644 --- a/src/__tests__/utils/spawn.ts +++ b/src/__tests__/utils/spawn.ts @@ -1,13 +1,11 @@ -import { stub, type SinonStub } from 'sinon'; +import { type SinonStub } from 'sinon'; +import { disposedStub } from './sinon'; import * as spawn from '../../utils/spawn'; /** Mock spawn with a default empty device list response */ export function stubSpawn(result?: Partial) { - const spawnStub = withSpawnResult(stub(spawn, 'spawn'), result); - // @ts-expect-error - spawnStub[Symbol.dispose] = () => spawnStub.restore(); - return spawnStub as Disposable & typeof spawnStub; + return withSpawnResult(disposedStub(spawn, 'spawn'), result); } export function withSpawnResult( diff --git a/src/expo/__tests__/bundler.test.ts b/src/expo/__tests__/bundler.test.ts index 729bdb9..3fa5b64 100644 --- a/src/expo/__tests__/bundler.test.ts +++ b/src/expo/__tests__/bundler.test.ts @@ -1,5 +1,6 @@ import { expect } from 'chai'; +import { mockDevice } from '../../__tests__/utils/debugging'; import { stubFetch, withFetchError, withFetchResponse } from '../../__tests__/utils/fetch'; import { type InspectableDevice, @@ -19,7 +20,7 @@ describe('fetchDevicesToInspect', () => { }); it('filters by predefined page title', async () => { - using _fetch = withFetchResponse(stubFetch(), [ + using _fetch = stubFetch([ mockDevice({ deviceName: 'iPhone 15 Pro', title: 'filter' }), mockDevice({ deviceName: 'iPhone 15 Pro' }), ]); @@ -31,7 +32,7 @@ describe('fetchDevicesToInspect', () => { }); it('filters by device name for React Native <0.73', async () => { - using _fetch = withFetchResponse(stubFetch(), [ + using _fetch = stubFetch([ mockDevice({ deviceName: 'iPhone 15 Pro' }), mockDevice({ deviceName: 'iPhone 15 Pro' }), ]); @@ -45,7 +46,7 @@ describe('fetchDevicesToInspect', () => { it('filters by logical device identifier for React Native +0.74', async () => { const reactNative: InspectableDevice['reactNative'] = { logicalDeviceId: '1337' }; - using _fetch = withFetchResponse(stubFetch(), [ + using _fetch = stubFetch([ mockDevice({ deviceName: 'iPhone 16 Pro', reactNative }), mockDevice({ deviceName: 'iPhone 15 Pro', reactNative }), ]); @@ -118,18 +119,3 @@ describe('findDeviceByName', () => { expect(findDeviceByName(devices, 'iPhone 15 Pro')).to.equal(target); }); }); - -function mockDevice(device: Partial): InspectableDevice { - return { - id: 'device1', - description: 'description1', - title: 'React Native Experimental (Improved Chrome Reloads)', // Magic title, do not change - faviconUrl: 'https://example.com/favicon.ico', - devtoolsFrontendUrl: 'devtools://devtools/example', - type: 'node', - webSocketDebuggerUrl: 'ws://example.com', - vm: 'hermes', - deviceName: 'iPhone 15 Pro', - ...device, - }; -} diff --git a/src/expoDebuggers.ts b/src/expoDebuggers.ts index 9159929..cce5071 100644 --- a/src/expoDebuggers.ts +++ b/src/expoDebuggers.ts @@ -22,7 +22,7 @@ const DEBUG_TYPE = 'expo'; const DEBUG_COMMAND = 'expo.debug.start'; const DEBUG_USER_AGENT = `vscode/${vscode.version} ${process.env.EXTENSION_NAME}/${process.env.EXTENSION_VERSION}`; -interface ExpoDebugConfig extends vscode.DebugConfiguration { +export interface ExpoDebugConfig extends vscode.DebugConfiguration { projectRoot: string; bundlerHost?: string; bundlerPort?: string; diff --git a/test/mocha/vscode-runner.ts b/test/mocha/vscode-runner.ts index f13553b..e775543 100644 --- a/test/mocha/vscode-runner.ts +++ b/test/mocha/vscode-runner.ts @@ -34,6 +34,7 @@ export async function run() { // Configure Chai extensions Chai.use(require('chai-subset')); + Chai.use(require('chai-as-promised')); Chai.use(require('sinon-chai')); Chai.use( require('mocha-chai-jest-snapshot').jestSnapshotPlugin({