diff --git a/.github/workflows/PullRequest.yml b/.github/workflows/PullRequest.yml index 713e6fdc..be2ebcef 100644 --- a/.github/workflows/PullRequest.yml +++ b/.github/workflows/PullRequest.yml @@ -32,10 +32,17 @@ jobs: with: node-version: 14.x - - name: Build and test + - name: Install dependencies + run: npm i -g npm + + - name: Build and unit test run: | - npm i -g npm npm ci npm run dist env: AZ_DevOps_Read_PAT: ${{ secrets.AZ_DevOps_Read_PAT }} + + - name: Run integration tests + uses: GabrielBB/xvfb-action@v1.6 + with: + run: npm run test-integration diff --git a/.vscode/launch.json b/.vscode/launch.json index d0873f4e..857baba4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,13 +2,9 @@ "version": "0.2.0", "configurations": [ { - "args": [ - "--extensionDevelopmentPath=${workspaceFolder}" - ], + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], "name": "Launch VSCode Extension", - "outFiles": [ - "${workspaceFolder}/dist/**/*.js" - ], + "outFiles": ["${workspaceFolder}/dist/**/*.js"], "sourceMaps": true, "runtimeExecutable": "${execPath}", "preLaunchTask": "gulp: compile", @@ -16,14 +12,14 @@ "type": "pwa-extensionHost", "trace": true }, - { - "type": "node", - "request": "attach", - "name": "Attach to Server", - "port": 6009, - "restart": true, - "outFiles": ["${workspaceFolder}/dist/**/*.js"] - }, + { + "type": "node", + "request": "attach", + "name": "Attach to Server", + "port": 6009, + "restart": true, + "outFiles": ["${workspaceFolder}/dist/**/*.js"] + }, { "type": "node", "request": "launch", @@ -31,13 +27,27 @@ "program": "${workspaceFolder}/node_modules/mocha/bin/mocha", "cwd": "${workspaceFolder}", "args": [ - "--require", "ts-node/register", - "-u", "bdd", - "--timeout", "999999", + "--require", + "ts-node/register", + "-u", + "bdd", + "--timeout", + "999999", "src/client/test/unit/**/*.ts" ], "internalConsoleOptions": "neverOpen" }, + { + "name": "Integration Tests", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionTestsPath=${workspaceFolder}/out/debugger/test/unit/index" + ], + "outFiles": ["${workspaceFolder}/out/debugger/test/**/*.js"] + }, { "type": "node", "request": "launch", @@ -45,9 +55,12 @@ "program": "${workspaceFolder}/node_modules/mocha/bin/mocha", "cwd": "${workspaceFolder}", "args": [ - "--require", "ts-node/register", - "-u", "bdd", - "--timeout", "999999", + "--require", + "ts-node/register", + "-u", + "bdd", + "--timeout", + "999999", "${file}" ], "internalConsoleOptions": "neverOpen" @@ -58,9 +71,7 @@ "name": "Launch current TypeScript src", "program": "${file}", "cwd": "${workspaceFolder}", - "outFiles": [ - "${workspaceFolder}/out/**/*.js" - ], + "outFiles": ["${workspaceFolder}/out/**/*.js"], "preLaunchTask": "gulp: compile", "sourceMaps": true }, @@ -70,32 +81,26 @@ "name": "Gulp task", "program": "${workspaceFolder}/node_modules/gulp/bin/gulp.js", "cwd": "${workspaceFolder}", - "args": [ - "recompile" - ], - "skipFiles": [ - "/**" - ] + "args": ["recompile"], + "skipFiles": ["/**"] }, { - "name": "Run Web Extension in VS Code", - "type": "pwa-extensionHost", - "debugWebWorkerHost": true, - "request": "launch", - "args": [ - "--extensionDevelopmentPath=${workspaceFolder}", - "--extensionDevelopmentKind=web" - ], - "outFiles": [ - "${workspaceFolder}/dist/web/**/*.js" - ], - "preLaunchTask": "gulp: compileWeb" - } + "name": "Run Web Extension in VS Code", + "type": "pwa-extensionHost", + "debugWebWorkerHost": true, + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionDevelopmentKind=web" + ], + "outFiles": ["${workspaceFolder}/dist/web/**/*.js"], + "preLaunchTask": "gulp: compileWeb" + } ], "compounds": [ - { - "name": "Client + Server", - "configurations": ["Launch VSCode Extension", "Attach to Server"] - } - ] + { + "name": "Client + Server", + "configurations": ["Launch VSCode Extension", "Attach to Server"] + } + ] } diff --git a/gulpfile.js b/gulpfile.js index 9ffb6e2e..16ba9d79 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -13,6 +13,7 @@ const exec = util.promisify(require('child_process').exec); const gulp = require('gulp'); const filter = require('gulp-filter'); const eslint = require('gulp-eslint'); +const gulpTs = require("gulp-typescript"); const replace = require('gulp-replace'); const mocha = require('gulp-mocha'); const moment = require('moment'); @@ -144,31 +145,70 @@ function lint() { .pipe(eslint.failAfterError()); } -function test() { +function testUnitTests() { return gulp - .src(['src/server/test/unit/**/*.ts','src/client/test/unit/**/*.ts'], { read: false }) - .pipe(mocha({ - require: [ "ts-node/register" ], - ui: 'bdd' - })); + .src( + [ + "src/server/test/unit/**/*.ts", + "src/client/test/unit/**/*.ts", + "src/debugger/test/unit/**/*.ts", + ], + { + read: false, + } + ) + .pipe( + mocha({ + require: ["ts-node/register"], + ui: "bdd", + }) + ); } +/** + * Compiles the integration tests and transpiles the results to /out + */ +function compileIntegrationTests() { + const tsProject = gulpTs.createProject("tsconfig.json", { + // to test puppeteer we need "dom". + // since "dom" overlaps with "webworker" we need to overwrite the lib property. + // This is a known ts issue (bot being able to have both webworker and dom): https://github.com/microsoft/TypeScript/issues/20595 + lib: ["es2019", "dom", "dom.iterable"], + }); + return gulp.src(["src/**/*.ts"]).pipe(tsProject()).pipe(gulp.dest("out")); +} + +/** + * Tests the debugger integration tests after transpiling the source files to /out + */ +const testDebugger = gulp.series(compileIntegrationTests, async () => { + const testRunner = require("./out/debugger/test/runTest"); + await testRunner.main(); +}); + function testWeb() { - return gulp - .src(['src/web/client/test/unit/**/*.ts'], { read: false }) - .pipe(mocha({ - require: [ "ts-node/register" ], - ui: 'bdd' - })); + return gulp.src(["src/web/client/test/unit/**/*.ts"], { read: false }).pipe( + mocha({ + require: ["ts-node/register"], + ui: "bdd", + }) + ); } +// unit tests without special test runner +const test = gulp.series(testUnitTests, testWeb); + +// tests that require vscode-electron (which requires a display or xvfb) +const testInt = gulp.series(testDebugger); + async function packageVsix() { fs.emptyDirSync(packagedir); return vsce.createVSIX({ packagePath: packagedir, - }) + }); } + async function git(args) { args.unshift('git'); const {stdout, stderr } = await exec(args.join(' ')); @@ -257,10 +297,8 @@ const dist = gulp.series( recompile, packageVsix, lint, - test, - testWeb + test ); - const translationExtensionName = "vscode-powerplatform"; // Extract all the localizable strings from TS and package.nls.json, and package into @@ -288,7 +326,7 @@ const languages = [ { id: "it", folderName: "ita" }, { id: "ja", folderName: "jpn" }, { id: "ko", folderName: "kor" }, - { id: "pt-BR", folderName: "ptb"}, + { id: "pt-BR", folderName: "ptb" }, { id: "ru", folderName: "rus" }, { id: "tr", folderName: "trk" }, { id: "zh-CN", folderName: "chs" }, @@ -346,6 +384,8 @@ exports.snapshot = snapshot; exports.lint = lint; exports.test = test; exports.testWeb = testWeb; +exports.compileIntegrationTests = compileIntegrationTests; +exports.testInt = testInt; exports.package = packageVsix; exports.ci = dist; exports.dist = dist; diff --git a/package-lock.json b/package-lock.json index fd1d9e0c..25b49632 100644 --- a/package-lock.json +++ b/package-lock.json @@ -305,6 +305,41 @@ "fastq": "^1.6.0" } }, + "@sinonjs/commons": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", + "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz", + "integrity": "sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0" + } + }, + "@sinonjs/samsam": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-6.1.1.tgz", + "integrity": "sha512-cZ7rKJTLiE7u7Wi/v9Hc2fs3Ucc3jrWeMgPHbbTCeVAB2S0wOBbYlkJVeNSL04i7fdhT8wIbDq1zhC/PXTD2SA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", + "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", + "dev": true + }, "@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -417,9 +452,41 @@ "dev": true }, "@types/node": { - "version": "14.18.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.21.tgz", - "integrity": "sha512-x5W9s+8P4XteaxT/jKF0PSb7XEvo5VmqEWgsMlyeY4ZlLK8I6aH6g5TPPyDlLAep+GYf4kefb7HFyc7PAO3m+Q==", + "version": "14.17.32", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.32.tgz", + "integrity": "sha512-JcII3D5/OapPGx+eJ+Ik1SQGyt6WvuqdRfh9jUwL6/iHGjmyOriBDciBUu7lEIBTL2ijxwrR70WUnw5AEDmFvQ==" + }, + "@types/puppeteer": { + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/@types/puppeteer/-/puppeteer-5.4.6.tgz", + "integrity": "sha512-98Kghehs7+/GD9b56qryhqdqVCXUTbetTv3PlvDnmFRTHQH0j9DIp1f7rkAW3BAj4U3yoeSEQnKgdW8bDq0Y0Q==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/puppeteer-core": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@types/puppeteer-core/-/puppeteer-core-5.4.0.tgz", + "integrity": "sha512-yqRPuv4EFcSkTyin6Yy17pN6Qz2vwVwTCJIDYMXbE3j8vTPhv0nCQlZOl5xfi0WHUkqvQsjAR8hAfjeMCoetwg==", + "dev": true, + "requires": { + "@types/puppeteer": "*" + } + }, + "@types/sinon": { + "version": "10.0.12", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.12.tgz", + "integrity": "sha512-uWf4QJ4oky/GckJ1MYQxU52cgVDcXwBhDkpvLbi4EKoLPqLE4MOH6T/ttM33l3hi0oZ882G6oIzWv/oupRYSxQ==", + "dev": true, + "requires": { + "@types/sinonjs__fake-timers": "*" + } + }, + "@types/sinonjs__fake-timers": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz", + "integrity": "sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==", "dev": true }, "@types/unzip-stream": { @@ -470,6 +537,15 @@ "integrity": "sha512-eHSaNYEyxRA5IAG0Ym/yCyf86niZUIF/TpWKofQI/CVfh5HsMEUyfE2kwFxha4ow0s5g0LfISQxpDKjbRDrizw==", "dev": true }, + "@types/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==", + "optional": true, + "requires": { + "@types/node": "*" + } + }, "@typescript-eslint/eslint-plugin": { "version": "5.29.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.29.0.tgz", @@ -576,6 +652,37 @@ "resolved": "https://registry.npmjs.org/@vscode/extension-telemetry/-/extension-telemetry-0.4.10.tgz", "integrity": "sha512-XgyUoWWRQExTmd9DynIIUQo1NPex/zIeetdUAXeBjVuW9ioojM1TcDaSqOa/5QLC7lx+oEXwSU1r0XSBgzyz6w==" }, + "@vscode/test-electron": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.1.5.tgz", + "integrity": "sha512-O/ioqFpV+RvKbRykX2ItYPnbcZ4Hk5V0rY4uhQjQTLhGL9WZUvS7exzuYQCCI+ilSqJpctvxq2llTfGXf9UnnA==", + "dev": true, + "requires": { + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "rimraf": "^3.0.2", + "unzipper": "^0.10.11" + }, + "dependencies": { + "@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "dev": true + }, + "http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, + "requires": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + } + } + } + }, "@vscode/test-web": { "version": "0.0.24", "resolved": "https://registry.npmjs.org/@vscode/test-web/-/test-web-0.0.24.tgz", @@ -743,12 +850,6 @@ "@xtuc/long": "4.2.2" } }, - "@webpack-cli/configtest": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", - "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", - "dev": true - }, "@webpack-cli/info": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", @@ -758,12 +859,6 @@ "envinfo": "^7.7.3" } }, - "@webpack-cli/serve": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", - "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", - "dev": true - }, "@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -792,12 +887,6 @@ "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==", "dev": true }, - "acorn-import-assertions": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", - "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", - "dev": true - }, "acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -814,7 +903,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, "requires": { "debug": "4" } @@ -1305,8 +1393,7 @@ "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, "basic-auth": { "version": "2.0.1", @@ -1354,16 +1441,6 @@ "file-uri-to-path": "1.0.0" } }, - "bl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", - "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", - "dev": true, - "requires": { - "readable-stream": "^2.3.5", - "safe-buffer": "^5.1.1" - } - }, "bluebird": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", @@ -1425,7 +1502,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "requires": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -1450,8 +1526,7 @@ "buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==" }, "buffer-equal": { "version": "1.0.0", @@ -1482,6 +1557,15 @@ "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==" }, + "bufferutil": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.6.tgz", + "integrity": "sha512-jduaYOYtnio4aIAyc6UbvPCVcgq7nYpVnucyxr6eCYg/Woad9Hf/oxxBRDnGGjPfjUm6j5O/uBWhIu4iLebFaw==", + "optional": true, + "requires": { + "node-gyp-build": "^4.3.0" + } + }, "cache-base": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", @@ -1665,8 +1749,7 @@ "chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" }, "chrome-trace-event": { "version": "1.0.3", @@ -1933,6 +2016,14 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "requires": { + "node-fetch": "2.6.7" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -2218,6 +2309,11 @@ "integrity": "sha512-CwffZFvlJffUg9zZA0uqrjQayUTC8ob94pnr5sFwaVv3IOmkfUHcWH+jXaQK3askE51Cqe8/9Ql/0uXNwqZ8Zg==", "dev": true }, + "devtools-protocol": { + "version": "0.0.1001819", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1001819.tgz", + "integrity": "sha512-G6OsIFnv/rDyxSqBa2lDLR6thp9oJioLsb2Gl+LbQlyoA9/OBAkrTU9jiCcQ8Pnh7z4d6slDiLaogR5hzgJLmQ==" + }, "diff": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", @@ -2360,7 +2456,6 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, "requires": { "once": "^1.4.0" } @@ -2870,6 +2965,36 @@ } } }, + "extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "requires": { + "@types/yauzl": "^2.9.1", + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "dependencies": { + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "requires": { + "pump": "^3.0.0" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + } + } + }, "fancy-log": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.3.3.tgz", @@ -2943,7 +3068,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, "requires": { "pend": "~1.2.0" } @@ -3228,8 +3352,7 @@ "fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" }, "fs-extra": { "version": "9.1.0", @@ -4545,8 +4668,7 @@ "ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" }, "ignore": { "version": "5.2.0", @@ -4963,6 +5085,12 @@ "integrity": "sha512-qpcRocdkUmf+UTNBYx5w6dexX5J31AKK1OmPwH630a83DdVVUIngk55RSAiIGpQyoH0dlr872VHfPjnQnK1qDQ==", "dev": true }, + "just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, "keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -5229,6 +5357,12 @@ "integrity": "sha512-GhrVeweiTD6uTmmn5hV/lzgCQhccwReIVRLHp7LT4SopOjqEZ5BbX8b5WWEtAKasjmy8hR7ZPwsYlxRCku5odg==", "dev": true }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5676,8 +5810,7 @@ "mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" }, "mocha": { "version": "9.2.2", @@ -6142,6 +6275,36 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "nise": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.1.tgz", + "integrity": "sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": ">=5", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "requires": { + "isarray": "0.0.1" + } + } + } + }, "node-abi": { "version": "3.22.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.22.0.tgz", @@ -6161,11 +6324,16 @@ "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dev": true, "requires": { "whatwg-url": "^5.0.0" } }, + "node-gyp-build": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.5.0.tgz", + "integrity": "sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==", + "optional": true + }, "node-releases": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.5.tgz", @@ -6436,8 +6604,7 @@ "p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" }, "parent-module": { "version": "1.0.1", @@ -6543,8 +6710,7 @@ "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" }, "path-is-absolute": { "version": "1.0.1", @@ -6608,8 +6774,7 @@ "pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" }, "picocolors": { "version": "1.0.0", @@ -6648,7 +6813,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, "requires": { "find-up": "^4.0.0" }, @@ -6657,7 +6821,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, "requires": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -6667,7 +6830,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, "requires": { "p-locate": "^4.1.0" } @@ -6676,7 +6838,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, "requires": { "p-try": "^2.0.0" } @@ -6685,7 +6846,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, "requires": { "p-limit": "^2.2.0" } @@ -6814,8 +6974,12 @@ "progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==" + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "prr": { "version": "1.0.1", @@ -6856,6 +7020,44 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", "dev": true }, + "puppeteer-core": { + "version": "14.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-14.4.1.tgz", + "integrity": "sha512-ykeQf8vAFVziQWsGu0AQ782KZgFlcQS/wDwbBJuKyR7zdb+rIKEBLpyeE46KUhbw/TjNyhb7MPFb/qrO9xIs0A==", + "requires": { + "cross-fetch": "3.1.5", + "debug": "4.3.4", + "devtools-protocol": "0.0.1001819", + "extract-zip": "2.0.1", + "https-proxy-agent": "5.0.1", + "pkg-dir": "4.2.0", + "progress": "2.0.3", + "proxy-from-env": "1.1.0", + "rimraf": "3.0.2", + "tar-fs": "2.1.1", + "unbzip2-stream": "1.4.3", + "ws": "8.7.0" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "requires": { + "agent-base": "6", + "debug": "4" + } + } + } + }, "qs": { "version": "6.10.5", "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.5.tgz", @@ -7374,7 +7576,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, "requires": { "glob": "^7.1.3" } @@ -7406,8 +7607,7 @@ "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "safe-regex": { "version": "1.1.0", @@ -7591,6 +7791,20 @@ "simple-concat": "^1.0.0" } }, + "sinon": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-14.0.0.tgz", + "integrity": "sha512-ugA6BFmE+WrJdh0owRZHToLd32Uw3Lxq6E6LtNRU+xTVBefx632h03Q7apXWRsRdZAJ41LB8aUfn2+O4jsDNMw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.8.3", + "@sinonjs/fake-timers": "^9.1.2", + "@sinonjs/samsam": "^6.1.1", + "diff": "^5.0.0", + "nise": "^5.1.1", + "supports-color": "^7.2.0" + } + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -7964,7 +8178,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "requires": { "safe-buffer": "~5.1.0" } @@ -8100,7 +8313,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", - "dev": true, "requires": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -8112,7 +8324,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, "requires": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -8123,7 +8334,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dev": true, "requires": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -8133,7 +8343,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, "requires": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -8144,7 +8353,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, "requires": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -8168,6 +8376,18 @@ "readable-stream": "^2.3.0", "to-buffer": "^1.1.1", "xtend": "^4.0.0" + }, + "dependencies": { + "bl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "dev": true, + "requires": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + } } }, "terser": { @@ -8218,8 +8438,7 @@ "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" }, "through2": { "version": "4.0.2", @@ -8453,8 +8672,7 @@ "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "traverse": { "version": "0.3.9", @@ -8608,7 +8826,6 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", - "dev": true, "requires": { "buffer": "^5.2.1", "through": "^2.3.8" @@ -8796,11 +9013,19 @@ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "dev": true }, + "utf-8-validate": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.9.tgz", + "integrity": "sha512-Yek7dAy0v3Kl0orwMlvi7TPtiCNrdfHNd7Gcc/pLq4BLXqfAmd0J7OWMizUQnTTJsyjKn02mU7anqwfmUP4J8Q==", + "optional": true, + "requires": { + "node-gyp-build": "^4.3.0" + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "uuid": { "version": "8.3.2", @@ -9115,37 +9340,6 @@ } } }, - "vscode-test": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/vscode-test/-/vscode-test-1.6.1.tgz", - "integrity": "sha512-086q88T2ca1k95mUzffvbzb7esqQNvJgiwY4h29ukPhFo8u+vXOOmelUoU5EQUHs3Of8+JuQ3oGdbVCqaxuTXA==", - "dev": true, - "requires": { - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "rimraf": "^3.0.2", - "unzipper": "^0.10.11" - }, - "dependencies": { - "@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true - }, - "http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, - "requires": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" - } - } - } - }, "vscode-uri": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.3.tgz", @@ -9165,8 +9359,7 @@ "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "webpack": { "version": "5.73.0", @@ -9198,6 +9391,20 @@ "terser-webpack-plugin": "^5.1.3", "watchpack": "^2.3.1", "webpack-sources": "^3.2.3" + }, + "dependencies": { + "acorn": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz", + "integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==", + "dev": true + }, + "acorn-import-assertions": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", + "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", + "dev": true + } } }, "webpack-cli": { @@ -9220,6 +9427,18 @@ "webpack-merge": "^5.7.3" }, "dependencies": { + "@webpack-cli/configtest": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", + "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", + "dev": true + }, + "@webpack-cli/serve": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", + "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", + "dev": true + }, "commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -9290,7 +9509,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, "requires": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -9396,6 +9614,11 @@ "mkdirp": "^0.5.1" } }, + "ws": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.7.0.tgz", + "integrity": "sha512-c2gsP0PRwcLFzUiA8Mkr37/MI7ilIlHQxaEAtd0uNMbVMoy8puJyafRlm0bV9MbGSabUPeLrRRaqIBcFcA2Pqg==" + }, "xml2js": { "version": "0.4.23", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", @@ -9479,7 +9702,6 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, "requires": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" diff --git a/package.json b/package.json index 28dd515e..54d36a65 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "build-grammar": "nearleyc ./src/server/Parser/liquidTagGrammar.ne -o ./src/server/Parser/liquidTagGrammar.js", "compile-web": "webpack", "watch-web": "webpack --watch", - "run-in-browser": "vscode-test-web --browserType=chromium --extensionDevelopmentPath=. ." + "run-in-browser": "vscode-test-web --browserType=chromium --extensionDevelopmentPath=. .", + "compile-tests": "node node_modules/gulp/bin/gulp.js compileIntegrationTests", + "test-integration": "node node_modules/gulp/bin/gulp.js testInt" }, "author": "PowerApps-ISV-Tools", "license": "SEE LICENSE IN LICENSE", @@ -64,15 +66,54 @@ "onCommand:microsoft-powerapps-portals.webExtension.init", "onStartupFinished", "onLanguage:yaml", - "onLanguage:html" + "onLanguage:html", + "onDebug" ], "capabilities": { + "untrustedWorkspaces": { + "supported": "limited", + "description": "Workspace trust is needed to configure and debug projects" + }, "virtualWorkspaces": { "supported": "limited", "description": "%microsoft-powerapps-portals.webExtension.limitation%" } }, "contributes": { + "problemMatchers": [ + { + "name": "pcf-scripts-build", + "label": "PCF Scripts Build problems", + "owner": "typescript", + "source": "ts", + "applyTo": "allDocuments", + "fileLocation": "absolute", + "severity": "error", + "pattern": [ + { + "regexp": "\\[tsl\\] (ERROR|WARNING) in (.*)?\\((\\d+),(\\d+)\\)", + "severity": 1, + "file": 2, + "line": 3, + "column": 4 + }, + { + "regexp": "\\s*TS(\\d+):\\s*(.*)$", + "code": 1, + "message": 2 + } + ], + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "(\\[Webpack stats\\])|(\\[build\\] [Ii]nitializing)" + }, + "endsPattern": { + "regexp": "[Cc]ompiled (.*?successfully|with .*?(error|warning))|[Cc]ompil(ation|er) .*?finished" + } + } + } + ], "viewsWelcome": [ { "view": "pacCLI.authPanel", @@ -228,9 +269,169 @@ "type": "boolean", "default": false, "markdownDescription": "Enable PowerPlatform web extension features" + }, + "powerPlatform.experimental.enablePcfDebuggingFeatures": { + "type": "boolean", + "default": false, + "markdownDescription": "Enable debugger features for PCF controls" + }, + "powerPlatform.experimental.port": { + "type": "number", + "default": 9222, + "markdownDescription": "The port on which to search for remote debuggable instances. You can leave that value as it is in most cases. The default value is `9222`." + }, + "powerPlatform.experimental.defaultUrl": { + "type": "string", + "default": "", + "format": "uri", + "markdownDescription": "The URL to your PowerApps instance where the PCF control is located. This value overrides `url` set in the launch.json file." + }, + "powerPlatform.experimental.userDataDir": { + "type": "string", + "default": "", + "markdownDescription": "By default, Microsoft Edge is launched with a separate user profile in a temp folder. Use this option to override the path. This setting will be ignored if `#powerapps-pcf-debugger.useDefaultUserDataProfile#` is `true`." + }, + "powerPlatform.experimental.useDefaultUserDataProfile": { + "type": "boolean", + "markdownDescription": "Set this property to true to use your default browser user profile for debugging. Otherwise, a temporary user profile will be created or the path specified in `#powerapps-pcf-debugger.userDataDir#` will be used.", + "default": false + }, + "powerPlatform.experimental.webRoot": { + "type": "string", + "markdownDescription": "The absolute path to the webserver root. Used to resolve paths like `/app.js` to files on disk. This setting supports `${workspaceFolder}` as a placeholder. This value overrides `webRoot` set in the launch.json file.", + "default": "" + }, + "powerPlatform.experimental.appId": { + "type": "string", + "markdownDescription": "The model driven application id which is used to host the PCF control. Only required for full screen controls. This value overrides `appId` set in the launch.json file.", + "default": "00000000-0000-0000-0000-000000000000" + }, + "powerPlatform.experimental.browserArgs": { + "type": "array", + "description": "Launch Microsoft Edge with specified args. (requires relaunching Visual Studio Code)", + "items": { + "type": "string" + }, + "default": [] + }, + "powerPlatform.experimental.browserFlavor": { + "type": "string", + "enum": [ + "Default", + "Stable", + "Beta", + "Dev", + "Canary" + ], + "enumDescriptions": [ + "PowerPlatform PCF Debugger for VS Code will try to open the Microsoft Edge flavors in the following order: Stable, Beta, Dev and Canary", + "PowerPlatform PCF Debugger for VS Code will use Microsoft Edge Stable version", + "PowerPlatform PCF Debugger for VS Code will use Microsoft Edge Beta version", + "PowerPlatform PCF Debugger for VS Code will use Microsoft Edge Dev version", + "PowerPlatform PCF Debugger for VS Code will use Microsoft Edge Canary version" + ] } } }, + "debuggers": [ + { + "type": "powerplatform-vscode.debug", + "label": "Debugger for PCF controls", + "configurationSnippets": [ + { + "label": "Debug PCF Control", + "description": "Debug a single PCF control", + "body": { + "type": "powerplatform-vscode.debug", + "request": "launch", + "name": "Launch a PCF control in Microsoft Edge and attach debugger.", + "url": "https://YOUR_ORG.crm.dynamics.com", + "webRoot": "^\"${2:\\${workspaceFolder\\}}\"", + "renderFullScreen": true, + "controlName": "publisher.MyControl", + "file": "${workspaceFolder}/out/bundle.js", + "useDefaultUserDataProfile": false + } + } + ], + "configurationAttributes": { + "launch": { + "properties": { + "url": { + "type": "string", + "description": "Absolute uri to launch. To enable automatic control navigation this value should be the form that the control is rendered on.", + "default": "https://YOUR_ORG.crm.dynamics.com" + }, + "file": { + "type": "string", + "description": "Relative path of the bundle file to debug", + "default": "${workspaceFolder}/out/bundle.js" + }, + "port": { + "type": "number", + "default": 9222, + "description": "The port on which to search for remote debuggable instances" + }, + "userDataDir": { + "type": [ + "string", + "boolean" + ], + "description": "By default, Microsoft Edge is launched with a separate user profile in a temp folder. Use this option to override the path. You can also set to false to launch with your default user profile instead.", + "default": true + }, + "useDefaultUserDataProfile": { + "type": "boolean", + "description": "Set this property to true to use your default browser user profile for debugging. Otherwise, a temporary user profile will be created or the path specified in 'User Data Dir' will be used.", + "default": false + }, + "webRoot": { + "type": "string", + "description": "The absolute path to the webserver root. Used to resolve paths like `/app.js` to files on disk", + "default": "${workspaceFolder}" + }, + "renderFullScreen": { + "type": "boolean", + "description": "Should we render the control in full screen? This only works if the control does not depend on any entity record context.", + "default": false + }, + "controlName": { + "type": "string", + "description": "This value is only required if renderFullScreen is true. The logical name of your PCF control. The logical name is defined as `_.`. Namespace and controlName are defined in the ControlManifest.xml. Dataverse will display the logical name of your control when looking at the control in the solution. Example: Control Name 'MyCustomControl'. Publisher Name: 'm365'. Namespace: 'Workflows'. -> Logical Name: 'm365_Workflows.MyCustomControl'", + "default": "m365_Workflows.MyControl" + }, + "tabName": { + "type": "string", + "description": "This value is not required if renderFullScreen is true. The name of the tab that hosts the control. If this is specified, the browser will try to navigate to the tab to load the control.", + "default": "MyControl Tab" + }, + "appId": { + "type": "string", + "description": "This value is not required if renderFullScreen is true. The model driven application id which is used to host the PCF control.", + "default": "00000000-0000-0000-0000-000000000000" + }, + "powerapps-pcf-debugger.browserFlavor": { + "type": "string", + "enum": [ + "Default", + "Stable", + "Beta", + "Dev", + "Canary" + ], + "enumDescriptions": [ + "Power Platform debugger will try to open the Microsoft Edge flavors in the following order: Stable, Beta, Dev and Canary", + "Power Platform debugger will use Microsoft Edge Stable version", + "Power Platform debugger will use Microsoft Edge Beta version", + "Power Platform debugger will use Microsoft Edge Dev version", + "Power Platform debugger will use Microsoft Edge Canary version" + ] + } + } + } + } + } + ], "snippets": [ { "language": "html", @@ -472,6 +673,8 @@ "@types/mocha": "^8.0.3", "@types/nearley": "^2.11.1", "@types/node": "^14.14.6", + "@types/puppeteer-core": "^5.4.0", + "@types/sinon": "^10.0.12", "@types/unzip-stream": "^0.3.0", "@types/uuid": "^8.3.0", "@types/vscode": "^1.51.0", @@ -479,6 +682,7 @@ "@types/webpack-env": "^1.17.0", "@typescript-eslint/eslint-plugin": "^5.15.0", "@typescript-eslint/parser": "^5.15.0", + "@vscode/test-electron": "^2.1.5", "@vscode/test-web": "^0.0.24", "chai": "^4.3.6", "eslint": "^8.11.0", @@ -498,12 +702,12 @@ "path-browserify": "^1.0.1", "process": "^0.11.10", "ps-list": "^7.2.0", + "sinon": "^14.0.0", "ts-loader": "^9.2.8", "ts-node": "^10.7.0", "typescript": "^4.6.2", "vsce": "^2.7.0", "vscode-nls-dev": "^4.0.0-next.1", - "vscode-test": "^1.5.2", "webpack": "^5.61.0", "webpack-cli": "^4.7.0", "webpack-stream": "^7.0.0", @@ -515,6 +719,7 @@ "glob": "^7.1.7", "n-readlines": "^1.0.1", "nearley": "^2.20.1", + "puppeteer-core": "^14.4.1", "unzip-stream": "^0.3.1", "uuid": "^8.3.2", "vscode-languageclient": "^7.0.0", @@ -522,5 +727,15 @@ "vscode-languageserver-textdocument": "^1.0.1", "vscode-nls": "^5.0.0", "yaml": "^1.10.2" + }, + "__metadata": { + "id": "b8680bb6-eaa9-481a-ae0b-83574fa58620", + "publisherDisplayName": "Microsoft", + "publisherId": "b0208c9d-08ff-4cfb-93f7-f64e487561a6", + "isPreReleaseVersion": false + }, + "optionalDependencies": { + "bufferutil": "^4.0.6", + "utf-8-validate": "^5.0.9" } } diff --git a/src/client/constants.ts b/src/client/constants.ts index aea8e986..0163a7cd 100644 --- a/src/client/constants.ts +++ b/src/client/constants.ts @@ -3,7 +3,27 @@ * Licensed under the MIT License. See License.txt in the project root for license information. */ -export const EXTENSION_ID = 'microsoft-IsvExpTools.powerplatform-vscode'; +export const EXTENSION_ID = "microsoft-IsvExpTools.powerplatform-vscode"; // TODO: Switch key based off of build source (official vs dev) -export const AI_KEY = '0d422197-d351-41c5-b371-a270ca3b13af'; +export const AI_KEY = "0d422197-d351-41c5-b371-a270ca3b13af"; + +/** + * Name of the extension as defined in package.name. + */ +export const EXTENSION_NAME = "powerplatform-vscode"; + +/** + * Name of the configuration section used to store experimental user configuration. + */ +export const SETTINGS_EXPERIMENTAL_STORE_NAME = "powerPlatform.experimental"; + +/** + * Default port used to connect vscode to the browsers debug endpoint. + */ +export const SETTINGS_DEBUGGER_DEFAULT_PORT = 9222; + +/** + * Default value for the `powerPlatform.experimental.enablePcfDebuggingFeatures` flag. + */ +export const DEBUGGER_ENABLED_DEFAULT_VALUE = false; diff --git a/src/client/extension.ts b/src/client/extension.ts index 374e28b1..65dd3c78 100644 --- a/src/client/extension.ts +++ b/src/client/extension.ts @@ -20,6 +20,7 @@ import { TransportKind, } from "vscode-languageclient/node"; import { readUserSettings } from "./telemetry/localfileusersettings"; +import { activateDebugger, deactivateDebugger, shouldEnableDebugger } from "../debugger"; let client: LanguageClient; let _context: vscode.ExtensionContext; @@ -107,6 +108,10 @@ export async function activate( _context.subscriptions.push(cli); _context.subscriptions.push(new PacTerminal(_context, _telemetry, cliPath)); + if (shouldEnableDebugger()) { + activateDebugger(context, _telemetry); + } + _telemetry.sendTelemetryEvent("activated"); } @@ -122,6 +127,9 @@ export async function deactivate(): Promise { if (client) { await client.stop(); } + + deactivateDebugger(); + } function didOpenTextDocument(document: vscode.TextDocument): void { diff --git a/src/common/ErrorReporter.ts b/src/common/ErrorReporter.ts new file mode 100644 index 00000000..578ca1d3 --- /dev/null +++ b/src/common/ErrorReporter.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { ITelemetry } from "../client/telemetry/ITelemetry"; +import * as vscode from "vscode"; +/** + * Static helper class to report errors to telemetry and show them as error dialogs. + */ +export class ErrorReporter { + /** + * Reports an error to telemetry and optionally shows an error dialog to the user. + * @param logger Logger used to send telemetry. + * @param errorIdentifier Unique identifier for the error. + * @param error The error to report. If error is a primitive or complex object (but not error) it will be stringified. + * @param message Error message that explains the error. + * @param showDialog [Default `true`] If `true`, an error dialog will be shown to the user. + * @param properties [Optional] Additional properties to include in the telemetry event. + */ + public static async report( + logger: ITelemetry, + errorIdentifier: string, + error: unknown, + message: string, + showDialog = true, + properties?: Record + ): Promise { + const errorObj = ErrorReporter.unknownToError(error); + const errorObjMessage = errorObj + ? ` - Inner Message: ${errorObj.message}` + : ""; + const errorStack = errorObj ? ` - Stack: ${errorObj.stack}` : ""; + const errorMessage = `${message}${errorObjMessage}`; + logger.sendTelemetryException( + new Error(`${errorIdentifier}: ${errorMessage}${errorStack}`), + properties + ); + + if (showDialog) { + await vscode.window.showErrorMessage(errorMessage); + } + } + + /** + * + * @param error The error to report. If error is a primitive or complex object (but not error) it will be stringified. + * @returns An error object or undefined if error was undefined. + */ + private static unknownToError(error: unknown): Error | undefined { + if (error instanceof Error) { + return error; + } + + if (!error) { + return undefined; + } + + try { + return new Error(JSON.stringify(error)); + } catch (error) { + return new Error("unknown error."); + } + } +} diff --git a/src/debugger/BundleLoader.ts b/src/debugger/BundleLoader.ts new file mode 100644 index 00000000..e8114ff5 --- /dev/null +++ b/src/debugger/BundleLoader.ts @@ -0,0 +1,104 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import path from "path"; +import { TextDocument, Uri, workspace, WorkspaceFolder } from "vscode"; +import { ITelemetry } from "../client/telemetry/ITelemetry"; +import { ErrorReporter } from "../common/ErrorReporter"; +import { SourceMapValidator } from "./SourceMapValidator"; + +/** + * Loads the raw string content of the bundle and verifies that it contains a valid source map. + */ +export class BundleLoader { + /** + * Name of the bundle. + * @example "bundle.js" + */ + public readonly fileName: string; + + /** + * Absolute path to the bundle on local disk. + */ + private readonly filePath: string; + + /** + * Creates a new BundleLoader instance. + * @param workspaceFolder The workspace folder that contains the bundle to load. + * @param logger The telemetry reporter to use for telemetry events. + */ + constructor( + relativeFilePath: string, + private readonly workspaceFolder: WorkspaceFolder, + private readonly logger: ITelemetry, + private readonly openTextDocument = workspace.openTextDocument + ) { + this.filePath = this.getAbsoluteFilePath(relativeFilePath); + this.fileName = path.basename(this.filePath); + } + + /** + * Gets the absolute path to the file to load. + * @param filePath The relative path to the file to load. + * @returns The absolute path to the file to load. + */ + private getAbsoluteFilePath(filePath: string): string { + const workspacePath = this.workspaceFolder.uri.path; + + const parsedPath = Uri.parse(filePath); + if (parsedPath.path.startsWith(workspacePath)) { + return filePath; + } + + return path.join(workspacePath, filePath); + } + + /** + * Loads the file contents of the bundle from disk. + * @returns The string contents of the file. + */ + public async loadFileContents(): Promise { + try { + const file: TextDocument = await this.openTextDocument( + Uri.file(this.filePath) + ); + const fileContent = file.getText(); + await this.warnIfNoSourceMap(fileContent); + + return fileContent; + } catch (error) { + void ErrorReporter.report( + this.logger, + "RequestInterceptor.loadFileContents.error", + error, + "Could not load file contents" + ); + throw new Error( + `Could not load control '${this.fileName}' with path '${ + this.filePath + }': ${error instanceof Error ? error.message : error}` + ); + } + } + + /** + * Checks if the bundle has an inlined source map. + * If not, it will show an warning message. + * @param fileContent The file contents. + */ + private async warnIfNoSourceMap(fileContent: string): Promise { + const isValid = SourceMapValidator.isValid(fileContent); + if (isValid) { + return; + } + + void ErrorReporter.report( + this.logger, + "RequestInterceptor.warnIfNoSourceMap.error", + undefined, + `Could not find inlined source map in '${this.fileName}'. Make sure you enable source maps in webpack with 'devtool: "inline-source-map"'. For local debugging, inlined source maps are required.` + ); + } +} diff --git a/src/debugger/FileWatcher.ts b/src/debugger/FileWatcher.ts new file mode 100644 index 00000000..6476c1b0 --- /dev/null +++ b/src/debugger/FileWatcher.ts @@ -0,0 +1,106 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { + Disposable, + FileSystemWatcher, + RelativePattern, + Uri, + WorkspaceFolder, + workspace, +} from "vscode"; +import { ITelemetry } from "../client/telemetry/ITelemetry"; +import { ErrorReporter } from "../common/ErrorReporter"; +import { sleep } from "./utils"; + +/** + * A file watcher that watches for file changes in a given folder. + */ +export class FileWatcher implements Disposable { + /** + * Vscode file watcher instance. + */ + private readonly watcher: FileSystemWatcher; + + /** + * Wether or not a file change was already triggered. + * This flag prevents us from reloading the page multiple times. + */ + private fileChangeTriggered = false; + + /** + * Delay after a bundle change was detected. + */ + private readonly FILE_WATCHER_CHANGE_DELAY = 5000; + + /** + * The callback to call when a file changes. + */ + private onFileChange?: () => Promise; + + /** + * Creates a new FileWatcher instance. + * @param filePattern The file pattern to watch. + * @param workspaceFolder The workspace folder to watch. + * @param logger The logger to use for telemetry. + */ + constructor( + filePattern: string, + workspaceFolder: WorkspaceFolder, + private readonly logger: ITelemetry, + createFileSystemWatcher = workspace.createFileSystemWatcher + ) { + const pattern = new RelativePattern(workspaceFolder, filePattern); + this.watcher = createFileSystemWatcher(pattern, true, false, true); + this.watcher.onDidChange((uri) => this.onChange(uri)); + } + + public register(onFileChange: () => Promise) { + this.onFileChange = onFileChange; + } + + /** + * Handle file change events. + * @param _ The URI of the file that changed. + */ + private onChange(_: Uri) { + if (this.fileChangeTriggered) { + return; + } + + this.fileChangeTriggered = true; + const onChangeAction = async () => { + if (!this.onFileChange) { + return; + } + + // Somehow we need to wait a bit before we can trigger the onFileChange. + // If we don't wait, then the bundle will still be in its old state *before* the change that triggered + // the file watcher to call the onChange event. + await sleep(this.FILE_WATCHER_CHANGE_DELAY); + try { + await this.onFileChange(); + } catch (error) { + await ErrorReporter.report( + this.logger, + "FileWatcher.onChange.error", + error, + "Could not execute file change action.", + false + ); + } + this.fileChangeTriggered = false; + }; + void onChangeAction(); + } + + /** + * Dispose the watcher. + */ + dispose() { + this.watcher.dispose(); + this.onFileChange = undefined; + } +} diff --git a/src/debugger/Readme.md b/src/debugger/Readme.md new file mode 100644 index 00000000..d13bd6b4 --- /dev/null +++ b/src/debugger/Readme.md @@ -0,0 +1,86 @@ +# Debugger for PCF controls + +Adds a debugger option for PCF controls. +This works by attaching the edge debugger to a puppeteer session which opens a model driven app, intercepts network requests to the bundle and responds with the local version of the bundle. + +## Setup + +To use the debugger with any PCF control you need to perform the following steps: + +1. **Enable Feature**: Make sure "_Power Platform -> Experimental: Enable Pcf Debugging Features_" is enabled. After enabling the feature, you need to restart vscode. + +![Image of vscode settings page with debugger feature enabled](assets/debugger-enable-feature-flag.png) + +2. **tsconfig source maps**: Open `tsconfig.json` and add `"sourceMap": true` under `compilerOptions`. Your file should look something like this: + + ```json + { + "extends": "./node_modules/pcf-scripts/tsconfig_base.json", + "compilerOptions": { + "sourceMap": true, + } + } + ``` + +3. **Add custom webpack config**: To enable the custom webpack config feature flag you need to create a file called `featureconfig.json` in your project root. Use the following contents: + +```json +{ + { "pcfAllowCustomWebpack": "on" } +} +``` + +4. **Generate source maps**: To generate source maps with webpack, create a new file called `webpack.config.js` in your project root. Use the following contents: + +```js + +const customConfig = { + // watch: true, // uncomment this line to enable webpack watch mode + devtool: "inline-source-map" +} +module.exports = customConfig; +``` + +5. **Add a launch config**: Create a new `launch.json` file under `.vscode`. If the folder `.vscode` does not yet exist, you can create it. +Click on "_Add Configuration..._" and select for "_Debug PCF Control_" from the dropdown. (If you can't find the entry the you might have to restart vscode.) + +![Image of vscode launch config](assets/debugger-selecting-launch-config.png) + +A configuration for the [Sample: TableControl](https://github.com/microsoft/PowerApps-Samples/tree/master/component-framework/TableControl) is listed below: + +```json +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "powerplatform-vscode.debug", + "request": "launch", + "name": "Launch Control", + "url": "https://YOUR_ORG_URL.crm.dynamics.com/main.aspx", + "webRoot": "${workspaceFolder}", + "renderFullScreen": false, + "controlName": "cc_SampleNamespace.TableControl", + "file": "out/controls/TableControl/bundle.js", + "tabName": "Sample Control Tab" + } + ] +} +``` + +Refer to the "Configuration" section of to learn more about the configuration options. + +6. **Launch your control**: Go to "Run and Debug" (Ctrl + Shift + D) and click on "_Launch Control_". This should now open a new browser instance which will navigate to the URL you provided in the launch configuration as `url`. This will likely trigger a login prompt. Provide your credentials to login. If the debugger has timed out waiting for the page to load you might have to restart the debugger. It should not prompt you to login again.
+Once the form page is loaded, the debugger will try to navigate to the tab the control is located on automatically. + +## Configuration + +* `name` The name of the debug configuration. This is displayed in the "Run and Debug" dropdown. +* `url`: The URL of the form page where the control is located. This needs to be the full, absolute URL to an entity record displayed on a form. The easiest way to obtain this URL is to visit a page that displays the control and copy the URL from the address bar. Example: `https://SOME_ORG.crm.dynamics.com/main.aspx?appid=00000000-0000-0000-0000-000000000000&pagetype=entityrecord&etn=account&id=00000000-0000-0000-0000-000000000000` +* `webRoot`: The path the the root of the control code. In most instances, this will be just `${workspaceFolder}` or empty. In case you have multiple controls in your workspace you may need to specify the path to the control root. Example: `${workspaceFolder}/MyControl`. +* `renderFullScreen`: It's possible to render controls full screen without any form context. Use `true` here to do this. In most instances, you'll want to leave this set to `false`. +* `controlName`: The name of the control including the namespace. Example: `cc_SampleNamespace.TableControl`. +* `file`: Relative path to the `bundle.js` file. You can execute `npm run build` to generate and locate this file. Example: `out/controls/YourControl/bundle.js`. +* `tabName`: The name of the tab the control is located on. The debugger will try to automatically select this tab. diff --git a/src/debugger/RequestInterceptor.ts b/src/debugger/RequestInterceptor.ts new file mode 100644 index 00000000..d1605b16 --- /dev/null +++ b/src/debugger/RequestInterceptor.ts @@ -0,0 +1,167 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { EventEmitter, HTTPRequest, Page } from "puppeteer-core"; +import { Disposable } from "vscode"; +import { ITelemetry } from "../client/telemetry/ITelemetry"; +import { ErrorReporter } from "../common/ErrorReporter"; +import { BundleLoader } from "./BundleLoader"; + +type OnRequestInterceptedCallback = (fileName: string) => Promise | void; + +/** + * Class that controls a Puppeteer page and replaces a request to a specific file with contents from a local file. + */ +export class RequestInterceptor implements Disposable { + /** + * The regex that matches the request for the bundle file that is being intercepted. + * @example "https://YOUR_ORG.crm4.dynamics.com/%7b637920349270000192%7d/webresources/publisher.ControlName/bundle.js" + */ + private static readonly webRequestUrlRegex = + /.*\/webresources\/.*\/bundle.js/; + + /** + * Callback for `puppeteer.on("request")`. + */ + private onRequestHandler?: (request: HTTPRequest) => void; + + /** + * Event emitter for {@link onRequestHandler} used to unregister the event. + */ + private requestEvent?: EventEmitter; + + /** + * Contents of the pcf control bundle. + */ + private fileContents?: string; + + /** + * Creates a new RequestInterceptor instance. + * @param bundleLoader Manager to load the contents of the bundle file. + * @param logger The telemetry reporter to use for telemetry events. + */ + constructor( + private readonly bundleLoader: BundleLoader, + private readonly logger: ITelemetry + ) {} + + /** + * Starts intercepting requests to the specified file. + * @param page The page to intercept requests on. + * @param onRequestIntercepted An optional callback that is invoked when a request is intercepted. + */ + public async register( + page: Page, + onRequestIntercepted?: OnRequestInterceptedCallback + ): Promise { + // don't re-register if we already have a request event + if (this.requestEvent) { + return; + } + this.fileContents = await this.bundleLoader.loadFileContents(); + + this.onRequestHandler = (event) => + this.onRequest(event, onRequestIntercepted); + this.requestEvent = page.on("request", this.onRequestHandler); + await page.setRequestInterception(true); + } + + /** + * Reloads the changed file contents. + */ + public async reloadFileContents(): Promise { + this.fileContents = await this.bundleLoader.loadFileContents(); + } + + /** + * Handles every request of the specified page. + * @param request The request to handle. + * @param onRequestIntercepted An optional callback that is invoked when a request is intercepted. + */ + private onRequest( + request: HTTPRequest, + onRequestIntercepted?: OnRequestInterceptedCallback + ): void { + this.isRequestForBundle(request) + ? void this.respondWithPcfBundle(request, onRequestIntercepted) + : void this.respondWithOriginalResource(request); + } + + /** + * Checks if a network request is for the bundle file. + * @param request The request to check. + * @returns true if the request should be intercepted with local bundle, false otherwise. + */ + private isRequestForBundle(request: HTTPRequest): boolean { + return ( + request.method() === "GET" && + !!request.url().match(RequestInterceptor.webRequestUrlRegex) + ); + } + + /** + * Respond with the file contents of the local bundle. + * @param request The request to handle. + * @param onRequestIntercepted An optional callback that is invoked when a request is intercepted. + * @returns A promise that resolves when the request has been handled. + */ + private async respondWithPcfBundle( + request: HTTPRequest, + onRequestIntercepted?: OnRequestInterceptedCallback + ): Promise { + try { + await request.respond({ + status: 200, + contentType: "text/javascript", + body: this.fileContents, + }); + } catch (error) { + void ErrorReporter.report( + this.logger, + "RequestInterceptor.onRequest.respond.error", + error, + "Could not respond to request" + ); + return; + } + + if (onRequestIntercepted) { + await onRequestIntercepted(this.bundleLoader.fileName); + } + } + + /** + * Responds with the original, requested file contents. + * This method is called for each request that is not the bundle. + * @param request The request to handle. + */ + private async respondWithOriginalResource( + request: HTTPRequest + ): Promise { + try { + await request.continue(); + } catch (error) { + void ErrorReporter.report( + this.logger, + "RequestInterceptor.respondWithOriginalResource.error", + error, + "Could not respond to non-bundle request", + false + ); + } + } + + /** + * Disposes the request interceptor. + */ + dispose() { + if (this.requestEvent && this.onRequestHandler) { + this.requestEvent.off("request", this.onRequestHandler); + this.requestEvent = undefined; + } + + this.fileContents = undefined; + } +} diff --git a/src/debugger/SourceMapValidator.ts b/src/debugger/SourceMapValidator.ts new file mode 100644 index 00000000..d80a448c --- /dev/null +++ b/src/debugger/SourceMapValidator.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +/** + * Class to check if a given file has a valid source map. + */ +export class SourceMapValidator { + /** + * Regex that matches the source map inlined in the bundle. + * @example + * // matches + * "... })(); //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9u" + */ + private static readonly sourceMapRegex = + /sourceMappingURL=data:application\/json/; + + /** + * Validates if a bundle contains a source map inlined using inline-source-map. + * @param fileContents The contents of the file to validate. + * @returns True if the source map is valid, false otherwise. + */ + public static isValid(fileContents: string): boolean { + const sourceMapMatch = fileContents.match(this.sourceMapRegex); + return !!sourceMapMatch; + } +} diff --git a/src/debugger/assets/debugger-enable-feature-flag.png b/src/debugger/assets/debugger-enable-feature-flag.png new file mode 100644 index 00000000..2bd49e28 --- /dev/null +++ b/src/debugger/assets/debugger-enable-feature-flag.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4256fc06e323067563b029dc64e76680a8b831a5611d32b12c3d4b681c3fdd4 +size 7260 diff --git a/src/debugger/assets/debugger-selecting-launch-config.png b/src/debugger/assets/debugger-selecting-launch-config.png new file mode 100644 index 00000000..4412b75f --- /dev/null +++ b/src/debugger/assets/debugger-selecting-launch-config.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87186ce6016a68072b044765736ca86b6c523675bc9cb3747a022309212f403a +size 72317 diff --git a/src/debugger/browser/BrowserArgsBuilder.ts b/src/debugger/browser/BrowserArgsBuilder.ts new file mode 100644 index 00000000..1ebbb50c --- /dev/null +++ b/src/debugger/browser/BrowserArgsBuilder.ts @@ -0,0 +1,80 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { ConfigurationManager } from "../configuration"; + +/** + * Class that builds the arguments for the browser instance launched by puppeteer. + */ +export class BrowserArgsBuilder { + private defaultArgs: string[]; + + public static readonly debuggingPortArg = "--remote-debugging-port"; + public static readonly userDataDirArg = "--user-data-dir"; + + /** + * Creates a new instance of BrowserArgsBuilder. + * @param port The remote debugging port specified in the launch configuration or user configuration. + * @param userDataDir The user data directory specified in the launch configuration or user configuration. + */ + constructor(port: number, private readonly userDataDir?: string) { + this.defaultArgs = [ + "--no-first-run", + "--no-default-browser-check", + `${BrowserArgsBuilder.debuggingPortArg}=${port}`, + ]; + } + + /** + * Builds the arguments for the browser launch by combining the default args and configuration arguments. + * @returns The arguments for the browser launch. + */ + public build(): string[] { + let userDefinedBrowserArgs: string[] = + ConfigurationManager.getBrowserArgs(); + let defaultArgs = this.defaultArgs; + userDefinedBrowserArgs = this.removeRemoteDebuggingPort( + userDefinedBrowserArgs + ); + + if (this.userDataDir) { + defaultArgs.unshift( + `${BrowserArgsBuilder.userDataDirArg}=${this.userDataDir}` + ); + userDefinedBrowserArgs = this.removeUserDataDir( + userDefinedBrowserArgs + ); + } + + if (userDefinedBrowserArgs.length) { + defaultArgs = [...defaultArgs, ...userDefinedBrowserArgs]; + } + + return defaultArgs; + } + + /** + * Removes the remote debugging port from the given browser args. + * @param args The browser args to remove the remote debugging port from. + * @returns The browser args without the remote debugging port. + */ + private removeRemoteDebuggingPort(args: string[]): string[] { + return args.filter( + (arg) => !arg.startsWith(BrowserArgsBuilder.debuggingPortArg) + ); + } + + /** + * Removes the user data directory from the given browser args. + * @param args The browser args to remove the user data directory from. + * @returns The browser args without the user data directory. + */ + private removeUserDataDir(args: string[]): string[] { + return args.filter( + (arg) => !arg.startsWith(BrowserArgsBuilder.userDataDirArg) + ); + } +} + diff --git a/src/debugger/browser/BrowserLocator.ts b/src/debugger/browser/BrowserLocator.ts new file mode 100644 index 00000000..abc5cb1b --- /dev/null +++ b/src/debugger/browser/BrowserLocator.ts @@ -0,0 +1,226 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as os from "os"; +import * as path from "path"; + +import { pathExists } from "fs-extra"; + +import { ConfigurationManager } from "../configuration"; +import { IPcfLaunchConfig } from "../configuration/types"; +import { BrowserFlavor } from "./types/BrowserFlavor"; +import { ITelemetry } from "../../client/telemetry/ITelemetry"; +import { ErrorReporter } from "../../common/ErrorReporter"; +import { IBrowserPath, Platform } from "./types"; + +const winAppDataFolder = process.env.LOCALAPPDATA || "/"; + +/** + * Class to retrieve and verify browser location on the user's machine. + */ +export class BrowserLocator { + private readonly browserFlavor: BrowserFlavor; + private readonly platform: Platform; + private readonly browserPathMapping: Map = + new Map([ + [ + "Stable", + { + debianLinux: "/opt/microsoft/msedge/msedge", + osx: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + windows: { + primary: + "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", + secondary: path.join( + winAppDataFolder, + "Microsoft\\Edge\\Application\\msedge.exe" + ), + }, + }, + ], + [ + "Beta", + { + debianLinux: "/opt/microsoft/msedge-beta/msedge", + osx: "/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta", + windows: { + primary: + "C:\\Program Files (x86)\\Microsoft\\Edge Beta\\Application\\msedge.exe", + secondary: path.join( + winAppDataFolder, + "Microsoft\\Edge Beta\\Application\\msedge.exe" + ), + }, + }, + ], + [ + "Dev", + { + debianLinux: "/opt/microsoft/msedge-dev/msedge", + osx: "/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev", + windows: { + primary: + "C:\\Program Files (x86)\\Microsoft\\Edge Dev\\Application\\msedge.exe", + secondary: path.join( + winAppDataFolder, + "Microsoft\\Edge Dev\\Application\\msedge.exe" + ), + }, + }, + ], + [ + "Canary", + { + debianLinux: "/opt/microsoft/msedge-canary/msedge", + osx: "/Applications/Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary", + windows: { + primary: + "C:\\Program Files (x86)\\Microsoft\\Edge SxS\\Application\\msedge.exe", + secondary: path.join( + winAppDataFolder, + "Microsoft\\Edge SxS\\Application\\msedge.exe" + ), + }, + }, + ], + ]); + + /** + * Creates a new instance of the BrowserLocator class. + * @param debugConfig The launch debug configuration to use. + * @param logger Logger instance to use for logging. + */ + constructor( + debugConfig: IPcfLaunchConfig, + private readonly logger: ITelemetry + ) { + this.browserFlavor = + debugConfig.browserFlavor || + ConfigurationManager.getBrowserFlavor(); + this.platform = this.getPlatform(); + } + + /** + * Gets the browser path for the specified browser flavor. + * @returns The browser path. + */ + public async getPath(): Promise { + try { + const browserPath = await this.verifyFlavorPath(); + return browserPath; + } catch (error) { + await ErrorReporter.report( + this.logger, + "BrowserLocator.getPath", + undefined, + "Microsoft Edge could not be found. Ensure you have installed Microsoft Edge and that you have selected 'default' or the appropriate version of Microsoft Edge in the extension settings panel." + ); + throw error; + } + } + + /** + * Get the current machine platform. + * @returns The current machine platform. + */ + private getPlatform(): Platform { + switch (os.platform()) { + case "darwin": + return "OSX"; + case "win32": + return "Windows"; + default: + return "Linux"; + } + } + + /** + * Verifies and returns if the browser for the current session exists in the + * desired flavor and platform. Providing a "default" flavor will scan for the + * first browser available in the following order: + * stable > beta > dev > canary + * For windows it will try: program files > local app data. + * @returns A promise with the path to the browser or an empty string if not found. + */ + private async verifyFlavorPath(): Promise { + let item = this.browserPathMapping.get(this.browserFlavor || "Default"); + if (!item) { + // if no flavor is specified search for any path present. + for (item of this.browserPathMapping.values()) { + const result = await this.verifyExecutableExists(item); + if (result) { + return result; + } + } + } + + return await this.verifyExecutableExists(item); + } + + /** + * Verifies if the path exists in disk. + * @param browserPath The path to be verified. + * @returns A promise with the path to the browser or an empty string if not found. + */ + private async verifyExecutableExists( + browserPath: IBrowserPath | undefined + ): Promise { + if (!browserPath) { + throw new Error( + `No browser path found for flavor: ${this.browserFlavor} and platform: ${this.platform}` + ); + } + + if ( + this.isDefaultOrWindows() && + (await pathExists(browserPath.windows.primary)) + ) { + return browserPath.windows.primary; + } + if ( + this.isDefaultOrWindows() && + (await pathExists(browserPath.windows.secondary)) + ) { + return browserPath.windows.secondary; + } + if (this.isDefaultOrOsx() && (await pathExists(browserPath.osx))) { + return browserPath.osx; + } + if ( + this.isDefaultOrLinux() && + (await pathExists(browserPath.debianLinux)) + ) { + return browserPath.debianLinux; + } + + throw new Error( + `No browser was found at expected path for flavor: ${this.browserFlavor} and platform: ${this.platform}` + ); + } + + /** + * Checks if the flavor is "Default" or if the platform is windows. + * @returns True if the flavor is "Default" or if the platform is windows. + */ + private isDefaultOrWindows(): boolean { + return this.platform === "Windows" || this.browserFlavor === "Default"; + } + + /** + * Checks if the flavor is "Default" or if the platform is OSX. + * @returns True if the flavor is "Default" or if the platform is OSX. + */ + private isDefaultOrOsx(): boolean { + return this.platform === "OSX" || this.browserFlavor === "Default"; + } + + /** + * Checks if the flavor is "Default" or if the platform is Linux. + * @returns True if the flavor is "Default" or if the platform is Linux. + */ + private isDefaultOrLinux(): boolean { + return this.platform === "Linux" || this.browserFlavor === "Default"; + } +} diff --git a/src/debugger/browser/BrowserManager.ts b/src/debugger/browser/BrowserManager.ts new file mode 100644 index 00000000..134f81b3 --- /dev/null +++ b/src/debugger/browser/BrowserManager.ts @@ -0,0 +1,286 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import puppeteer, { Browser, Page } from "puppeteer-core"; + +import { FileWatcher } from "../FileWatcher"; +import { RequestInterceptor } from "../RequestInterceptor"; +import { ConfigurationManager } from "../configuration"; +import { IPcfLaunchConfig } from "../configuration/types"; +import { ControlLocator } from "../controlLocation"; + +import { BrowserLocator } from "./BrowserLocator"; +import { ITelemetry } from "../../client/telemetry/ITelemetry"; +import { ErrorReporter } from "../../common/ErrorReporter"; +import { BrowserArgsBuilder } from "./BrowserArgsBuilder"; +import { Disposable } from "vscode"; + +/** + * Callback that is invoked by the {@link BrowserManager browser manager} when the browser is closed. + */ +type OnBrowserClose = () => Promise; + +/** + * Callback that is invoked by the {@link BrowserManager browser manager} when the browser is ready. + * The browser is ready when the bundle has been requested. + */ +type OnBrowserReady = () => Promise; + +/** + * Class that controls a {@link puppeteer.Browser puppeteer browser instance} and manages all logic that interacts with puppeteer including: + * - {@link BrowserLocator} to locate the browser. + * - {@link RequestInterceptor} to intercept and replace all requests for the pcf control bundle. + * - {@link FileWatcher} to watch for local changes to the control bundle. + * - {@link ControlLocator} to automatically navigate to the control within a Power App. + */ +export class BrowserManager implements Disposable { + /** + * Puppeteer {@link puppeteer.Browser browser} instance. + * This will defined after calling {@link launch}. + */ + private browserInstance?: Browser = undefined; + + /** + * Callback that is invoked when the browser is closed. + * Register this callback with {@link registerOnBrowserClose BrowserManager.registerOnBrowserClose}. + */ + private onBrowserClose?: OnBrowserClose; + + /** + * Callback that is invoked when the browser is ready. The browser is ready when the bundle has been requested. + * Register this callback with {@link registerOnBrowserReady BrowserManager.registerOnBrowserReady}. + */ + private onBrowserReady?: OnBrowserReady; + + /** + * Flag indicating whether this instance is disposed. + */ + private isDisposed = false; + + /** + * Returns the browser instance process id. + * @returns Browser process id. + */ + private get browserInstancePID(): string { + return `${this.browserInstance?.process()?.pid}` || "undefined"; + } + + /** + * Creates a new Launch manager instance. + * @param bundleWatcher Watches for local changes to the bundle file to allow for hot reload. + * @param bundleInterceptor Intercepts all puppeteer requests and answers with the contents of the local version of the pcf control bundle. + * @param controlLocator Navigates the puppeteer browser to the location of the pcf control within the Power App. + * @param browserLocator Manager to locate the browser executable. + * @param debugConfig Launch configuration. + * @param logger Telemetry reporter used to emit telemetry events. + * @param puppeteerLaunchWrapper [Optional] Wrapper around {@link puppeteer.launch}. Can be used to overwrite the puppeteer launch method for testing. + */ + constructor( + private readonly bundleWatcher: FileWatcher, + private readonly bundleInterceptor: RequestInterceptor, + private readonly controlLocator: ControlLocator, + private readonly browserLocator: BrowserLocator, + private readonly debugConfig: IPcfLaunchConfig, + private readonly logger: ITelemetry, + private readonly puppeteerLaunchWrapper = puppeteer.launch + ) {} + + /** + * Registers the callback that is invoked when the browser is closed. + * @param onBrowserClose Callback to invoke + */ + public registerOnBrowserClose(onBrowserClose: OnBrowserClose): void { + this.onBrowserClose = onBrowserClose; + } + + /** + * Callback that is invoked when the browser is ready. The browser is ready when the bundle has been requested. + * @param onBrowserReady Callback to invoke + */ + public registerOnBrowserReady(onBrowserReady: OnBrowserReady): void { + this.onBrowserReady = onBrowserReady; + } + + /** + * Launches a new browser instance or attaches to existing one. + * This method is the main entry point. It is called from the `Debugger` once debugging is started. + */ + public async launch(): Promise { + const telemetryProps = { + debugConfig: JSON.stringify(this.debugConfig), + browserFlavor: ConfigurationManager.getBrowserFlavor(), + }; + this.logger.sendTelemetryEvent("BrowserManager.launch", telemetryProps); + + const browser = await this.getBrowser(); + const pages = await browser.pages(); + + if (pages.length > 0) { + try { + await this.registerPage(pages[0]); + } catch (error) { + await ErrorReporter.report( + this.logger, + "BrowserManager.launch.registerPage", + error, + "Could not register page", + true, + telemetryProps + ); + throw error; + } + } else { + const message = + "Could not start browser. Please try again. Browser instance does not have any active pages."; + await ErrorReporter.report( + this.logger, + "BrowserManager.launch.noPages", + undefined, + message, + true + ); + throw new Error(message); + } + } + + /** + * Retrieves the {@link puppeteer.Browser puppeteer browser instance}. If the browser instance hasn't been created yet, it will create one. + * @returns Browser instance. + */ + private async getBrowser(): Promise { + if (this.browserInstance) { + return this.browserInstance; + } + + const { port, userDataDir } = + ConfigurationManager.getRemoteEndpointSettings(this.debugConfig); + + // Launch a new instance + const browserPath = await this.browserLocator.getPath(); + + try { + this.browserInstance = await this.launchPuppeteerInstance( + browserPath, + port, + userDataDir + ); + + // make sure we remove the reference to the browser instance when it is closed + this.browserInstance.on("disconnected", () => { + this.browserInstance = undefined; + }); + const version = await this.browserInstance.version(); + this.logger.sendTelemetryEvent("BrowserManager.getBrowser", { + port: `${port}`, + processId: this.browserInstancePID, + wsEndpoint: this.browserInstance.wsEndpoint() || "unknown", + version, + }); + + return this.browserInstance; + } catch (error) { + await ErrorReporter.report( + this.logger, + "BrowserManager.getBrowser", + error, + "Could not launch browser Please check your settings and try again." + ); + + throw error; + } + } + + /** + * Launch the specified puppeteer browser with remote debugging enabled. + * @param browserPath The path of the browser to launch. + * @param port The port on which to enable remote debugging. + * @param userDataDir The user data directory for the launched instance. + * @returns The browser process. + */ + private async launchPuppeteerInstance( + browserPath: string, + port: number, + userDataDir?: string + ) { + const argsBuilder = new BrowserArgsBuilder(port, userDataDir); + const args = argsBuilder.build(); + const browserInstance = await this.puppeteerLaunchWrapper({ + executablePath: browserPath, + args, + headless: false, + defaultViewport: null, + }); + return browserInstance; + } + + /** + * Performs actions to register a page with different managers to allow request interception, event logging and navigation to the control. + * @param page Page to register. + */ + private async registerPage(page: Page) { + /** + * Disposes of all the managers related to this debugging session. + */ + const disposeSession = async () => { + this.onBrowserClose && (await this.onBrowserClose()); + this.disposeSessionInstances(); + }; + + page.once("close", () => { + void disposeSession(); + }); + + const onFileChangeHandler = async () => { + await this.bundleInterceptor?.reloadFileContents(); + await this.controlLocator?.navigateToControl(page); + }; + + const onBundleLoaded = async () => { + this.onBrowserReady && (await this.onBrowserReady()); + }; + this.bundleWatcher.register(onFileChangeHandler); + try { + await this.bundleInterceptor?.register(page, onBundleLoaded); + await this.controlLocator?.navigateToControl(page); + } catch (error) { + await ErrorReporter.report( + this.logger, + "BrowserManager.registerPage", + error, + "Failed to start browser session." + ); + await disposeSession(); + } + } + + /** + * Dispose this instance. + */ + public dispose() { + if (this.isDisposed) { + return; + } + const disposeAsync = async () => { + if (this.browserInstance) { + await this.browserInstance.close(); + this.browserInstance = undefined; + } + }; + this.onBrowserClose = undefined; + this.onBrowserReady = undefined; + this.disposeSessionInstances(); + void disposeAsync(); + this.isDisposed = true; + } + + /** + * Disposes the current session instances. + */ + private disposeSessionInstances() { + this.controlLocator.dispose(); + this.bundleInterceptor.dispose(); + this.bundleWatcher.dispose(); + } +} diff --git a/src/debugger/browser/index.ts b/src/debugger/browser/index.ts new file mode 100644 index 00000000..984eebf7 --- /dev/null +++ b/src/debugger/browser/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +export { BrowserManager } from "./BrowserManager"; diff --git a/src/debugger/browser/types/BrowserFlavor.ts b/src/debugger/browser/types/BrowserFlavor.ts new file mode 100644 index 00000000..29c31501 --- /dev/null +++ b/src/debugger/browser/types/BrowserFlavor.ts @@ -0,0 +1,9 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +/** + * The PCF debugger will try to open the Microsoft Edge flavors in the following order: Stable, Beta, Dev and Canary. + */ +export type BrowserFlavor = "Default" | "Stable" | "Beta" | "Dev" | "Canary"; diff --git a/src/debugger/browser/types/IBrowserPath.ts b/src/debugger/browser/types/IBrowserPath.ts new file mode 100644 index 00000000..4e675965 --- /dev/null +++ b/src/debugger/browser/types/IBrowserPath.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +/** + * Interface to store browser path information for different platforms. + */ +export interface IBrowserPath { + /** + * Path to the browser executable in debian linux. + */ + debianLinux: string; + /** + * Path to the browser executable in windows. + */ + windows: { + /** + * Primary browser path. + */ + primary: string; + /** + * Secondary browser path. + */ + secondary: string; + }; + /** + * Path to the browser executable in macOS. + */ + osx: string; +} diff --git a/src/debugger/browser/types/Platform.ts b/src/debugger/browser/types/Platform.ts new file mode 100644 index 00000000..d693c9a8 --- /dev/null +++ b/src/debugger/browser/types/Platform.ts @@ -0,0 +1,6 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +export type Platform = "Windows" | "OSX" | "Linux"; diff --git a/src/debugger/browser/types/index.ts b/src/debugger/browser/types/index.ts new file mode 100644 index 00000000..db6aab10 --- /dev/null +++ b/src/debugger/browser/types/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +export { BrowserFlavor } from "./BrowserFlavor"; +export { Platform } from "./Platform"; +export { IBrowserPath } from "./IBrowserPath"; diff --git a/src/debugger/configuration/ConfigurationManager.ts b/src/debugger/configuration/ConfigurationManager.ts new file mode 100644 index 00000000..6de9702f --- /dev/null +++ b/src/debugger/configuration/ConfigurationManager.ts @@ -0,0 +1,212 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as os from "os"; +import * as path from "path"; + +import { ControlLocation } from "../controlLocation"; +import { replaceWorkSpaceFolderPlaceholder } from "../utils"; + +import { LaunchJsonConfigManager } from "./LaunchJsonConfigManager"; +import { UserSettingsConfigManager } from "./UserSettingsConfigManager"; +import { + IDevToolsSettings, + IPcfLaunchConfig, + IUserSettings, + LaunchDebugConfiguration, + UserDataDir, +} from "./types"; +import { BrowserFlavor } from "../browser/types/BrowserFlavor"; +import { + EXTENSION_NAME, + SETTINGS_DEBUGGER_DEFAULT_PORT, +} from "../../client/constants"; + +/** + * Class that manages configuration of the extension. + * Configuration can come from two different sources: + * - User configuration stored in either the users global settings or workspace settings. + * - Configuration from the launch.json file. + * + * The user configuration always overrides values in the launch.json file. + * The reason is that a launch.json file is usually committed to source control and might contain general values for environment URLs or App Id. + * In order to debug controls within dev environment, the user needs to be able to override these values. + */ +export class ConfigurationManager { + /** + * Use static methods to retrieve configuration. + */ + private constructor() { + /** */ + } + + /** + * Returns the launch configuration. + * @param selectedLaunchConfig The launch configuration from the launch.json that the user selected. + * @returns The launch configuration. + */ + public static getLaunchConfig( + selectedLaunchConfig?: LaunchDebugConfiguration + ): IPcfLaunchConfig { + const userConfig = UserSettingsConfigManager.getConfig(); + const mergedConfig = this.mergeConfigs( + selectedLaunchConfig, + userConfig + ); + LaunchJsonConfigManager.validateLaunchJson(mergedConfig); + + return mergedConfig; + } + + /** + * Retrieves the {@link BrowserFlavor type of browser build} to use for the launch. + * @returns The {@link BrowserFlavor type of browser build} to use for the launch. + */ + public static getBrowserFlavor(): BrowserFlavor { + return UserSettingsConfigManager.getBrowserFlavor(); + } + + /** + * Get the command line args which are passed to the browser. + * @returns Additional args to start the browser. + */ + public static getBrowserArgs(): string[] { + return UserSettingsConfigManager.getBrowserArgs(); + } + + /** + * Get the remote endpoint settings from the vscode configuration. + * @param debugConfig The settings specified by a launch config, if any. + * @returns The remote endpoint settings. + */ + public static getRemoteEndpointSettings( + debugConfig: IPcfLaunchConfig + ): IDevToolsSettings { + const userDataDir = this.resolveUserDataDirPath(debugConfig); + return { + port: debugConfig.port, + userDataDir, + useDefaultUserDataProfile: debugConfig.useDefaultUserDataProfile, + }; + } + + /** + * Resolves the path for the browser user directory. + * @param debugConfig Merged launch config. + * @returns The path to the browser user directory. + */ + private static resolveUserDataDirPath( + debugConfig: IPcfLaunchConfig + ): string { + let userDataDir: UserDataDir; + if (typeof debugConfig.userDataDir !== "undefined") { + userDataDir = debugConfig.userDataDir; + } else { + const { userDataDir: settingsUserDataDir } = + UserSettingsConfigManager.getConfig(); + if (typeof settingsUserDataDir !== "undefined") { + userDataDir = settingsUserDataDir; + } + } + + // Check to see if we need to use a user data directory, which will force Edge to launch with a new manager process. + // We generate a temp directory if the user opted in explicitly with 'true' (which is the default), + // Or if it is not defined and they are not using a custom browser path (such as electron). + // This matches the behavior of the chrome and edge debug extensions. + const browserFlavor = this.getBrowserFlavor(); + + if ( + !debugConfig.useDefaultUserDataProfile || + (typeof userDataDir === "undefined" && browserFlavor === "Default") + ) { + return path.join( + os.tmpdir(), + `${EXTENSION_NAME}-userdatadir_${debugConfig.port}` + ); + } else if (!userDataDir) { + // Explicit opt-out + return ""; + } + + return userDataDir; + } + + /** + * Combines a launch configuration with the default configuration. + * @param selectedLaunchConfig The launch configuration from the launch.json that the user selected. + * @param userConfig Configuration from the user settings. + * @returns A launch configuration. + */ + private static mergeConfigs( + selectedLaunchConfig?: LaunchDebugConfiguration, + userConfig?: Partial + ): IPcfLaunchConfig { + // const fallbackLaunchConfig = this.launchConfigManager.getConfig(); + + if (!selectedLaunchConfig) { + throw new Error( + "Could not get config from launch.json or the provided config was not supported." + ); + } + + if (!userConfig) { + return { + ...selectedLaunchConfig, + controlLocation: + this.getControlLocationConfig(selectedLaunchConfig), + }; + } + + // let controlLocation: ControlLocation; + + const controlLocation = this.getControlLocationConfig( + selectedLaunchConfig, + userConfig + ); + + const file = replaceWorkSpaceFolderPlaceholder( + selectedLaunchConfig.file + ); + + return { + url: userConfig.defaultUrl || selectedLaunchConfig.url, + browserFlavor: + userConfig.browserFlavor ?? selectedLaunchConfig.browserFlavor, + webRoot: userConfig.webRoot || selectedLaunchConfig.webRoot, + file, + port: + userConfig.port || + selectedLaunchConfig.port || + SETTINGS_DEBUGGER_DEFAULT_PORT, + controlLocation, + userDataDir: + userConfig.userDataDir || selectedLaunchConfig.userDataDir, + useDefaultUserDataProfile: + userConfig.useDefaultUserDataProfile ?? + selectedLaunchConfig.useDefaultUserDataProfile, + request: "launch", + name: `Launch ${controlLocation.controlName}`, + type: selectedLaunchConfig.type || `${EXTENSION_NAME}.debug`, + }; + } + + /** + * Retrieves the {@link ControlLocation} configuration from the launch configuration. + * @param launchConfig The selected launch configuration. + * @param userConfig The user settings. + * @returns The {@link ControlLocation Configuration} which specifies the location of the control. + */ + private static getControlLocationConfig( + launchConfig: LaunchDebugConfiguration, + userConfig?: Partial + ): ControlLocation { + return LaunchJsonConfigManager.getControlLocationConfig( + launchConfig.renderFullScreen ?? false, + launchConfig.tabName, + launchConfig.controlName, + launchConfig.appId || userConfig?.appId + ); + } +} diff --git a/src/debugger/configuration/LaunchDebugProvider.ts b/src/debugger/configuration/LaunchDebugProvider.ts new file mode 100644 index 00000000..0bb4d8d0 --- /dev/null +++ b/src/debugger/configuration/LaunchDebugProvider.ts @@ -0,0 +1,66 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import { EXTENSION_NAME } from "../../client/constants"; +import { ITelemetry } from "../../client/telemetry/ITelemetry"; +import { ErrorReporter } from "../../common/ErrorReporter"; + +import { ConfigurationManager } from "./ConfigurationManager"; +import { providedDebugConfig } from "./LaunchJsonConfigManager"; +import { LaunchDebugConfiguration } from "./types"; + +/** + * A class that registers the extension as a debug provider. + */ +export class LaunchDebugProvider implements vscode.DebugConfigurationProvider { + /** + * Creates a new LaunchDebugProvider instance. + * @param logger The logger to log telemetry events. + */ + constructor(private readonly logger: ITelemetry) {} + + /** + * Provides the supported debug configuration. + * @returns The supported debug configuration. + */ + provideDebugConfigurations(): vscode.ProviderResult< + vscode.DebugConfiguration[] + > { + return Promise.resolve([providedDebugConfig]); + } + + /** + * Resolves the debug configuration, substitutes variables and launches the application. + * @param folder The [optional] {@link vscode.WorkspaceFolder workspace} folder. + * @param config The {@link LaunchDebugConfiguration debug configuration} that the user selected to debug the PCF control. + * @param _ The cancellation token. + * @returns The resolved debug configuration. + */ + resolveDebugConfigurationWithSubstitutedVariables( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, + _?: vscode.CancellationToken + ): vscode.ProviderResult { + const selectedConfig = config as LaunchDebugConfiguration; + + if (config && config.type === `${EXTENSION_NAME}.debug`) { + if (config.request && config.request === "launch") { + const debugConfig = + ConfigurationManager.getLaunchConfig(selectedConfig); + return debugConfig; + } + } else { + void ErrorReporter.report( + this.logger, + "LaunchDebugProvider.resolveDebugConfigurationWithSubstitutedVariables", + undefined, + "Invalid or missing debug configuration in launch.json" + ); + } + + return null; + } +} diff --git a/src/debugger/configuration/LaunchJsonConfigManager.ts b/src/debugger/configuration/LaunchJsonConfigManager.ts new file mode 100644 index 00000000..1027ce1c --- /dev/null +++ b/src/debugger/configuration/LaunchJsonConfigManager.ts @@ -0,0 +1,96 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import { EXTENSION_NAME } from "../../client/constants"; + +import { ControlLocation } from "../controlLocation"; + +import { IPcfLaunchConfig } from "./types/IPcfLaunchConfig"; + +export const providedDebugConfig: vscode.DebugConfiguration = { + type: `${EXTENSION_NAME}.debug`, + request: "launch", + name: "Launch a PCF control in Microsoft Edge and attach debugger.", + url: "https://YOUR_ORG.crm.dynamics.com", + webRoot: '^"${2:\\${workspaceFolder\\}}"', + renderFullScreen: true, + controlName: "publisher.MyControl", + file: "${workspaceFolder}/out/bundle.js", +}; + +/** + * + */ +export class LaunchJsonConfigManager { + /** + * Validates the selected launch configuration and throws an error if it is not valid. + * @param debugConfig The selected launch configuration. + */ + public static validateLaunchJson( + debugConfig?: Partial + ): void { + if ( + !debugConfig?.url || + !debugConfig?.webRoot || + !debugConfig?.file || + !debugConfig?.port || + !debugConfig?.type || + !debugConfig?.controlLocation?.controlName || + debugConfig?.controlLocation.renderFullScreen === undefined + ) { + throw new Error( + "Could not get launch configuration from user config. Config: " + + JSON.stringify(debugConfig) + ); + } + } + + /** + * Creates a {@link ControlLocation } config from parameters. + * @param renderFullPage Wether to render the page in full screen or not. + * @param tabName Name of the tab to open. + * @param controlName Name of the control to open. + * @param appId App id that hosts the control. + * @returns The {@link ControlLocation Configuration} which specifies the location of the control. + */ + public static getControlLocationConfig( + renderFullPage: boolean | undefined, + tabName: string | undefined, + controlName: string | undefined, + appId: string | undefined + ): ControlLocation { + if (!controlName) { + throw new Error("Missing controlName in launch.json"); + } + + let controlLocation: ControlLocation; + if (!renderFullPage) { + if (!tabName) { + throw new Error( + "renderFullScreen is false but tabName is not specified in launch.json" + ); + } + controlLocation = { + controlName: controlName, + tabName, + renderFullScreen: false, + }; + } else { + if (!appId) { + throw new Error( + "renderFullScreen is true but appId is not specified in launch.json or extension settings" + ); + } + controlLocation = { + appId, + controlName: controlName, + renderFullScreen: true, + }; + } + return controlLocation; + } +} + diff --git a/src/debugger/configuration/UserSettingsConfigManager.ts b/src/debugger/configuration/UserSettingsConfigManager.ts new file mode 100644 index 00000000..656e2dca --- /dev/null +++ b/src/debugger/configuration/UserSettingsConfigManager.ts @@ -0,0 +1,81 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import { + DEBUGGER_ENABLED_DEFAULT_VALUE, + SETTINGS_EXPERIMENTAL_STORE_NAME, +} from "../../client/constants"; +import { BrowserFlavor } from "../browser/types"; + +import { IUserSettings, UserDataDir } from "./types"; + +/** + * Class that is used to retrieve user settings for the extension. + */ +export class UserSettingsConfigManager { + /** + * Returns a browser debug configuration from a PcfLaunchConfig. + * @returns The {@link IUserSettings users extension} configuration. + */ + public static getConfig(): Partial { + const settings = this.getSettings(); + const port = settings.get("port"); + const defaultUrl = settings.get("defaultUrl"); + const appId = settings.get("appId"); + const userDataDir = settings.get("userDataDir"); + const webRoot = settings.get("webRoot"); + const browserFlavor = this.getBrowserFlavor(); + + return { + defaultUrl, + appId, + port, + userDataDir, + webRoot, + browserFlavor, + }; + } + + /** + * Retrieves the {@link BrowserFlavor type of browser build} to use for the launch from the user configuration in their settings. + * Uses `Default` if the user has not specified a browser type. + * @returns The browser flavor to use for the extension. + */ + public static getBrowserFlavor(): BrowserFlavor { + const settings = this.getSettings(); + const browserFlavor = + settings.get("browserFlavor") || "Default"; + return browserFlavor; + } + + /** + * Get the command line args from the users settings which are passed to the browser. + * @returns Additional args to start the browser. + */ + public static getBrowserArgs(): string[] { + const settings = this.getSettings(); + const browserArgs: string[] = settings.get("browserArgs") || []; + return browserArgs.map((arg) => arg.trim()); + } + + public static shouldEnableDebugger(): boolean { + const settings = this.getSettings(); + return ( + settings.get("enablePcfDebuggingFeatures") || + DEBUGGER_ENABLED_DEFAULT_VALUE + ); + } + + /** + * Gets the users workspace configuration. + * @returns The workspace configuration. + */ + private static getSettings(): vscode.WorkspaceConfiguration { + return vscode.workspace.getConfiguration( + SETTINGS_EXPERIMENTAL_STORE_NAME + ); + } +} diff --git a/src/debugger/configuration/index.ts b/src/debugger/configuration/index.ts new file mode 100644 index 00000000..7531b409 --- /dev/null +++ b/src/debugger/configuration/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +export { LaunchDebugProvider } from "./LaunchDebugProvider"; +export { ConfigurationManager } from "./ConfigurationManager"; diff --git a/src/debugger/configuration/types/DebugConfiguration.ts b/src/debugger/configuration/types/DebugConfiguration.ts new file mode 100644 index 00000000..fd86f157 --- /dev/null +++ b/src/debugger/configuration/types/DebugConfiguration.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { DebugConfiguration as vscodeDebugConfiguration } from "vscode"; + +/** + * Reexports the {@link vscodeDebugConfiguration DebugConfiguration} from vscode without the index type. + */ +export type DebugConfiguration = Pick; diff --git a/src/debugger/configuration/types/FlattenType.ts b/src/debugger/configuration/types/FlattenType.ts new file mode 100644 index 00000000..f9f41924 --- /dev/null +++ b/src/debugger/configuration/types/FlattenType.ts @@ -0,0 +1,40 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +/** + * Primitives for flattening a type. + */ +type Primitive = string | number | boolean | undefined; + +/** + * Creates key-value tuples out of a given object. + */ +type CreateKeyValueTuple = { + [TKey in keyof T]: T[TKey] extends Primitive // Map over all the keys in the type + ? [TKey, T[TKey]] // if the current key is a primitive, return a tuple with the key and the value. E.g. CreateKeyValuePair<{a: number}> = {a: [a, number]} + : CreateKeyValueTuple; // if the current key is an object, recurse into the object and return a tuple with the key and the value primitive values. +}[keyof T] & // Create a union + [PropertyKey, Primitive]; // Hack for the "Type instantiation is excessively deep and possibly infinite."; + +/** + * Flattens a given object type into a `Record`. + * @param T The object type to flatten. + * @returns A `Record` representing the flattened object. + * @example + * ```ts + * // Test is {a: string, c: number, d: string, f: string} + * type Test = FlattenType<{ + * a: string; + * b: { + * c: number; + * d: string; + * e: { + * f: string; + * } + * } + * }>; + * ``` + */ +export type FlattenType = { [KV in CreateKeyValueTuple as KV[0]]: KV[1] }; diff --git a/src/debugger/configuration/types/IDevToolsSettings.ts b/src/debugger/configuration/types/IDevToolsSettings.ts new file mode 100644 index 00000000..743e6854 --- /dev/null +++ b/src/debugger/configuration/types/IDevToolsSettings.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { UserDataDir } from "./UserDataDir"; + +/** + * Interface to store dev tools settings. + */ +export interface IDevToolsSettings { + /** + * Port to use to connect to local devTools. + * @example 9222 + */ + port: number; + + /** + * By default, Microsoft Edge is launched with a separate user profile in a temp folder. + * Use this option to override the path. + * You can also set to false to launch with your default user profile instead. + * + * Values: + * - `"PATH"` - Use the path specified in the `userDataDir` property. + * - `undefined` (no value set) - Launch with a temporary user profile. + */ + userDataDir: UserDataDir; + + /** + * Set this property to true to use your default browser user profile for debugging. Otherwise, a temporary user profile will be created or the path specified in 'User Data Dir' will be used. + * + * Values: + * - `false` - Launch with the default user profile. + * - `true` - Launch with a temporary user profile created in the extension. + */ + useDefaultUserDataProfile: boolean; +} diff --git a/src/debugger/configuration/types/IPcfLaunchConfig.ts b/src/debugger/configuration/types/IPcfLaunchConfig.ts new file mode 100644 index 00000000..5ce2e30d --- /dev/null +++ b/src/debugger/configuration/types/IPcfLaunchConfig.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { BrowserFlavor } from "../../browser/types"; +import { ControlLocation } from "../../controlLocation"; + +import { DebugConfiguration } from "./DebugConfiguration"; +import { IDevToolsSettings } from "./IDevToolsSettings"; + +/** + * Interface to represent a configuration specified in a launch.json file. + */ +export interface IPcfLaunchConfig + extends DebugConfiguration, + IDevToolsSettings { + /** + * The url to the PowerPlatform application. + * @example "https://ORG_URL.crm.dynamics.com" + */ + url: string; + + /** + * The path to the local root of the control. + * @example "${workspaceFolder}/controls/my-control" + */ + webRoot: string; + + /** + * Relative path of the bundle file to debug. + * @example "${workspaceFolder}/controls/my-control/out/controls/src/bundle.js" + */ + file: string; + + /** + * The browser flavor to use. + * PowerPlatform PCF Debugger for VS Code will try to open the Microsoft Edge flavors in the following order: Stable, Beta, Dev and Canary. + */ + browserFlavor: BrowserFlavor; + + /** + * Location of the PCF control. + */ + controlLocation: ControlLocation; +} diff --git a/src/debugger/configuration/types/IUserSettings.ts b/src/debugger/configuration/types/IUserSettings.ts new file mode 100644 index 00000000..8a63654d --- /dev/null +++ b/src/debugger/configuration/types/IUserSettings.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { BrowserFlavor } from "../../browser/types"; + +import { IDevToolsSettings } from "./IDevToolsSettings"; + +/** + * Interface to store the user configuration/settings. + */ +export interface IUserSettings extends IDevToolsSettings { + /** + * The URL to use as an override for any settings in launch.json. + * @example + * "https://myOrg.dynamics.com/" + */ + defaultUrl: string; + + /** + * The model driven application id which is used to host the PCF control. + * @example "00000000-0000-0000-0000-000000000000" + */ + appId: string; + + /** + * The {@link BrowserFlavor browser} to use. + * PowerPlatform PCF Debugger for VS Code will try to open the Microsoft Edge flavors in the following order: Stable, Beta, Dev and Canary. + */ + browserFlavor: BrowserFlavor; + + /** + * The absolute path to the webserver root. Used to resolve paths like `/app.js` to files on disk. + */ + webRoot: string; +} diff --git a/src/debugger/configuration/types/LaunchDebugConfiguration.ts b/src/debugger/configuration/types/LaunchDebugConfiguration.ts new file mode 100644 index 00000000..09a11b10 --- /dev/null +++ b/src/debugger/configuration/types/LaunchDebugConfiguration.ts @@ -0,0 +1,12 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { FlattenType } from "./FlattenType"; +import { IPcfLaunchConfig } from "./IPcfLaunchConfig"; + +/** + * Represents the {@link FlattenType flattened} type of the {@link IPcfLaunchConfig}. + */ +export type LaunchDebugConfiguration = FlattenType; diff --git a/src/debugger/configuration/types/UserDataDir.ts b/src/debugger/configuration/types/UserDataDir.ts new file mode 100644 index 00000000..ccd3802f --- /dev/null +++ b/src/debugger/configuration/types/UserDataDir.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +/** + * By default, Microsoft Edge is launched with a separate user profile in a temp folder. + * Use this option to override the path. + * You can also set to false to launch with your default user profile instead. + * + * Values: + * - `"PATH"` - Use the path specified in the `userDataDir` property. + * - `undefined` (no value set) - Launch with a temporary user profile. + */ +export type UserDataDir = string | undefined; diff --git a/src/debugger/configuration/types/index.ts b/src/debugger/configuration/types/index.ts new file mode 100644 index 00000000..853bcf56 --- /dev/null +++ b/src/debugger/configuration/types/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +export { IUserSettings } from "./IUserSettings"; +export { LaunchDebugConfiguration } from "./LaunchDebugConfiguration"; +export { IPcfLaunchConfig } from "./IPcfLaunchConfig"; +export { UserDataDir } from "./UserDataDir"; +export { IDevToolsSettings } from "./IDevToolsSettings"; diff --git a/src/debugger/controlLocation/ControlLocation.ts b/src/debugger/controlLocation/ControlLocation.ts new file mode 100644 index 00000000..d6dbee3e --- /dev/null +++ b/src/debugger/controlLocation/ControlLocation.ts @@ -0,0 +1,56 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +/** + * Control location informs the {@link ControlLocator} where a control is located. + */ +export type ControlLocation = { + /** + * The logical name of your PCF control. The logical name is defined as `_.`. Namespace and controlName are defined in the ControlManifest.xml. Dataverse will display the logical name of your control when looking at the control in the solution. Example: Control Name 'MyCustomControl'. Publisher Name: 'm365'. Namespace: 'Workflows'. -> Logical Name: 'm365_Workflows.MyCustomControl'. + */ + controlName: string; +} & (ControlTabLocation | ControlFullscreenLocation); + +/** + * Type used for controls that are located on a tab within a page. + */ +type ControlTabLocation = { + /** + * The name of the tab that hosts the control. If this is specified, the browser will try to navigate to the tab to load the control. + */ + tabName: string; + + /** + * Wether to render the control as a full page control. + * **Not supported if the control is located on a tab.**. + */ + renderFullScreen: false; + + /** + * The model driven application id which is used to host the PCF control. + */ + appId?: never; +}; + +/** + * Type used for controls that can be rendered full screen. + */ +type ControlFullscreenLocation = { + /** + * Name of the tab that the control is located on. + * **Not supported if the control is rendered as a full page control.**. + */ + tabName?: never; + + /** + * Wether to render the control as a full page control. + */ + renderFullScreen: true; + + /** + * The model driven application id which is used to host the PCF control. + */ + appId: string; +}; diff --git a/src/debugger/controlLocation/ControlLocator.ts b/src/debugger/controlLocation/ControlLocator.ts new file mode 100644 index 00000000..5e616d49 --- /dev/null +++ b/src/debugger/controlLocation/ControlLocator.ts @@ -0,0 +1,196 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { Page } from "puppeteer-core"; +import { Disposable } from "vscode"; + +import { IPcfLaunchConfig } from "../configuration/types/IPcfLaunchConfig"; +import { sleep } from "../utils"; +import { ITelemetry } from "../../client/telemetry/ITelemetry"; +import { ErrorReporter } from "../../common/ErrorReporter"; + +/** + * The default delay after which to retry the navigation to the control. + */ +const DEFAULT_CONTROL_LOCATOR_RETRY_TIMEOUT = 2000; + +/** + * Default number of retries to locate a control. + */ +const DEFAULT_CONTROL_LOCATOR_RETRIES = -1; + +/** + * ControlLocator is a class which tries to navigate to a control once the page is loaded. + * Depending on the control type this might involve navigating to a different tab. + */ +export class ControlLocator implements Disposable { + /** + * Absolute url where the control is located in the remote environment. + */ + private readonly pageUrl: string; + + /** + * Flag indicating if navigation to the control should be retried after failure. + */ + private shouldRetryNavigation: boolean; + + /** + * Flag indicating if the instance is disposed. + */ + private isDisposed: boolean; + + /** + * Creates a new instance of ControlLocator. + * @param debugConfig Debug configuration. + * @param logger Telemetry reporter. + * @param controlLocatorRetryTimeout Delay after which to retry the navigation to the control. + * @param controlLocatorRetries Number of retries to locate a control. + */ + constructor( + private readonly debugConfig: IPcfLaunchConfig, + private readonly logger: ITelemetry, + private readonly controlLocatorRetryTimeout: number = DEFAULT_CONTROL_LOCATOR_RETRY_TIMEOUT, + private readonly controlLocatorRetries: number = DEFAULT_CONTROL_LOCATOR_RETRIES + ) { + this.pageUrl = this.getPageUrl(); + this.shouldRetryNavigation = true; + this.isDisposed = false; + } + + /** + * Builds the url to the initial page based on configuration. + * @returns Url to control. + */ + private getPageUrl(): string { + const url = this.debugConfig.url; + const { + appId, + controlName: name, + renderFullScreen: renderFullPage, + } = this.debugConfig.controlLocation; + + if (renderFullPage) { + // make sure that URL does not contain any path + const urlWithoutPath = url.split("/").slice(0, 3).join("/"); + return `${urlWithoutPath}/main.aspx?appid=${appId}&pagetype=control&controlName=${name}`; + } + + return url; + } + + /** + * Tries to navigate to the control. + * @param page Puppeteer page. + * @param retryCount Number of retries left. + */ + public async navigateToControl( + page: Page, + retryCount: number = this.controlLocatorRetries + ): Promise { + await this.navigateToPage(page, retryCount); + await this.navigateToTab(page, retryCount); + } + + /** + * Navigates to the form page with a deep link. Will retry if the navigation fails. + * @param page Puppeteer page. + * @param retryCount Number of retries left. If -1, retries indefinitely. + * @returns Promise which resolves when the navigation is complete or retries are exhausted. + */ + private async navigateToPage( + page: Page, + retryCount: number = this.controlLocatorRetries + ): Promise { + try { + await page.goto(this.pageUrl); + } catch (error) { + ErrorReporter.report( + this.logger, + "ControlLocator.navigateToPage.goto", + error, + "Could not navigate to form with url " + this.pageUrl, + true, + { + pageLink: this.pageUrl, + retryCount: `${retryCount}/${this.controlLocatorRetries}`, + } + ); + if (this.shouldRetry(retryCount)) { + await sleep(this.controlLocatorRetryTimeout); + return await this.navigateToPage(page, retryCount - 1); + } else { + this.throwErrorIfNotDisposed(error); + } + } + } + + private throwErrorIfNotDisposed(error: unknown): void { + // Protocol error is expected if the debugging session was disposed. + if (this.isDisposed && (error as Error).name === "ProtocolError") { + return; + } + throw error; + } + + /** + * Navigates to the tab within the form that the control is located at. + * If the control is rendered as full screen, returns early. + * @param page Puppeteer page. + * @param retryCount Number of retries left. If -1, retries indefinitely. + */ + private async navigateToTab( + page: Page, + retryCount: number = this.controlLocatorRetries + ): Promise { + if (this.debugConfig.controlLocation.renderFullScreen) { + return; + } + const tabName = this.debugConfig.controlLocation.tabName; + + try { + await page.waitForSelector("ul[role='tablist']"); + await page.click(`li[aria-label='${tabName}']`); + } catch (error) { + if (this.shouldRetry(retryCount)) { + await sleep(this.controlLocatorRetryTimeout); + return await this.navigateToTab(page, retryCount - 1); + } else { + await ErrorReporter.report( + this.logger, + "ControlLocation.navigateToTab.navigation", + error, + "Could not navigate to tab.", + false, + { retryCount: "" + retryCount } + ); + + this.throwErrorIfNotDisposed(error); + } + } + } + + /** + * Checks if navigation should be retried. + * @param retryCount Number of retries left. + * @returns True if retry should be performed, false otherwise. + */ + private shouldRetry(retryCount: number): boolean { + return ( + this.shouldRetryNavigation && + !this.isDisposed && + (retryCount <= -1 || retryCount > 0) + ); + } + + /** + * Disposes the control locator. + */ + public dispose(): void { + this.isDisposed = true; + if (this.shouldRetryNavigation) { + this.shouldRetryNavigation = false; + } + } +} diff --git a/src/debugger/controlLocation/index.ts b/src/debugger/controlLocation/index.ts new file mode 100644 index 00000000..077876ed --- /dev/null +++ b/src/debugger/controlLocation/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +export { ControlLocator } from "./ControlLocator"; +export { ControlLocation } from "./ControlLocation"; diff --git a/src/debugger/debugAdaptor/DebugAdaptorFactory.ts b/src/debugger/debugAdaptor/DebugAdaptorFactory.ts new file mode 100644 index 00000000..122bbde4 --- /dev/null +++ b/src/debugger/debugAdaptor/DebugAdaptorFactory.ts @@ -0,0 +1,126 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { + DebugAdapterDescriptor, + DebugAdapterDescriptorFactory, + DebugAdapterInlineImplementation, + DebugSession, + ProviderResult, + WorkspaceFolder, + debug, + workspace, +} from "vscode"; + +import { Debugger } from "./Debugger"; +import { ITelemetry } from "../../client/telemetry/ITelemetry"; +import { ErrorReporter } from "../../common/ErrorReporter"; +import { BrowserManager } from "../browser"; +import { BundleLoader } from "../BundleLoader"; +import { IPcfLaunchConfig } from "../configuration/types"; +import { RequestInterceptor } from "../RequestInterceptor"; +import { FileWatcher } from "../FileWatcher"; +import { ControlLocator } from "../controlLocation"; +import { BrowserLocator } from "../browser/BrowserLocator"; + +/** + * Factory class that creates the debugger. + */ +export class DebugAdaptorFactory implements DebugAdapterDescriptorFactory { + /** + * Creates a new DebugAdaptorFactory instance. + * @param logger The telemetry reporter to use for telemetry. + */ + constructor(private readonly logger: ITelemetry) {} + + /** + * Creates the dependencies for the debugger. + * @param session The {@link DebugSession debug session} for which the debug adapter will be used. + * @param workspaceFolder The current workspace folder for the debugger to use. + * @returns The BrowserManager instance. + */ + private createDependencyTree( + session: DebugSession, + workspaceFolder: WorkspaceFolder + ): BrowserManager { + const debugConfig = session.configuration as IPcfLaunchConfig; + + const bundleWatcher = new FileWatcher( + debugConfig.file, + workspaceFolder, + this.logger + ); + + const bundleLoader = new BundleLoader( + debugConfig.file, + workspaceFolder, + this.logger + ); + const bundleInterceptor = new RequestInterceptor( + bundleLoader, + this.logger + ); + + const controlLocator = new ControlLocator(debugConfig, this.logger); + + const browserLocator = new BrowserLocator(debugConfig, this.logger); + + return new BrowserManager( + bundleWatcher, + bundleInterceptor, + controlLocator, + browserLocator, + debugConfig, + this.logger + ); + } + + /** + * This method is called at the start of a debug session to provide details about the debug adapter to use. + * These details must be returned as objects of type {@link DebugAdapterDescriptor}. + * @param session The {@link DebugSession debug session} for which the debug adapter will be used. + * @returns A {@link DebugAdapterDescriptor debug adapter descriptor} or undefined. + */ + public async createDebugAdapterDescriptor( + session: DebugSession + ): Promise> { + const workspaceFolder = this.getWorkspaceFolder(); + if (!workspaceFolder) { + await debug.stopDebugging(); + return; + } + + const browserManager = this.createDependencyTree( + session, + workspaceFolder + ); + const debugAdaptor = new Debugger( + browserManager, + session, + workspaceFolder, + this.logger + ); + return new DebugAdapterInlineImplementation(debugAdaptor); + } + + /** + * Retrieves the current workspace folder for the debugger to use. + * @returns The current workspace folder or undefined if no workspace is open. + */ + private getWorkspaceFolder(): WorkspaceFolder | undefined { + const folders = workspace.workspaceFolders || []; + const workspaceFolder = folders[0]; + if (!workspaceFolder) { + void ErrorReporter.report( + this.logger, + "DebugAdaptorFactory.getWorkspaceFolder", + undefined, + "Could not find workspace folder for debugger. Please make sure you've opened a workspace and try again." + ); + return; + } + return workspaceFolder; + } +} diff --git a/src/debugger/debugAdaptor/DebugProtocolMessage.ts b/src/debugger/debugAdaptor/DebugProtocolMessage.ts new file mode 100644 index 00000000..cb58976f --- /dev/null +++ b/src/debugger/debugAdaptor/DebugProtocolMessage.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { DebugProtocolMessage } from "vscode"; + +/** + * Extends the {@link DebugProtocolMessage} class the [ProtocolMessage](https://microsoft.github.io/debug-adapter-protocol/specification#Base_Protocol_ProtocolMessage) type defined in the Debug Adapter Protocol. + */ +export interface ProtocolMessage extends DebugProtocolMessage { + /** + * Sequence number (also known as message ID). For protocol messages of type + * 'request' this ID can be used to cancel the request. + */ + seq: number; + + /** + * Message type. + * Values: 'request', 'response', 'event', etc. + */ + type: "request" | "response" | "event" | string; + + /** + * Message command. + */ + command: string; +} diff --git a/src/debugger/debugAdaptor/Debugger.ts b/src/debugger/debugAdaptor/Debugger.ts new file mode 100644 index 00000000..a5fce5ee --- /dev/null +++ b/src/debugger/debugAdaptor/Debugger.ts @@ -0,0 +1,327 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { + DebugAdapter, + DebugConfiguration, + DebugSession, + Disposable, + Event, + WorkspaceFolder, + debug, +} from "vscode"; +import { ITelemetry } from "../../client/telemetry/ITelemetry"; +import { ErrorReporter } from "../../common/ErrorReporter"; + +import { BrowserManager } from "../browser"; +import { IPcfLaunchConfig } from "../configuration/types/IPcfLaunchConfig"; +import { sleep } from "../utils"; + +import { ProtocolMessage } from "./DebugProtocolMessage"; + +/** + * The default number of retries to attach the debugger. + */ +const DEFAULT_DEBUGGING_RETRY_COUNT = 3; + +/** + * The default delay in ms after which to retry the attach to the debugger. + */ +const DEFAULT_DEBUGGING_RETRY_DELAY = 1000; + +/** + * Time after which to dispose the debugger if the parent session was terminated. + */ +const DEFAULT_DEBUGGING_DISPOSE_TIMEOUT = 4000; + +/** + * Control debugger that controls the msedge debug session. + */ +export class Debugger implements Disposable, DebugAdapter { + private readonly debugConfig: IPcfLaunchConfig; + private edgeDebugSession?: DebugSession; + private startDebuggingDisposable?: Disposable; + private debugSessionTerminatedDisposable?: Disposable; + public isDisposed = false; + + /** + * Flag indicating if a debug session is running. + * @returns True if a debug session is running, false otherwise. + */ + public get isRunning(): boolean { + const activeSession = debug.activeDebugSession; + return ( + activeSession !== undefined && + activeSession.id === this.edgeDebugSession?.id + ); + } + + /** + * Flag indicating if the debugger session is attached. + * @returns True if the debugger session is attached, false otherwise. + */ + public get hasAttachedDebuggerSession(): boolean { + return this.edgeDebugSession !== undefined; + } + + /** + * Creates a new Debugger instance. + * @param parentSession The parent {@link DebugSession debug session} that was started by the user. + * @param workspaceFolder The workspace folder to use for debugging. + * @param logger The telemetry reporter to use for telemetry. + * @param debuggingRetryCount The number of times to retry starting the debug session if it fails. + * @param debuggingRetryDelay The delay in ms after which to retry starting the debug session. + */ + constructor( + private readonly browserManager: BrowserManager, + private readonly parentSession: DebugSession, + private readonly workspaceFolder: WorkspaceFolder, + private readonly logger: ITelemetry, + private readonly debuggingRetryCount: number = DEFAULT_DEBUGGING_RETRY_COUNT, + private readonly debuggingRetryDelay: number = DEFAULT_DEBUGGING_RETRY_DELAY, + private readonly debuggingDisposeTimeout: number = DEFAULT_DEBUGGING_DISPOSE_TIMEOUT + ) { + this.debugConfig = parentSession.configuration as IPcfLaunchConfig; + this.browserManager.registerOnBrowserClose(async () => { + this.stopDebugging(); + }); + this.browserManager.registerOnBrowserReady(async () => { + this.attachEdgeDebugger(); + }); + + this.startDebuggingDisposable = debug.onDidStartDebugSession( + (session) => this.onDebugSessionStarted(session) + ); + } + + /** + * An event which fires after the debug adapter has sent a Debug Adapter Protocol message to the editor. + * Messages can be requests, responses, or events. + * + * *This debugger does not send messages to the editor, hence why subscribing is not supported*. + * @implements + {@link DebugAdapter.onDidSendMessage} + */ + onDidSendMessage: Event = () => { + return { + dispose: () => undefined, + }; + }; + + /** + * Handle a Debug Adapter Protocol message. + * Messages can be requests, responses, or events. + * Results or errors are returned via onSendMessage events. + * @param message A Debug Adapter Protocol message. + * @implements + {@link DebugAdapter.handleMessage} + */ + handleMessage(message: ProtocolMessage): void { + switch (message.command) { + case "disconnect": + void this.stopDebugging(); + break; + case "initialize": + void this.browserManagerLaunch(); + break; + } + } + + /** + * Asynchronously starts the browser manager so that the debugger can be attached when the bundle has been intercepted. + * The browser manager will then call {@link attachEdgeDebugger} through the {@link BrowserManager.onBrowserReady onBrowserReady} event. + */ + private async browserManagerLaunch() { + try { + await this.browserManager.launch(); + } catch (error) { + await this.stopDebugging(); + } + } + + /** + * Starts the debugging session by attaching the 'pwa-msedge' debugger to the browser started by {@link BrowserManager}. + * @param retryCount The number of times to retry starting the debug session if it fails. + */ + public async attachEdgeDebugger( + retryCount: number = this.debuggingRetryCount + ): Promise { + if (this.isDisposed) { + throw new Error("Debugger is disposed"); + } + + // don't restart the debug session if it is already running + if (this.hasAttachedDebuggerSession) { + return; + } + + this.logger.sendTelemetryEvent("Debugger.attachEdgeDebugger", { + running: "" + this.isRunning, + retryCount: `${retryCount}`, + }); + const activeSession = debug.activeDebugSession; + if (activeSession && this.isRunning) { + this.onDebugSessionStarted(activeSession); + return; + } + + const edgeDebugConfig: DebugConfiguration = { + type: "pwa-msedge", + name: `edge ${this.debugConfig.name}`, + request: "attach", + webRoot: this.debugConfig.webRoot, + port: this.debugConfig.port, + }; + + let success: boolean; + try { + success = await debug.startDebugging( + this.workspaceFolder, + edgeDebugConfig, + this.parentSession + ); + } catch (error) { + await ErrorReporter.report( + this.logger, + "Debugger.startDebugging.error", + error, + "Exception starting debug session", + false + ); + success = false; + } + + if (success) { + this.logger.sendTelemetryEvent( + "Debugger.attachEdgeDebugger.success", + { running: "" + this.isRunning, retryCount: `${retryCount}` } + ); + } else { + await this.handleStartDebuggingNonSuccess(retryCount); + } + } + + /** + * Handles a failure to attach the debugger with retries. + * @param retryCount The number of times to retry starting the debug session if it fails. + */ + private async handleStartDebuggingNonSuccess( + retryCount: number + ): Promise { + await ErrorReporter.report( + this.logger, + "Debugger.handleStartDebuggingNonSuccess", + undefined, + "Could not start debugging session. Retrying", + false, + { + retryCount: `${retryCount}` ?? "undefined", + } + ); + + if (retryCount > 0) { + await sleep(this.debuggingRetryDelay); + await this.attachEdgeDebugger(retryCount - 1); + } else { + void ErrorReporter.report( + this.logger, + "Debugger.handleStartDebuggingNonSuccess.noRetry", + undefined, + "Could not start debugging session.", + true, + { + retryCount: + `${retryCount}/${this.debuggingRetryCount}` ?? + "undefined", + } + ); + } + } + + /** + * Callback called by {@link debug.onDidStartDebugSession} when the debug session has successfully started. + * @param edgeDebugSession The {@link DebugSession debug session} that has started by attaching the 'pwa-msedge' debugger. + */ + private onDebugSessionStarted(edgeDebugSession: DebugSession): void { + // don't start the debug session if it is already running + if (this.isRunning && this.hasAttachedDebuggerSession) { + return; + } + + this.edgeDebugSession = edgeDebugSession; + this.debugSessionTerminatedDisposable = + debug.onDidTerminateDebugSession( + (session) => void this.onDebugSessionStopped(session) + ); + + this.logger.sendTelemetryEvent("Debugger.onDebugSessionStarted", { + edgeDebugSessionId: this.edgeDebugSession?.id || "undefined", + parentSessionId: this.parentSession.id || "undefined", + }); + } + + /** + * Stops the debugging session. + */ + public async stopDebugging(): Promise { + this.logger.sendTelemetryEvent("debugger.stopDebugging", { + sessionId: this.edgeDebugSession?.id || "undefined", + }); + + if (this.hasAttachedDebuggerSession || this.isRunning) { + // remove the onDebugStopped callback to prevent closing the browser + // when the debug session is stopped + await debug.stopDebugging(this.edgeDebugSession); + await debug.stopDebugging(this.parentSession); + } + + this.dispose(); + } + + /** + * Callback called by {@link debug.onDidTerminateDebugSession} for when vscode terminates the debug session. + * @param session The {@link DebugSession debug session} that has stopped. + */ + private async onDebugSessionStopped(session: DebugSession): Promise { + // Disposes the debugger if it the parent session is stopped after 4 seconds. + this.debuggingDisposeTimeout > 0 && + (await sleep(this.debuggingDisposeTimeout)); + if (this.isRunning) { + return; + } + void this.logger.sendTelemetryEvent( + "debugger.onDebugSessionStopped.stopped", + { + sessionId: session.id, + } + ); + + this.dispose(); + } + + /** + * Disposes the debugger. + */ + dispose() { + if (this.isDisposed) { + return; + } + + if (this.startDebuggingDisposable) { + this.startDebuggingDisposable.dispose(); + this.startDebuggingDisposable = undefined; + } + + if (this.debugSessionTerminatedDisposable) { + this.debugSessionTerminatedDisposable.dispose(); + this.debugSessionTerminatedDisposable = undefined; + } + + if (this.browserManager) { + this.browserManager.dispose(); + } + + this.edgeDebugSession = undefined; + this.isDisposed = true; + } +} diff --git a/src/debugger/debugAdaptor/index.ts b/src/debugger/debugAdaptor/index.ts new file mode 100644 index 00000000..50bb83ef --- /dev/null +++ b/src/debugger/debugAdaptor/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +export { DebugAdaptorFactory } from "./DebugAdaptorFactory"; diff --git a/src/debugger/extension.ts b/src/debugger/extension.ts new file mode 100644 index 00000000..9dedec8f --- /dev/null +++ b/src/debugger/extension.ts @@ -0,0 +1,48 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; + +import { LaunchDebugProvider } from "./configuration"; +import { DebugAdaptorFactory } from "./debugAdaptor"; + +import TelemetryReporter from "@vscode/extension-telemetry"; +import { EXTENSION_NAME } from "../client/constants"; +import { UserSettingsConfigManager } from "./configuration/UserSettingsConfigManager"; + +/** + * Activates the extension. + * @param context The extension context. + */ +export function activateDebugger( + context: vscode.ExtensionContext, + telemetry: TelemetryReporter +): void { + // Register the launch provider + vscode.debug.registerDebugConfigurationProvider( + `${EXTENSION_NAME}.debug`, + new LaunchDebugProvider(telemetry) + ); + + context.subscriptions.push( + vscode.debug.registerDebugAdapterDescriptorFactory( + `${EXTENSION_NAME}.debug`, + new DebugAdaptorFactory(telemetry) + ) + ); +} + +/** + * Deactivates the debugger part of the extension. + */ +export function deactivateDebugger(): void { + void vscode.debug.stopDebugging(); +} + +/** + * Checks if the experimental feature flag in the user configuration is enabled. + */ +export const shouldEnableDebugger = (): boolean => + UserSettingsConfigManager.shouldEnableDebugger(); diff --git a/src/debugger/index.ts b/src/debugger/index.ts new file mode 100644 index 00000000..e895f816 --- /dev/null +++ b/src/debugger/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +export { + activateDebugger, + deactivateDebugger, + shouldEnableDebugger, +} from "./extension"; diff --git a/src/debugger/test/helpers.ts b/src/debugger/test/helpers.ts new file mode 100644 index 00000000..cfc67b64 --- /dev/null +++ b/src/debugger/test/helpers.ts @@ -0,0 +1,207 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { EXTENSION_NAME } from "../../client/constants"; +import { IPcfLaunchConfig } from "../configuration/types"; +import * as vscode from "vscode"; +import { expect } from "chai"; +import { Browser, HTTPRequest, Page } from "puppeteer-core"; +import sinon from "sinon"; +import { FileWatcher } from "../FileWatcher"; +import { BundleLoader } from "../BundleLoader"; +import { RequestInterceptor } from "../RequestInterceptor"; +import { ControlLocator } from "../controlLocation"; +import { BrowserLocator } from "../browser/BrowserLocator"; +import { BrowserManager } from "../browser"; +import { validSourceMapBundle } from "./unit/SourceMapValidator.test"; + +export const getWorkspaceFolder = () => { + const workspace: vscode.WorkspaceFolder = { + uri: vscode.Uri.file("some/path"), + index: 0, + name: "Workspace", + }; + return workspace; +}; + +export const mockTabbedControlConfiguration: IPcfLaunchConfig = { + browserFlavor: "Default", + controlLocation: { + renderFullScreen: false, + tabName: "Control Tab", + controlName: "Control Name", + }, + file: "${workspaceFolder}/controls/my-control/out/controls/src/bundle.js", + name: "Debug Control", + port: 1234, + request: "attach", + type: `${EXTENSION_NAME}.debug`, + url: "https://ORG_URL.crm.dynamics.com/with/path", + useDefaultUserDataProfile: false, + userDataDir: undefined, + webRoot: "${workspaceFolder}/controls/my-control", +}; + +export const mockFullscreenControlConfiguration: IPcfLaunchConfig = { + ...mockTabbedControlConfiguration, + controlLocation: { + renderFullScreen: true, + controlName: "ControlName", + appId: "f96ac8ee-529f-4510-af13-3fe5ff45f2b6", + }, +}; + +export const expectThrowsAsync = async ( + fn: () => Promise +): Promise => { + let caughtError: unknown = undefined; + let result: T | undefined = undefined; + try { + result = await fn(); + } catch (error) { + caughtError = error; + } + expect( + result, + "expected function to throw but got result instead: " + + JSON.stringify(result) + ).to.be.undefined; + expect(caughtError, "expected function to throw but error was undefined.") + .to.not.be.undefined; +}; + +export const getRequest = ( + url: string, + method: string, + respondSpy: sinon.SinonSpy = sinon.spy(), + continueSpy: sinon.SinonSpy = sinon.spy() +): HTTPRequest => { + return { + method: () => method, + url: () => url, + respond: respondSpy, + continue: continueSpy, + } as unknown as HTTPRequest; +}; + +const getBundleRequest = () => + getRequest( + "https://someOrg.com/webresources/publisher.ControlName/bundle.js", + "GET" + ); + +export const getMockBrowser = ( + invokePageOnceCloseCallback: boolean, + invokeBrowserOnDisconnectedCallback: boolean, + invokePageOnRequestCallback: boolean +): Browser => { + const page = { + goto: async () => undefined, + waitForSelector: async () => undefined, + click: async () => undefined, + once: (event: string, callback: () => void) => { + event === "close" && invokePageOnceCloseCallback && callback(); + }, + on: (event: string, callback: (request: HTTPRequest) => void) => { + event === "request" && + invokePageOnRequestCallback && + callback(getBundleRequest()); + }, + setRequestInterception: () => undefined, + }; + const browser = { + pages: async () => [page], + newPage: async () => page, + version: () => "0.0.mock", + wsEndpoint: () => "mockEndpoint", + process: () => ({ pid: "mockPID" }), + close: () => undefined, + on: (event: string, callback: () => void) => { + event === "disconnected" && + invokeBrowserOnDisconnectedCallback && + callback(); + }, + } as unknown as Browser; + + return browser; +}; + +export const mockFileSystemWatcher = () => { + return sinon.stub(vscode.workspace, "createFileSystemWatcher").returns({ + onDidChange: () => ({ dispose: () => undefined }), + } as unknown as vscode.FileSystemWatcher); +}; + +export const getMockFileWatcher = (invokeOnRegister = false): FileWatcher => { + const fileWatcherMock = sinon.createStubInstance(FileWatcher); + if (invokeOnRegister) { + const mockRegister = (cb: () => Promise) => { + void cb(); + }; + fileWatcherMock.register = + fileWatcherMock.register.callsFake(mockRegister); + } + return fileWatcherMock; +}; + +export const getMockBundleLoader = ( + fileContents = validSourceMapBundle +): BundleLoader => { + const bundleLoaderMock = sinon.createStubInstance(BundleLoader); + bundleLoaderMock.loadFileContents.resolves(fileContents); + return bundleLoaderMock; +}; + +export const getMockRequestInterceptor = ( + invokeOnRegister = false +): RequestInterceptor => { + const requestInterceptorMock = sinon.createStubInstance(RequestInterceptor); + if (invokeOnRegister) { + const mockRegister = async ( + page: Page, + onRequestIntercepted?: (fileName: string) => Promise | void + ) => { + if (onRequestIntercepted) { + await onRequestIntercepted("mockFileName"); + } + }; + requestInterceptorMock.register.callsFake(mockRegister); + } + return requestInterceptorMock; +}; + +export const getMockControlLocator = (): ControlLocator => { + const controlLocatorMock = sinon.createStubInstance(ControlLocator); + return controlLocatorMock; +}; + +export const getMockBrowserLocator = ( + path = "browser/path" +): BrowserLocator => { + const browserManagerMock = sinon.createStubInstance(BrowserLocator); + browserManagerMock.getPath.resolves(path); + return browserManagerMock; +}; + +export const getMockBrowserManager = ( + invokeOnBrowserClose = false, + invokeOnBrowserReady = false +): BrowserManager => { + const browserManagerMock = sinon.createStubInstance(BrowserManager); + if (invokeOnBrowserClose) { + const mockOnBrowserClose = (cb: () => Promise) => { + void cb(); + }; + browserManagerMock.registerOnBrowserClose.callsFake(mockOnBrowserClose); + } + + if (invokeOnBrowserReady) { + const mockOnBrowserReady = (cb: () => Promise) => { + void cb(); + }; + browserManagerMock.registerOnBrowserReady.callsFake(mockOnBrowserReady); + } + return browserManagerMock; +}; diff --git a/src/debugger/test/integration/BrowserArgsBuilder.test.ts b/src/debugger/test/integration/BrowserArgsBuilder.test.ts new file mode 100644 index 00000000..bfc42805 --- /dev/null +++ b/src/debugger/test/integration/BrowserArgsBuilder.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { BrowserArgsBuilder } from "../../browser/BrowserArgsBuilder"; + +import { expect } from "chai"; + +describe("BrowserArgsBuilder", () => { + it("with user data dir undefined", () => { + const expectedArgs = [ + "--no-first-run", + "--no-default-browser-check", + `${BrowserArgsBuilder.debuggingPortArg}=1234`, + ]; + const args = new BrowserArgsBuilder(1234).build(); + expect(args).to.eql(expectedArgs); + }); + + it("with user data dir defined", () => { + const expectedArgs = [ + `${BrowserArgsBuilder.userDataDirArg}=/tmp/userDataDir`, + "--no-first-run", + "--no-default-browser-check", + `${BrowserArgsBuilder.debuggingPortArg}=1234`, + ]; + const args = new BrowserArgsBuilder(1234, "/tmp/userDataDir").build(); + expect(args).to.eql(expectedArgs); + }); +}); diff --git a/src/debugger/test/integration/BrowserManager.test.ts b/src/debugger/test/integration/BrowserManager.test.ts new file mode 100644 index 00000000..ccba3a6b --- /dev/null +++ b/src/debugger/test/integration/BrowserManager.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import sinon from "sinon"; +import { NoopTelemetryInstance } from "../../../client/telemetry/NoopTelemetry"; +import { + getMockBrowser, + getMockBrowserLocator, + getMockControlLocator, + getMockFileWatcher, + getMockRequestInterceptor, + mockTabbedControlConfiguration, +} from "../helpers"; +import { BrowserManager } from "../../browser/"; + +describe("BrowserManager", () => { + const getInstance = ( + fireBundleIntercepted: boolean, + fireOnBrowserClose: boolean, + fireOnBrowserDisconnect: boolean, + fireOnRequest: boolean + ): BrowserManager => { + const browser = getMockBrowser( + fireOnBrowserClose, + fireOnBrowserDisconnect, + fireOnRequest + ); + const bundleWatcher = getMockFileWatcher(); + const bundleInterceptor = getMockRequestInterceptor( + fireBundleIntercepted + ); + const controlLocator = getMockControlLocator(); + const browserLocator = getMockBrowserLocator(); + const puppeteerLaunchMock = async () => browser; + return new BrowserManager( + bundleWatcher, + bundleInterceptor, + controlLocator, + browserLocator, + mockTabbedControlConfiguration, + NoopTelemetryInstance, + puppeteerLaunchMock + ); + }; + + it("calls onBrowserReady when bundle intercepted", async () => { + const instance = getInstance(true, false, false, false); + const browserReadyStub = sinon.spy(); + instance.registerOnBrowserReady(browserReadyStub); + await instance.launch(); + sinon.assert.calledOnce(browserReadyStub); + }); + + it("calls onBrowserClose when browser closed", async () => { + const instance = getInstance(false, true, false, false); + const browserCloseStub = sinon.spy(); + instance.registerOnBrowserClose(browserCloseStub); + await instance.launch(); + sinon.assert.calledOnce(browserCloseStub); + }); +}); diff --git a/src/debugger/test/integration/BundleLoader.test.ts b/src/debugger/test/integration/BundleLoader.test.ts new file mode 100644 index 00000000..ef0d87a4 --- /dev/null +++ b/src/debugger/test/integration/BundleLoader.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { NoopTelemetryInstance } from "../../../client/telemetry/NoopTelemetry"; +import { BundleLoader } from "../../BundleLoader"; +import { expectThrowsAsync, getWorkspaceFolder } from "../helpers"; +import { TextDocument } from "vscode"; +import { expect } from "chai"; +import sinon from "sinon"; +import { ErrorReporter } from "../../../common/ErrorReporter"; +import { + missingSourceMapBundle, + validSourceMapBundle, +} from "../unit/SourceMapValidator.test"; + +describe("BundleLoader", () => { + const mockFilePath = "mockFilePath"; + + const getOpenTextDocumentMock = ( + hasSourceMap: boolean, + rejects = false + ): (() => Promise) => { + const bundleContents = hasSourceMap + ? validSourceMapBundle + : missingSourceMapBundle; + return async () => { + if (rejects) { + throw new Error(); + } + return { + getText: () => bundleContents, + } as TextDocument; + }; + }; + + it("returns file contents", async () => { + const instance = new BundleLoader( + mockFilePath, + getWorkspaceFolder(), + NoopTelemetryInstance, + getOpenTextDocumentMock(true) + ); + + const fileContents = await instance.loadFileContents(); + expect(fileContents).to.equal(validSourceMapBundle); + }); + + it("warns if no source map", async () => { + const reporterSpy = sinon.spy(ErrorReporter, "report"); + const instance = new BundleLoader( + mockFilePath, + getWorkspaceFolder(), + NoopTelemetryInstance, + getOpenTextDocumentMock(false) + ); + await instance.loadFileContents(); + sinon.assert.calledWith( + reporterSpy, + sinon.match.any, + "RequestInterceptor.warnIfNoSourceMap.error" + ); + reporterSpy.restore(); + }); + + it("throws error if load fails", () => { + const instance = new BundleLoader( + mockFilePath, + getWorkspaceFolder(), + NoopTelemetryInstance, + getOpenTextDocumentMock(false, true) + ); + + expectThrowsAsync(() => instance.loadFileContents()); + }); +}); diff --git a/src/debugger/test/integration/ControlLocator.test.ts b/src/debugger/test/integration/ControlLocator.test.ts new file mode 100644 index 00000000..db1c9105 --- /dev/null +++ b/src/debugger/test/integration/ControlLocator.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { Page } from "puppeteer-core"; +import sinon from "sinon"; +import { NoopTelemetryInstance } from "../../../client/telemetry/NoopTelemetry"; +import { ControlLocator } from "../../controlLocation"; +import { + expectThrowsAsync, + mockFullscreenControlConfiguration, + mockTabbedControlConfiguration, +} from "../helpers"; + +describe("ControlLocator", () => { + const pageGotoStub = sinon.stub(); + const pageWaitForSelectorStub = sinon.stub(); + const pageClickStub = sinon.stub(); + + const getPageMock = ( + gotoSucceeds = true, + waitForSelectorSucceeds = true, + pageClickSpySucceeds = true + ) => { + gotoSucceeds + ? pageGotoStub.resolves() + : pageGotoStub.rejects(new Error()); + waitForSelectorSucceeds + ? pageWaitForSelectorStub.resolves() + : pageWaitForSelectorStub.rejects(new Error()); + pageClickSpySucceeds + ? pageClickStub.resolves() + : pageClickStub.rejects(new Error()); + return { + goto: pageGotoStub, + waitForSelector: pageWaitForSelectorStub, + click: pageClickStub, + } as unknown as Page; + }; + + afterEach(() => { + pageGotoStub.resetHistory(); + pageWaitForSelectorStub.resetHistory(); + pageClickStub.resetHistory(); + }); + + it("navigates to fullscreen control", async () => { + const expectedUrl = + "https://ORG_URL.crm.dynamics.com/main.aspx?appid=f96ac8ee-529f-4510-af13-3fe5ff45f2b6&pagetype=control&controlName=ControlName"; + const instance = new ControlLocator( + mockFullscreenControlConfiguration, + NoopTelemetryInstance, + 0, + 0 + ); + const page = getPageMock(); + await instance.navigateToControl(page); + sinon.assert.calledWith(pageGotoStub, expectedUrl); + }); + + it("navigates to tab", async () => { + const expectedUrl = "https://ORG_URL.crm.dynamics.com/with/path"; + const expectedTabSelector = "li[aria-label='Control Tab']"; + const instance = new ControlLocator( + mockTabbedControlConfiguration, + NoopTelemetryInstance, + 0, + 0 + ); + const page = getPageMock(); + await instance.navigateToControl(page); + sinon.assert.calledWith(pageGotoStub, expectedUrl); + sinon.assert.calledWith(pageClickStub, expectedTabSelector); + }); + + describe("no retry", () => { + const instance = new ControlLocator( + mockTabbedControlConfiguration, + NoopTelemetryInstance, + 0, + 0 + ); + it("does not retry goto if retry count is 0", async () => { + const page = getPageMock(false); + + await expectThrowsAsync(() => instance.navigateToControl(page)); + sinon.assert.calledOnce(pageGotoStub); + }); + + it("does not retry wait for selector if retry count is 0", async () => { + const page = getPageMock(true, false, false); + + await expectThrowsAsync(() => instance.navigateToControl(page)); + sinon.assert.calledOnce(pageWaitForSelectorStub); + }); + }); + + describe("1 retry", () => { + const instance = new ControlLocator( + mockTabbedControlConfiguration, + NoopTelemetryInstance, + 0, + 1 + ); + it("goto fail", async () => { + const page = getPageMock(false); + await expectThrowsAsync(() => instance.navigateToControl(page)); + sinon.assert.calledTwice(pageGotoStub); + sinon.assert.notCalled(pageWaitForSelectorStub); + sinon.assert.notCalled(pageClickStub); + }); + + it("wait for selector fails", async () => { + const page = getPageMock(true, false, false); + await expectThrowsAsync(() => instance.navigateToControl(page)); + sinon.assert.calledTwice(pageWaitForSelectorStub); + sinon.assert.notCalled(pageClickStub); + }); + + it("click fails", async () => { + const page = getPageMock(true, true, false); + await expectThrowsAsync(() => instance.navigateToControl(page)); + sinon.assert.calledTwice(pageWaitForSelectorStub); + sinon.assert.calledTwice(pageClickStub); + }); + }); +}); diff --git a/src/debugger/test/integration/Debugger.test.ts b/src/debugger/test/integration/Debugger.test.ts new file mode 100644 index 00000000..f66cf627 --- /dev/null +++ b/src/debugger/test/integration/Debugger.test.ts @@ -0,0 +1,196 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { DebugSession } from "vscode"; +import { NoopTelemetryInstance } from "../../../client/telemetry/NoopTelemetry"; +import sinon from "sinon"; +import * as vscode from "vscode"; +import { Debugger } from "../../debugAdaptor/Debugger"; +import { expect } from "chai"; +import { BrowserManager } from "../../browser/BrowserManager"; +import { ProtocolMessage } from "../../debugAdaptor/DebugProtocolMessage"; +import { + expectThrowsAsync, + getMockBrowserManager, + getWorkspaceFolder, + mockFileSystemWatcher, + mockTabbedControlConfiguration, +} from "../helpers"; + +describe("Debugger", () => { + let instance: Debugger; + let browserManagerInstance: BrowserManager; + let attachEdgeDebuggerSpy: sinon.SinonSpy<[retryCount?: number | undefined], Promise>; + const activeDebugSessionStub = sinon.stub( + vscode.debug, + "activeDebugSession" + ); + const startDebuggingStub = sinon.stub(vscode.debug, "startDebugging"); + const onDidStartDebugSessionStub = sinon.stub( + vscode.debug, + "onDidStartDebugSession" + ); + + const parentSession = { + id: "parentSessionId", + configuration: mockTabbedControlConfiguration, + } as unknown as DebugSession; + + const workspace = getWorkspaceFolder(); + const fileSystemWatcherStub = mockFileSystemWatcher(); + let onDebugSessionStartedCallback: (session: DebugSession) => void = () => + undefined; + + after(() => { + fileSystemWatcherStub.restore(); + startDebuggingStub.restore(); + activeDebugSessionStub.restore(); + onDidStartDebugSessionStub.restore(); + attachEdgeDebuggerSpy.restore(); + }); + + /** + * Creates a new testing instance of the debugger. + * @param invokeOnBrowserClose Calls {@link Debugger.stopDebugging} + * @param invokeOnBrowserReady Calls {@link Debugger.attachEdgeDebugger} + * @returns + */ + const initializeDebuggerInstance = ( + invokeOnBrowserClose: boolean, + invokeOnBrowserReady: boolean + ): Debugger => { + browserManagerInstance = getMockBrowserManager( + invokeOnBrowserClose, + invokeOnBrowserReady + ); + const instance = new Debugger( + browserManagerInstance, + parentSession, + workspace, + NoopTelemetryInstance, + 1, + 0, + 0 + ); + attachEdgeDebuggerSpy = sinon.spy(instance, "attachEdgeDebugger"); + return instance; + }; + + beforeEach(() => { + startDebuggingStub.reset(); + startDebuggingStub.resetHistory(); + onDidStartDebugSessionStub.reset(); + activeDebugSessionStub.reset(); + onDidStartDebugSessionStub.callsFake((cb) => { + onDebugSessionStartedCallback = cb; + return { dispose: () => undefined }; + }); + }); + + const mockDebugSessionStart = async (startSucceeds = true) => { + activeDebugSessionStub.get(() => + startSucceeds ? parentSession : undefined + ); + startDebuggingStub.resolves(startSucceeds); + + instance = initializeDebuggerInstance(false, false); + await instance.attachEdgeDebugger(1); + + if (startSucceeds) { + onDebugSessionStartedCallback(parentSession); + } + return startDebuggingStub; + }; + + const expectDebuggerToBeInState = (instance: Debugger, state: boolean) => { + expect(instance.isRunning).to.equal(state); + expect(instance.hasAttachedDebuggerSession).to.equal(state); + }; + + describe("starts", () => { + it("starts the debugger", async () => { + const startDebuggingStub = await mockDebugSessionStart(); + sinon.assert.calledOnce(startDebuggingStub); + expectDebuggerToBeInState(instance, true); + }); + + it("doesn't restart if already running", async () => { + const startDebuggingStub = await mockDebugSessionStart(); + sinon.assert.calledOnce(startDebuggingStub); + expectDebuggerToBeInState(instance, true); + startDebuggingStub.resetHistory(); + await instance.attachEdgeDebugger(); + sinon.assert.notCalled(startDebuggingStub); + }); + + it("onBrowserReady starts debugger", () => { + initializeDebuggerInstance(false, true); + sinon.assert.calledOnce(startDebuggingStub); + }); + + it("retries starting debugger if it fails", async () => { + await mockDebugSessionStart(false); + sinon.assert.calledTwice(attachEdgeDebuggerSpy); + expectDebuggerToBeInState(instance, false); + }); + + it("throws if started after being disposed", async () => { + instance.dispose(); + await expectThrowsAsync(() => instance.attachEdgeDebugger()); + }); + }); + + describe("stops", () => { + const stopDebuggingStub = sinon + .stub(vscode.debug, "stopDebugging") + .resolves(); + + afterEach(() => { + stopDebuggingStub.resetHistory(); + }); + + after(() => { + startDebuggingStub.restore(); + }); + + it("disposes if onDidTerminateDebugSession is called", async () => { + let invokeOnTerminateDebugSession: (e: DebugSession) => void = () => + undefined; + const onDidTerminateDebugSessionStub = sinon + .stub(vscode.debug, "onDidTerminateDebugSession") + .callsFake((cb) => { + invokeOnTerminateDebugSession = cb; + return { dispose: () => undefined }; + }); + await mockDebugSessionStart(); + + // simulate session stop + activeDebugSessionStub.get(() => undefined); + invokeOnTerminateDebugSession(parentSession); + expect(instance.isDisposed).to.be.true; + onDidTerminateDebugSessionStub.restore(); + }); + + it("on disconnect message", async () => { + await mockDebugSessionStart(); + expectDebuggerToBeInState(instance, true); + + instance.handleMessage({ + command: "disconnect", + } as ProtocolMessage); + sinon.assert.called(stopDebuggingStub); + stopDebuggingStub.restore(); + }); + }); + + describe("handleMessage", () => { + it("calls launch on initialize command", async () => { + const launchSpy = browserManagerInstance.launch as sinon.SinonStub<[], Promise>; + instance.handleMessage({ command: "initialize" } as ProtocolMessage); + sinon.assert.calledOnce(launchSpy); + launchSpy.restore(); + }); + }); +}); diff --git a/src/debugger/test/integration/RequestInterceptor.test.ts b/src/debugger/test/integration/RequestInterceptor.test.ts new file mode 100644 index 00000000..fe5470e8 --- /dev/null +++ b/src/debugger/test/integration/RequestInterceptor.test.ts @@ -0,0 +1,130 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { RequestInterceptor } from "../../RequestInterceptor"; +import { NoopTelemetryInstance } from "../../../client/telemetry/NoopTelemetry"; +import { HTTPRequest, Page } from "puppeteer-core"; +import sinon from "sinon"; +import { getMockBundleLoader, getRequest } from "../helpers"; + +describe("RequestInterceptor", () => { + let instance: RequestInterceptor; + let puppeteerPage: Page; + const setRequestInterceptionSpy = sinon.spy(); + const mockBundleContents = "mock bundle contents"; + + beforeEach(() => { + instance = new RequestInterceptor( + getMockBundleLoader(mockBundleContents), + NoopTelemetryInstance + ); + + puppeteerPage = { + setRequestInterception: setRequestInterceptionSpy, + on: () => ({}), + } as unknown as Page; + + setRequestInterceptionSpy.resetHistory(); + }); + + describe("register", () => { + it("should set setRequestInterception to true on register", async () => { + await instance.register(puppeteerPage, () => undefined); + sinon.assert.calledWith(setRequestInterceptionSpy, true); + }); + + it("should not register twice", async () => { + await instance.register(puppeteerPage, () => undefined); + await instance.register(puppeteerPage, () => undefined); + sinon.assert.calledOnce(setRequestInterceptionSpy); + }); + }); + + describe("onRequest", () => { + const doMockRequestForUrl = async ( + url: string, + method: string, + respondSpy: sinon.SinonSpy, + continueSpy: sinon.SinonSpy, + onRequestInterceptedSpy: sinon.SinonSpy> = sinon.spy() + ) => { + let onRequestCallback: (request: HTTPRequest) => void = () => + undefined; + puppeteerPage = { + setRequestInterception: setRequestInterceptionSpy, + on: ( + eventName: string, + callback: (request: HTTPRequest) => void + ) => { + onRequestCallback = callback; + }, + } as unknown as Page; + + await instance.register(puppeteerPage, onRequestInterceptedSpy); + const request = getRequest(url, method, respondSpy, continueSpy); + + onRequestCallback(request); + }; + + it("responds with local bundle if request for bundle", async () => { + const requestRespondSpy = sinon.spy(); + const requestContinueSpy = sinon.spy(); + await doMockRequestForUrl( + "https://someOrg.com/webresources/publisher.ControlName/bundle.js", + "GET", + requestRespondSpy, + requestContinueSpy + ); + + sinon.assert.notCalled(requestContinueSpy); + sinon.assert.calledWith(requestRespondSpy, { + status: 200, + body: mockBundleContents, + contentType: "text/javascript", + }); + }); + + it("calls onRequestIntercepted on bundle interception", async () => { + const requestInterceptedSpy = sinon.spy(); + await doMockRequestForUrl( + "https://someOrg.com/webresources/publisher.ControlName/bundle.js", + "GET", + sinon.spy(), + sinon.spy(), + requestInterceptedSpy + ); + + sinon.assert.calledOnce(requestInterceptedSpy); + }); + + it("continues if not bundle", async () => { + const requestRespondSpy = sinon.spy(); + const requestContinueSpy = sinon.spy(); + await doMockRequestForUrl( + "https://someOrg.com/somethingElse", + "GET", + requestRespondSpy, + requestContinueSpy + ); + + sinon.assert.notCalled(requestRespondSpy); + sinon.assert.calledOnce(requestContinueSpy); + }); + + it("continues if not GET request", async () => { + const requestRespondSpy = sinon.spy(); + const requestContinueSpy = sinon.spy(); + await doMockRequestForUrl( + "https://someOrg.com/somethingElse", + "POST", + requestRespondSpy, + requestContinueSpy + ); + + sinon.assert.notCalled(requestRespondSpy); + sinon.assert.calledOnce(requestContinueSpy); + }); + }); +}); diff --git a/src/debugger/test/integration/index.ts b/src/debugger/test/integration/index.ts new file mode 100644 index 00000000..d645bdc7 --- /dev/null +++ b/src/debugger/test/integration/index.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as path from "path"; +import Mocha from "mocha"; +import glob from "glob"; + +export function run(): Promise { + // Create the mocha test + const mocha = new Mocha({ + ui: "bdd", + color: true, + }); + + const testsRoot = path.resolve(__dirname, ".."); + + return new Promise((c, e) => { + glob("**/**.test.js", { cwd: testsRoot }, (err, files) => { + if (err) { + return e(err); + } + + // Add files to the test suite + files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); + + try { + // Run the mocha test + mocha.run((failures) => { + if (failures > 0) { + e(new Error(`${failures} tests failed.`)); + } else { + c(); + } + }); + } catch (err) { + console.error(err); + e(err); + } + }); + }); +} diff --git a/src/debugger/test/runTest.ts b/src/debugger/test/runTest.ts new file mode 100644 index 00000000..8c2a8883 --- /dev/null +++ b/src/debugger/test/runTest.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as path from "path"; + +import { runTests } from "@vscode/test-electron"; + +async function main() { + try { + // The folder containing the Extension Manifest package.json + // Passed to `--extensionDevelopmentPath` + const extensionDevelopmentPath = path.resolve(__dirname, "../../../"); + + // The path to test runner + // Passed to --extensionTestsPath + const extensionTestsPath = path.resolve( + __dirname, + "./integration/index" + ); + + // Download VS Code, unzip it and run the integration test + await runTests({ extensionDevelopmentPath, extensionTestsPath }); + } catch (err) { + console.error("Failed to run tests"); + process.exit(1); + } +} + +exports.main = main; diff --git a/src/debugger/test/unit/SourceMapValidator.test.ts b/src/debugger/test/unit/SourceMapValidator.test.ts new file mode 100644 index 00000000..6f44c6ae --- /dev/null +++ b/src/debugger/test/unit/SourceMapValidator.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { expect } from "chai"; +import { SourceMapValidator } from "../../SourceMapValidator"; + +export const validSourceMapBundle = + "/******//******/ })(); //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9u"; +export const missingSourceMapBundle = "/******//******/ })();"; +export const urlSourceMapBundle = "//# sourceMappingURL=main.js.map"; + +describe("SourceMapValidator", () => { + it("should return true if the file contains a source map", () => { + expect(SourceMapValidator.isValid(validSourceMapBundle)).to.be.true; + }); + + it("should return false if the file does not contain a source map", () => { + expect(SourceMapValidator.isValid(missingSourceMapBundle)).to.be.false; + }); + + it("should return false if source map is url", () => { + expect(SourceMapValidator.isValid(urlSourceMapBundle)).to.be.false; + }); +}); diff --git a/src/debugger/test/unit/utils.test.ts b/src/debugger/test/unit/utils.test.ts new file mode 100644 index 00000000..8d1f48d1 --- /dev/null +++ b/src/debugger/test/unit/utils.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { expect } from "chai"; +import { + removeTrailingSlash, + replaceWorkSpaceFolderPlaceholder, +} from "../../utils"; + +describe("utils", () => { + describe("removeTrailingSlash", () => { + it("should remove a '/' from the end of the specified string if it exists", () => { + expect(removeTrailingSlash("/")).to.equal(""); + expect(removeTrailingSlash("test/")).to.equal("test"); + expect(removeTrailingSlash("test.com/path/test/")).to.equal( + "test.com/path/test" + ); + }); + + it("should not remove slashes in the beginning", () => { + expect(removeTrailingSlash("/test/test")).to.equal("/test/test"); + }); + }); + + describe("replaceWorkSpaceFolderPlaceholder", () => { + it("should handle empty string", () => { + expect(replaceWorkSpaceFolderPlaceholder("")).to.equal(""); + }); + + it("should handle string without placeholder", () => { + expect(replaceWorkSpaceFolderPlaceholder("test")).to.equal("test"); + }); + it("should replace the workspaceFolder placeholder in a specified path", () => { + expect( + replaceWorkSpaceFolderPlaceholder("${workspaceFolder}/test") + ).to.equal("test"); + expect( + replaceWorkSpaceFolderPlaceholder("${workspaceFolder}/test/") + ).to.equal("test/"); + expect( + replaceWorkSpaceFolderPlaceholder( + "${workspaceFolder}/test/test" + ) + ).to.equal("test/test"); + }); + }); +}); diff --git a/src/debugger/utils.ts b/src/debugger/utils.ts new file mode 100644 index 00000000..5de9325e --- /dev/null +++ b/src/debugger/utils.ts @@ -0,0 +1,33 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +/** + * Remove a '/' from the end of the specified string if it exists. + * @param uri The string from which to remove the trailing slash (if any). + * @returns The string without the trailing slash. + */ +export function removeTrailingSlash(uri: string): string { + return uri.endsWith("/") ? uri.slice(0, -1) : uri; +} + +/** + * Replaces the workspaceFolder placeholder in a specified path, returns the + * given path with file disk path. + * @param customPath The path that will be replaced. + * @returns The path with the workspaceFolder placeholder replaced. + */ +export function replaceWorkSpaceFolderPlaceholder(customPath: string) { + return customPath.replace("${workspaceFolder}/", ""); +} + +/** + * Sleep for the specified number of milliseconds. + * @param ms The number of milliseconds to sleep. + * @returns A promise that resolves after the specified number of milliseconds. + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + diff --git a/tsconfig.json b/tsconfig.json index 8da25c57..f41f11ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,4 +19,4 @@ "node_modules", ".vscode-test-web" ] -} +} \ No newline at end of file