diff --git a/.eslintignore b/.eslintignore index ada2efa3..44f86779 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,4 +2,5 @@ node_modules dist out scripts -**/*.js \ No newline at end of file +vscode-extension-tester +**/*.js diff --git a/package-lock.json b/package-lock.json index b7d127b4..41d1f570 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,10 @@ "version": "0.3.1", "hasInstallScript": true, "license": "MIT", + "workspaces": [ + "vscode-extension-tester/page-objects", + "vscode-extension-tester/locators" + ], "dependencies": { "axios": "^1.6.7", "node-stream-zip": "^1.15.0", @@ -16,11 +20,16 @@ }, "devDependencies": { "@types/chai": "^4.3.12", + "@types/clone-deep": "^4.0.4", + "@types/fs-extra": "^11.0.4", "@types/glob": "^8.1.0", + "@types/got": "^9.6.12", "@types/gulp": "^4.0.17", + "@types/js-yaml": "^4.0.9", "@types/mocha": "^9.1.1", "@types/node": "^18.19.24", "@types/sinon": "^10.0.20", + "@types/targz": "^1.0.4", "@types/vscode": "^1.87.0", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", @@ -29,24 +38,31 @@ "@vscode/test-electron": "^2.3.9", "@vscode/vsce": "^2.24.0", "chai": "^4.4.1", + "clone-deep": "^4.0.1", "eslint": "^8.57.0", "eslint-config-prettier": "^8.10.0", "eslint-plugin-license-header": "^0.4.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-security": "^1.7.1", + "fs-extra": "^11.2.0", "glob": "^8.1.0", + "got": "^14.2.1", + "hpagent": "^1.2.0", "husky": "^8.0.3", + "js-yaml": "^4.1.0", "lint-staged": "^13.3.0", "mocha": "^9.2.2", "prettier": "^2.8.8", "rimraf": "^3.0.2", + "sanitize-filename": "^1.6.3", "sinon": "^14.0.2", + "targz": "^1.0.1", + "ts-essentials": "^9.4.1", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths-webpack-plugin": "^3.5.2", "typescript": "^4.9.5", "vinyl-fs": "^4.0.0", - "vscode-extension-tester": "5.10.0", "vscode-nls-dev": "^4.0.4", "webpack": "^5.90.3", "webpack-cli": "^4.10.0" @@ -355,12 +371,12 @@ } }, "node_modules/@sindresorhus/is": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", - "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-6.2.0.tgz", + "integrity": "sha512-yM/IGPkVnYGblhDosFBwq0ZGdnVSBkNV4onUtipGMOjZd4kB6GAu3ys91aftSbyMHh6A2GPdt+KDI5NoWP63MQ==", "dev": true, "engines": { - "node": ">=14.16" + "node": ">=16" }, "funding": { "url": "https://github.com/sindresorhus/is?sponsor=1" @@ -461,6 +477,12 @@ "integrity": "sha512-zNKDHG/1yxm8Il6uCCVsm+dRdEsJlFoDu73X17y09bId6UwoYww+vFBsAcRzl8knM1sab3Dp1VRikFQwDOtDDw==", "dev": true }, + "node_modules/@types/clone-deep": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/clone-deep/-/clone-deep-4.0.4.tgz", + "integrity": "sha512-vXh6JuuaAha6sqEbJueYdh5zNBPPgG1OYumuz2UvLvriN6ABHDSW8ludREGWJb1MLIzbwZn4q4zUbUCerJTJfA==", + "dev": true + }, "node_modules/@types/eslint": { "version": "8.56.5", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.5.tgz", @@ -493,6 +515,16 @@ "integrity": "sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==", "dev": true }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "dev": true, + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, "node_modules/@types/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", @@ -514,6 +546,31 @@ "@types/streamx": "*" } }, + "node_modules/@types/got": { + "version": "9.6.12", + "resolved": "https://registry.npmjs.org/@types/got/-/got-9.6.12.tgz", + "integrity": "sha512-X4pj/HGHbXVLqTpKjA2ahI4rV/nNBc9mGO2I/0CgAra+F2dKgMXnENv2SRpemScBzBAI4vMelIVYViQxlSE6xA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.0" + } + }, + "node_modules/@types/got/node_modules/form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, "node_modules/@types/gulp": { "version": "4.0.17", "resolved": "https://registry.npmjs.org/@types/gulp/-/gulp-4.0.17.tgz", @@ -532,6 +589,12 @@ "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", "dev": true }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -544,6 +607,15 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", + "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/minimatch": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", @@ -610,6 +682,40 @@ "@types/node": "*" } }, + "node_modules/@types/tar-fs": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/tar-fs/-/tar-fs-2.0.4.tgz", + "integrity": "sha512-ipPec0CjTmVDWE+QKr9cTmIIoTl7dFG/yARCM5MqK8i6CNLIG1P8x4kwDsOQY1ChZOZjH0wO9nvfgBvWl4R3kA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/tar-stream": "*" + } + }, + "node_modules/@types/tar-stream": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-3.1.3.tgz", + "integrity": "sha512-Zbnx4wpkWBMBSu5CytMbrT5ZpMiF55qgM+EpHzR4yIDu7mv52cej8hTkOc6K+LzpkOAbxwn/m7j3iO+/l42YkQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/targz": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/targz/-/targz-1.0.4.tgz", + "integrity": "sha512-4i2weIjweWsnrvutLH7dM/+FPVSFSqxb+XKWo61tAiHxyYYHveImqys5JijMboKJz+jhFu24SlFrdVAB0xAMIw==", + "dev": true, + "dependencies": { + "@types/tar-fs": "*" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true + }, "node_modules/@types/undertaker": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/@types/undertaker/-/undertaker-1.2.11.tgz", @@ -1429,28 +1535,6 @@ } ] }, - "node_modules/big-integer": { - "version": "1.6.52", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", - "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", - "dev": true, - "engines": { - "node": ">=0.6" - } - }, - "node_modules/binary": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", - "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", - "dev": true, - "dependencies": { - "buffers": "~0.1.1", - "chainsaw": "~0.1.0" - }, - "engines": { - "node": "*" - } - }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -1472,12 +1556,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/bluebird": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", - "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", - "dev": true - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -1606,24 +1684,6 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, - "node_modules/buffer-indexof-polyfill": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", - "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", - "dev": true, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/buffers": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", - "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", - "dev": true, - "engines": { - "node": ">=0.2.0" - } - }, "node_modules/cacheable-lookup": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", @@ -1741,18 +1801,6 @@ "node": ">=4" } }, - "node_modules/chainsaw": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", - "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", - "dev": true, - "dependencies": { - "traverse": ">=0.3.0 <0.4" - }, - "engines": { - "node": "*" - } - }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -1891,7 +1939,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz", "integrity": "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==", - "dev": true, "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", @@ -1908,7 +1955,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", @@ -1931,7 +1977,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, "engines": { "node": ">=16" }, @@ -1943,7 +1988,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, "engines": { "node": ">=16.17.0" } @@ -1952,7 +1996,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "engines": { "node": ">=14" }, @@ -2072,7 +2115,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "dev": true, "dependencies": { "is-plain-object": "^2.0.4", "kind-of": "^6.0.2", @@ -2182,8 +2224,7 @@ "node_modules/compare-versions": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.0.tgz", - "integrity": "sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg==", - "dev": true + "integrity": "sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg==" }, "node_modules/concat-map": { "version": "0.0.1", @@ -2200,8 +2241,7 @@ "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, "node_modules/create-require": { "version": "1.1.1", @@ -2213,7 +2253,6 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -2461,45 +2500,6 @@ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "dev": true }, - "node_modules/duplexer2": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", - "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", - "dev": true, - "dependencies": { - "readable-stream": "^2.0.2" - } - }, - "node_modules/duplexer2/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/duplexer2/node_modules/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 - }, - "node_modules/duplexer2/node_modules/string_decoder": { - "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, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3217,12 +3217,12 @@ } }, "node_modules/form-data-encoder": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", - "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.0.2.tgz", + "integrity": "sha512-KQVhvhK8ZkWzxKxOr56CPulAhH3dobtuQ4+hNQ+HekH/Wp5gSOafqRAeTphQUJAIk0GBvHZgJ2ZGRWd5kphMuw==", "dev": true, "engines": { - "node": ">= 14.17" + "node": ">= 18" } }, "node_modules/from": { @@ -3241,7 +3241,6 @@ "version": "11.2.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", - "dev": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -3284,53 +3283,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/fstream": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", - "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "inherits": "~2.0.0", - "mkdirp": ">=0.5 0", - "rimraf": "2" - }, - "engines": { - "node": ">=0.6" - } - }, - "node_modules/fstream/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fstream/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3533,35 +3485,46 @@ } }, "node_modules/got": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/got/-/got-13.0.0.tgz", - "integrity": "sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/got/-/got-14.2.1.tgz", + "integrity": "sha512-KOaPMremmsvx6l9BLC04LYE6ZFW4x7e4HkTe3LwBmtuYYQwpeS4XKqzhubTIkaQ1Nr+eXxeori0zuwupXMovBQ==", "dev": true, "dependencies": { - "@sindresorhus/is": "^5.2.0", + "@sindresorhus/is": "^6.1.0", "@szmarczak/http-timer": "^5.0.1", "cacheable-lookup": "^7.0.0", - "cacheable-request": "^10.2.8", + "cacheable-request": "^10.2.14", "decompress-response": "^6.0.0", - "form-data-encoder": "^2.1.2", - "get-stream": "^6.0.1", - "http2-wrapper": "^2.1.10", + "form-data-encoder": "^4.0.2", + "get-stream": "^8.0.1", + "http2-wrapper": "^2.2.1", "lowercase-keys": "^3.0.0", - "p-cancelable": "^3.0.0", + "p-cancelable": "^4.0.1", "responselike": "^3.0.0" }, "engines": { - "node": ">=16" + "node": ">=20" }, "funding": { "url": "https://github.com/sindresorhus/got?sponsor=1" } }, + "node_modules/got/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -3798,8 +3761,7 @@ "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "dev": true + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" }, "node_modules/import-fresh": { "version": "3.3.0", @@ -3858,8 +3820,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "1.3.8", @@ -3914,7 +3875,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, "bin": { "is-docker": "cli.js" }, @@ -3962,7 +3922,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, "dependencies": { "is-docker": "^3.0.0" }, @@ -4016,7 +3975,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, "dependencies": { "isobject": "^3.0.1" }, @@ -4028,7 +3986,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -4061,7 +4018,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "dev": true, "dependencies": { "is-inside-container": "^1.0.0" }, @@ -4076,7 +4032,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/is64bit/-/is64bit-2.0.0.tgz", "integrity": "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==", - "dev": true, "dependencies": { "system-architecture": "^0.1.0" }, @@ -4090,20 +4045,17 @@ "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -4222,7 +4174,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, "dependencies": { "universalify": "^2.0.0" }, @@ -4234,7 +4185,6 @@ "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "dev": true, "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", @@ -4246,7 +4196,6 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -4260,14 +4209,12 @@ "node_modules/jszip/node_modules/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==" }, "node_modules/jszip/node_modules/string_decoder": { "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, "dependencies": { "safe-buffer": "~5.1.0" } @@ -4303,7 +4250,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -4343,7 +4289,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "dev": true, "dependencies": { "immediate": "~3.0.5" } @@ -4414,12 +4359,6 @@ "node": ">=16" } }, - "node_modules/listenercount": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", - "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", - "dev": true - }, "node_modules/listr2": { "version": "6.6.1", "resolved": "https://registry.npmjs.org/listr2/-/listr2-6.6.1.tgz", @@ -4692,8 +4631,7 @@ "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" }, "node_modules/merge2": { "version": "1.4.1", @@ -4752,7 +4690,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, "engines": { "node": ">=12" }, @@ -5000,23 +4937,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/monaco-page-objects": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/monaco-page-objects/-/monaco-page-objects-3.13.1.tgz", - "integrity": "sha512-YKjoeGs/XLJ7KRT4xSEeNTLYtXnZOJylWVSyGExMs5sketAK/6uW6IXEmwhzOxW37Ty/H/zc03yJrGwVis2idw==", - "dev": true, - "dependencies": { - "clipboardy": "^4.0.0", - "clone-deep": "^4.0.1", - "compare-versions": "^6.1.0", - "fs-extra": "^11.2.0", - "ts-essentials": "^9.4.1" - }, - "peerDependencies": { - "selenium-webdriver": "^4.6.1", - "typescript": ">=4.6.2" - } - }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -5172,7 +5092,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, "dependencies": { "path-key": "^4.0.0" }, @@ -5187,7 +5106,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, "engines": { "node": ">=12" }, @@ -5229,7 +5147,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, "dependencies": { "mimic-fn": "^4.0.0" }, @@ -5258,12 +5175,12 @@ } }, "node_modules/p-cancelable": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", - "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz", + "integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==", "dev": true, "engines": { - "node": ">=12.20" + "node": ">=14.16" } }, "node_modules/p-limit": { @@ -5308,8 +5225,7 @@ "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, "node_modules/parent-module": { "version": "1.0.1", @@ -5397,7 +5313,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -5409,12 +5324,12 @@ "dev": true }, "node_modules/path-scurry": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", - "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.2.tgz", + "integrity": "sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==", "dev": true, "dependencies": { - "lru-cache": "^9.1.1 || ^10.0.0", + "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { @@ -5632,8 +5547,7 @@ "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "node_modules/proxy-from-env": { "version": "1.1.0", @@ -6106,7 +6020,7 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.18.1.tgz", "integrity": "sha512-uP4OJ5wR4+VjdTi5oi/k8oieV2fIhVdVuaOPrklKghgS59w7Zz3nGa5gcG73VcU9EBRv5IZEBRhPr7qFJAj5mQ==", - "dev": true, + "peer": true, "dependencies": { "jszip": "^3.10.1", "tmp": "^0.2.1", @@ -6160,14 +6074,12 @@ "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "dev": true + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" }, "node_modules/shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "dev": true, "dependencies": { "kind-of": "^6.0.2" }, @@ -6179,7 +6091,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -6191,7 +6102,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -6552,7 +6462,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, "engines": { "node": ">=12" }, @@ -6600,7 +6509,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz", "integrity": "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==", - "dev": true, "engines": { "node": ">=18" }, @@ -6847,7 +6755,6 @@ "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", - "dev": true, "engines": { "node": ">=14.14" } @@ -6882,15 +6789,6 @@ "node": ">=10.13.0" } }, - "node_modules/traverse": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", - "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -6904,7 +6802,6 @@ "version": "9.4.1", "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-9.4.1.tgz", "integrity": "sha512-oke0rI2EN9pzHsesdmrOrnqv1eQODmJpd/noJjwj2ZPC3Z4N2wbjrOEqnsEgmvlO2+4fBb0a794DCna2elEVIQ==", - "dev": true, "peerDependencies": { "typescript": ">=4.1.0" }, @@ -7240,7 +7137,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7271,59 +7168,10 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "engines": { "node": ">= 10.0.0" } }, - "node_modules/unzipper": { - "version": "0.10.14", - "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", - "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", - "dev": true, - "dependencies": { - "big-integer": "^1.6.17", - "binary": "~0.3.0", - "bluebird": "~3.4.1", - "buffer-indexof-polyfill": "~1.0.0", - "duplexer2": "~0.1.4", - "fstream": "^1.0.12", - "graceful-fs": "^4.2.2", - "listenercount": "~1.0.1", - "readable-stream": "~2.3.6", - "setimmediate": "~1.0.4" - } - }, - "node_modules/unzipper/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/unzipper/node_modules/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 - }, - "node_modules/unzipper/node_modules/string_decoder": { - "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, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/update-browserslist-db": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", @@ -7378,8 +7226,7 @@ "node_modules/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==" }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", @@ -7502,100 +7349,13 @@ "node": ">=10.13.0" } }, - "node_modules/vscode-extension-tester": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/vscode-extension-tester/-/vscode-extension-tester-5.10.0.tgz", - "integrity": "sha512-9tltf+hlwNTvi7XXjA7oH1kcTrIDOLqGB/5d+W6CSwTRMXHwxeIw4JUgKlriuy65elr06YB5IVpSBgn+fs4X5A==", - "dev": true, - "dependencies": { - "@types/selenium-webdriver": "^4.1.17", - "@vscode/vsce": "^2.21.1", - "commander": "^11.0.0", - "compare-versions": "^6.1.0", - "fs-extra": "^11.1.0", - "glob": "^10.3.10", - "got": "^13.0.0", - "hpagent": "^1.2.0", - "js-yaml": "^4.1.0", - "monaco-page-objects": "^3.10.0", - "sanitize-filename": "^1.6.3", - "selenium-webdriver": "^4.13.0", - "targz": "^1.0.1", - "unzipper": "^0.10.14", - "vscode-extension-tester-locators": "^3.8.0" - }, - "bin": { - "extest": "out/cli.js" - }, - "peerDependencies": { - "mocha": ">=5.2.0", - "typescript": ">=4.6.2" - } - }, "node_modules/vscode-extension-tester-locators": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/vscode-extension-tester-locators/-/vscode-extension-tester-locators-3.11.0.tgz", - "integrity": "sha512-Fo38bb/CuVlVATOGv2nPs26jxlA7u6Ds0niuhT/41+ZVwfl9UUmQHy/bcWWBgcoJLn/8ReMVCFs15rrq0J9pzA==", - "dev": true, - "peerDependencies": { - "monaco-page-objects": "^3.13.0", - "selenium-webdriver": "^4.6.1" - } + "resolved": "vscode-extension-tester/locators", + "link": true }, - "node_modules/vscode-extension-tester/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/vscode-extension-tester/node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", - "dev": true, - "engines": { - "node": ">=16" - } - }, - "node_modules/vscode-extension-tester/node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/vscode-extension-tester/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "node_modules/vscode-extension-tester-monaco-page-objects": { + "resolved": "vscode-extension-tester/page-objects", + "link": true }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", @@ -7946,7 +7706,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -8115,7 +7874,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", - "dev": true, + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -8297,6 +8056,182 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "vscode-extension-tester/locators": { + "name": "vscode-extension-tester-locators", + "devDependencies": { + "@types/node": "^18.19.21", + "@types/selenium-webdriver": "^4.1.22", + "rimraf": "^5.0.5", + "typescript": "5.4.2" + }, + "peerDependencies": { + "selenium-webdriver": ">=4.6.1", + "vscode-extension-tester-monaco-page-objects": "*" + } + }, + "vscode-extension-tester/locators/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "vscode-extension-tester/locators/node_modules/glob": { + "version": "10.3.12", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", + "integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.6", + "minimatch": "^9.0.1", + "minipass": "^7.0.4", + "path-scurry": "^1.10.2" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "vscode-extension-tester/locators/node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "vscode-extension-tester/locators/node_modules/rimraf": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz", + "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==", + "dev": true, + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "vscode-extension-tester/locators/node_modules/typescript": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", + "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "vscode-extension-tester/page-objects": { + "name": "vscode-extension-tester-monaco-page-objects", + "dependencies": { + "clipboardy": "^4.0.0", + "clone-deep": "^4.0.1", + "compare-versions": "^6.1.0", + "fs-extra": "^11.2.0", + "ts-essentials": "^9.4.1" + }, + "devDependencies": { + "@types/clone-deep": "^4.0.4", + "@types/fs-extra": "^11.0.4", + "@types/node": "^18.19.21", + "@types/selenium-webdriver": "^4.1.22", + "rimraf": "^5.0.5", + "typescript": "*" + }, + "peerDependencies": { + "selenium-webdriver": ">=4.6.1", + "typescript": ">=4.6.2" + } + }, + "vscode-extension-tester/page-objects/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "vscode-extension-tester/page-objects/node_modules/glob": { + "version": "10.3.12", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", + "integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.6", + "minimatch": "^9.0.1", + "minipass": "^7.0.4", + "path-scurry": "^1.10.2" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "vscode-extension-tester/page-objects/node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "vscode-extension-tester/page-objects/node_modules/rimraf": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz", + "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==", + "dev": true, + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } } } } diff --git a/package.json b/package.json index 04cf3436..5536f9b2 100644 --- a/package.json +++ b/package.json @@ -357,7 +357,9 @@ "build": "npm run build:pq-test-result-view", "copy:i18n": "ts-node scripts/addI18n.ts", "package": "npm run clean && npm run build && webpack --mode production && npm run copy:i18n", - "compile-tests": "rimraf out && tsc -p . --outDir out", + "compile-tests:page-objects": "npm run build --workspace=vscode-extension-tester-monaco-page-objects", + "compile-tests:locators": "npm run build --workspace=vscode-extension-tester-locators", + "compile-tests": "npm run compile-tests:page-objects && npm run compile-tests:locators && rimraf out && tsc -p . --outDir out", "watch-tests": "rimraf out && tsc -p . -w --outDir out", "pretest": "rimraf out && npm run compile-tests && npm run compile && npm run lint", "audit": "npm audit --omit=dev", @@ -377,11 +379,16 @@ }, "devDependencies": { "@types/chai": "^4.3.12", + "@types/clone-deep": "^4.0.4", + "@types/fs-extra": "^11.0.4", "@types/glob": "^8.1.0", "@types/gulp": "^4.0.17", + "@types/got": "^9.6.12", + "@types/js-yaml": "^4.0.9", "@types/mocha": "^9.1.1", "@types/node": "^18.19.24", "@types/sinon": "^10.0.20", + "@types/targz": "^1.0.4", "@types/vscode": "^1.87.0", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", @@ -390,27 +397,34 @@ "@vscode/test-electron": "^2.3.9", "@vscode/vsce": "^2.24.0", "chai": "^4.4.1", + "clone-deep": "^4.0.1", "eslint": "^8.57.0", "eslint-config-prettier": "^8.10.0", "eslint-plugin-license-header": "^0.4.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-security": "^1.7.1", + "fs-extra": "^11.2.0", + "got": "^14.2.1", + "hpagent": "^1.2.0", + "js-yaml": "^4.1.0", "glob": "^8.1.0", "husky": "^8.0.3", "lint-staged": "^13.3.0", "mocha": "^9.2.2", "prettier": "^2.8.8", "rimraf": "^3.0.2", + "sanitize-filename": "^1.6.3", "sinon": "^14.0.2", + "ts-essentials": "^9.4.1", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths-webpack-plugin": "^3.5.2", "typescript": "^4.9.5", "vinyl-fs": "^4.0.0", - "vscode-extension-tester": "5.10.0", "vscode-nls-dev": "^4.0.4", "webpack": "^5.90.3", - "webpack-cli": "^4.10.0" + "webpack-cli": "^4.10.0", + "targz": "^1.0.1" }, "lint-staged": { "!(src)/**/*.{ts}": [ @@ -421,5 +435,9 @@ "hooks": { "pre-commit": "lint-staged" } - } + }, + "workspaces": [ + "vscode-extension-tester/page-objects", + "vscode-extension-tester/locators" + ] } diff --git a/scripts/test-e2e.ts b/scripts/test-e2e.ts index 77ed5309..c06ae4de 100644 --- a/scripts/test-e2e.ts +++ b/scripts/test-e2e.ts @@ -7,7 +7,7 @@ import * as path from "path"; import * as os from "os"; -import { ExTester } from "vscode-extension-tester"; +import { ExTester } from "./tester/exTester"; import { getFirstVsixFileDirectlyBeneathOneDirectory } from "./utils/vsixs"; const theVsixFilePath: string = getFirstVsixFileDirectlyBeneathOneDirectory(process.cwd()); @@ -19,7 +19,7 @@ async function doE2eTest() { const extTest = new ExTester(testerResourceFolder); // Performs all necessary setup: getting VSCode + ChromeDriver into the test instance - await extTest.downloadCode(); + await extTest.downloadCode('stable'); await extTest.downloadChromeDriver(); // Install the extension into the test instance of VS Code diff --git a/scripts/tester/browser.ts b/scripts/tester/browser.ts new file mode 100644 index 00000000..a872d942 --- /dev/null +++ b/scripts/tester/browser.ts @@ -0,0 +1,195 @@ +'use strict'; + +import * as path from 'path'; +import * as fs from 'fs-extra'; +import { compareVersions } from 'compare-versions'; +import { WebDriver, Builder, until, initPageObjects, logging, By, Browser } from 'vscode-extension-tester-monaco-page-objects'; +import { Options, ServiceBuilder } from 'selenium-webdriver/chrome'; +import { getLocatorsPath } from 'vscode-extension-tester-locators'; +import { CodeUtil, ReleaseQuality } from './util/codeUtil'; +import { DEFAULT_STORAGE_FOLDER } from './exTester'; +import { DriverUtil } from './util/driverUtil'; + +export class VSBrowser { + static readonly baseVersion = '1.37.0'; + static readonly browserName = 'vscode'; + private storagePath: string; + private extensionsFolder: string | undefined; + private customSettings: Object; + private _driver!: WebDriver; + private codeVersion: string; + private releaseType: ReleaseQuality; + private logLevel: logging.Level; + private static _instance: VSBrowser; + + constructor(codeVersion: string, releaseType: ReleaseQuality, customSettings: Object = {}, logLevel: logging.Level = logging.Level.INFO) { + this.storagePath = process.env.TEST_RESOURCES ? process.env.TEST_RESOURCES : path.resolve(DEFAULT_STORAGE_FOLDER); + this.extensionsFolder = process.env.EXTENSIONS_FOLDER ? process.env.EXTENSIONS_FOLDER : undefined; + this.customSettings = customSettings; + this.codeVersion = codeVersion; + this.releaseType = releaseType; + + this.logLevel = logLevel; + + VSBrowser._instance = this; + }; + + /** + * Starts the vscode browser from a given path + * @param codePath path to code binary + */ + async start(codePath: string): Promise { + const userSettings = path.join(this.storagePath, 'settings', 'User'); + if (fs.existsSync(userSettings)) { + fs.removeSync(path.join(this.storagePath, 'settings')); + } + let defaultSettings = { + "workbench.editor.enablePreview": false, + "workbench.startupEditor": "none", + "window.titleBarStyle": "custom", + "window.commandCenter": false, + "window.dialogStyle": "custom", + "window.restoreFullscreen": true, + "window.newWindowDimensions": "maximized", + "security.workspace.trust.enabled": false, + "files.simpleDialog.enable": true, + "terminal.integrated.copyOnSelection": true + }; + if (Object.keys(this.customSettings).length > 0) { + console.log('Detected user defined code settings'); + defaultSettings = { ...defaultSettings, ...this.customSettings }; + } + + fs.mkdirpSync(path.join(userSettings, 'globalStorage')); + await fs.remove(path.join(this.storagePath, 'screenshots')); + fs.writeJSONSync(path.join(userSettings, 'settings.json'), defaultSettings); + console.log(`Writing code settings to ${path.join(userSettings, 'settings.json')}`); + + const args = ['--no-sandbox', '--disable-dev-shm-usage', `--user-data-dir=${path.join(this.storagePath, 'settings')}`]; + + if (this.extensionsFolder) { + args.push(`--extensions-dir=${this.extensionsFolder}`); + } + + if (compareVersions(this.codeVersion, '1.39.0') < 0) { + if (process.platform === 'win32') { + fs.copyFileSync(path.resolve(__dirname, '..', '..', 'resources', 'state.vscdb'), path.join(userSettings, 'globalStorage', 'state.vscdb')); + } + args.push(`--extensionDevelopmentPath=${process.cwd()}`); + } + + let options = new Options().setChromeBinaryPath(codePath).addArguments(...args) as any; + options['options_'].windowTypes = ['webview']; + options = options as Options; + + const prefs = new logging.Preferences(); + prefs.setLevel(logging.Type.DRIVER, this.logLevel); + options.setLoggingPrefs(prefs); + + const driverBinary = process.platform === 'win32' ? 'chromedriver.exe' : 'chromedriver'; + let chromeDriverBinaryPath = path.join(this.storagePath, driverBinary); + if(this.codeVersion >= '1.86.0') { + chromeDriverBinaryPath = path.join(this.storagePath, `chromedriver-${DriverUtil.getChromeDriverPlatform()}`, driverBinary); + } + + console.log('Launching browser...'); + this._driver = await new Builder() + .setChromeService(new ServiceBuilder(chromeDriverBinaryPath)) + .forBrowser(Browser.CHROME) + .setChromeOptions(options) + .build(); + VSBrowser._instance = this; + + initPageObjects(this.codeVersion, VSBrowser.baseVersion, getLocatorsPath(), this._driver, VSBrowser.browserName); + return this; + } + + /** + * Returns a reference to the underlying instance of Webdriver + */ + get driver(): WebDriver { + return this._driver; + } + + /** + * Returns the vscode version as string + */ + get version(): string { + return this.codeVersion; + } + + /** + * Returns an instance of VSBrowser + */ + static get instance(): VSBrowser { + return VSBrowser._instance; + } + + /** + * Waits until parts of the workbench are loaded + */ + async waitForWorkbench(timeout = 30000): Promise { + // Workaround/patch for https://github.com/redhat-developer/vscode-extension-tester/issues/466 + try { + await this._driver.wait(until.elementLocated(By.className('monaco-workbench')), timeout, `Workbench was not loaded properly after ${timeout} ms.`); + } catch (err) { + if((err as Error).name === 'WebDriverError') { + await new Promise(res => setTimeout(res, 3000)); + } else { + throw err; + } + } + } + + /** + * Terminates the webdriver/browser + */ + async quit(): Promise { + const entries = await this._driver.manage().logs().get(logging.Type.DRIVER); + const logFile = path.join(this.storagePath, 'test.log'); + const stream = fs.createWriteStream(logFile, { flags: 'w' }); + entries.forEach(entry => { + stream.write(`[${new Date(entry.timestamp).toLocaleTimeString()}][${entry.level.name}] ${entry.message}`); + }); + stream.end(); + + console.log('Shutting down the browser'); + await this._driver.quit(); + } + + /** + * Take a screenshot of the browser + * @param name file name of the screenshot without extension + */ + async takeScreenshot(name: string): Promise { + const data = await this._driver.takeScreenshot(); + const dir = path.join(this.storagePath, 'screenshots'); + fs.mkdirpSync(dir); + fs.writeFileSync(path.join(dir, `${name}.png`), data, 'base64'); + } + + /** + * Get a screenshots folder path + * @returns string path to the screenshots folder + */ + getScreenshotsDir(): string { + return path.join(this.storagePath, 'screenshots'); + } + + /** + * Open folder(s) or file(s) in the current instance of vscode. + * + * @param paths path(s) of folder(s)/files(s) to open as varargs + * @returns Promise resolving when all selected resources are opened and the workbench reloads + */ + async openResources(...paths: string[]): Promise { + if (paths.length === 0) { + return; + } + + const code = new CodeUtil(this.storagePath, this.releaseType, this.extensionsFolder); + code.open(...paths); + await new Promise(res => setTimeout(res, 3000)); + await this.waitForWorkbench(); + } +} diff --git a/scripts/tester/exTester.ts b/scripts/tester/exTester.ts new file mode 100644 index 00000000..8b8bef89 --- /dev/null +++ b/scripts/tester/exTester.ts @@ -0,0 +1,184 @@ +'use strict'; + +import { CodeUtil, DEFAULT_RUN_OPTIONS, ReleaseQuality, RunOptions } from './util/codeUtil'; +import { DriverUtil } from './util/driverUtil'; +import * as fs from 'fs-extra'; +import * as path from 'path'; +import * as os from 'os'; +import { URL } from 'url'; + +export { ReleaseQuality } +export { MochaOptions } from 'mocha'; +export * from './browser'; +export * from './suite/mochaHooks'; +export * from 'vscode-extension-tester-monaco-page-objects'; + +export interface SetupOptions { + /** version of VS Code to test against, defaults to latest */ + vscodeVersion?: string; + /** when true run `vsce package` with the `--yarn` flag */ + useYarn?: boolean; + /** install the extension's dependencies from the marketplace. Defaults to `false`. */ + installDependencies?: boolean; +} + +export const DEFAULT_SETUP_OPTIONS = { + vscodeVersion: 'latest', + installDependencies: false +} + +export const DEFAULT_STORAGE_FOLDER = process.env.TEST_RESOURCES ? process.env.TEST_RESOURCES : path.join(os.tmpdir(), 'test-resources'); + +export const VSCODE_VERSION_MIN = '1.85.2'; +export const VSCODE_VERSION_MAX = '1.87.2'; + +/** + * The latest version with automated tests + */ +export const NODEJS_VERSION_MAX = '18'; + +/** + * ExTester + */ +export class ExTester { + private code: CodeUtil; + private chrome: DriverUtil; + + constructor(storageFolder: string = DEFAULT_STORAGE_FOLDER, releaseType: ReleaseQuality = ReleaseQuality.Stable, extensionsDir?: string) { + this.code = new CodeUtil(storageFolder, releaseType, extensionsDir); + this.chrome = new DriverUtil(storageFolder); + + if (process.versions.node.slice(0, 2) > NODEJS_VERSION_MAX) { + console.log( + '\x1b[33m%s\x1b[0m', + `\nWarning: You are using the untested NodeJS version '${process.versions.node}'. The latest supported version is '${NODEJS_VERSION_MAX}.x.x'.\n\t We recommend to use tested version to have ExTester working properly.\n\n` + ); + } + } + + /** + * Download VSCode of given version and release quality stream + * @param version version to download, default latest + */ + async downloadCode(version: string = 'latest'): Promise { + await this.code.downloadAndUnzipVSCode(version); + } + + /** + * Install the extension into the test instance of VS Code + * @param vsixFile path to extension .vsix file. If not set, default vsce path will be used + * @param useYarn when true run `vsce package` with the `--yarn` flag + */ + async installVsix({vsixFile, useYarn, installDependencies}: {vsixFile?: string, useYarn?: boolean, installDependencies?: boolean} = {}): Promise { + let target = vsixFile; + if (vsixFile) { + try { + const uri = new URL(vsixFile); + if (!(process.platform === 'win32' && /^\w:/.test(uri.protocol))) { + target = path.basename(vsixFile); + } + } catch (err) { + if (!fs.existsSync(vsixFile)) { + throw new Error(`File ${vsixFile} does not exist`); + } + } + if (target !== vsixFile) { + target = await this.code.downloadExtension(vsixFile); + } + } else { + await this.code.packageExtension(useYarn); + } + this.code.installExtension(target); + if (installDependencies) { + this.code.installDependencies(); + } + } + + /** + * Install an extension from VS Code marketplace into the test instance + * @param id id of the extension to install + */ + async installFromMarketplace(id: string): Promise { + return this.code.installExtension(undefined, id); + } + + /** + * Download the matching chromedriver for a given VS Code version + * @param vscodeVersion selected versio nof VSCode, default latest + */ + async downloadChromeDriver(vscodeVersion: string = 'latest'): Promise { + const chromiumVersion = await this.code.getChromiumVersion(loadCodeVersion(vscodeVersion)); + return await this.chrome.downloadChromeDriverForChromiumVersion(chromiumVersion); + } + + /** + * Performs all necessary setup: getting VSCode + ChromeDriver + * and packaging/installing extension into the test instance + * + * @param options Additional options for setting up the tests + */ + async setupRequirements(options: SetupOptions = DEFAULT_SETUP_OPTIONS, offline = false): Promise { + const { useYarn, vscodeVersion, installDependencies } = options; + + const vscodeParsedVersion = loadCodeVersion(vscodeVersion); + if (!offline) { + await this.downloadCode(vscodeParsedVersion); + await this.downloadChromeDriver(vscodeParsedVersion); + } else { + console.log('Attempting Setup in offline mode'); + const expectedChromeVersion = (this.code.checkOfflineRequirements()).split('.')[0]; + const actualChromeVersion = (await this.chrome.checkDriverVersionOffline(vscodeParsedVersion)).split('.')[0]; + if (expectedChromeVersion !== actualChromeVersion) { + console.log('\x1b[33m%s\x1b[0m', `WARNING: Local copy of VS Code runs Chromium version ${expectedChromeVersion}, the installed ChromeDriver is version ${actualChromeVersion}.`) + console.log(`Attempting with ChromeDriver ${actualChromeVersion} anyway. Tests may experience issues due to version mismatch.`); + } + } + await this.installVsix({useYarn}); + if (installDependencies && !offline) { + this.code.installDependencies(); + } + } + + /** + * Performs requirements setup and runs extension tests + * + * @param testFilesPattern glob pattern(s) for test files to run + * @param vscodeVersion version of VS Code to test against, defaults to latest + * @param setupOptions Additional options for setting up the tests + * @param runOptions Additional options for running the tests + * + * @returns Promise resolving to the mocha process exit code - 0 for no failures, 1 otherwise + */ + async setupAndRunTests(testFilesPattern: string | string[], vscodeVersion: string = 'latest', setupOptions: Omit = DEFAULT_SETUP_OPTIONS, runOptions: Omit = DEFAULT_RUN_OPTIONS): Promise { + await this.setupRequirements({...setupOptions, vscodeVersion}, runOptions.offline); + return await this.runTests(testFilesPattern, {...runOptions, vscodeVersion}); + } + + /** + * Runs the selected test files in VS Code using mocha and webdriver + * @param testFilesPattern glob pattern(s) for selected test files + * @param runOptions Additional options for running the tests + * + * @returns Promise resolving to the mocha process exit code - 0 for no failures, 1 otherwise + */ + async runTests(testFilesPattern: string | string[], runOptions: RunOptions = DEFAULT_RUN_OPTIONS): Promise { + runOptions.vscodeVersion = loadCodeVersion(runOptions.vscodeVersion); + const patterns = (typeof testFilesPattern === 'string') ? ([testFilesPattern]) : (testFilesPattern); + return await this.code.runTests(patterns, runOptions); + } +} + +export function loadCodeVersion(version: string | undefined): string { + const code_version = process.env.CODE_VERSION ? process.env.CODE_VERSION : version; + + if (code_version !== undefined) { + if (code_version.toLowerCase() === 'max') { + return VSCODE_VERSION_MAX; + } + if (code_version.toLowerCase() === 'min') { + return VSCODE_VERSION_MIN; + } + return code_version; + } + return 'latest'; +} diff --git a/scripts/tester/suite/VSRunner.ts b/scripts/tester/suite/VSRunner.ts new file mode 100644 index 00000000..f6b5a028 --- /dev/null +++ b/scripts/tester/suite/VSRunner.ts @@ -0,0 +1,163 @@ +'use strict'; + +import { VSBrowser } from '../browser'; +import * as fs from 'fs-extra'; +import Mocha from 'mocha'; +import * as glob from 'glob'; +import { CodeUtil, ReleaseQuality } from '../util/codeUtil'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import sanitize from 'sanitize-filename'; +import { logging } from 'selenium-webdriver'; +import * as os from 'os'; + +/** + * Mocha runner wrapper + */ +export class VSRunner { + private mocha: Mocha; + private chromeBin: string; + private customSettings: Object; + private codeVersion: string; + private cleanup: boolean; + private tmpLink = path.join(os.tmpdir(), 'extest-code'); + private releaseType: ReleaseQuality; + + constructor(bin: string, codeVersion: string, customSettings: Object = {}, cleanup: boolean = false, releaseType: ReleaseQuality, config?: string) { + const conf = this.loadConfig(config); + this.mocha = new Mocha(conf); + this.chromeBin = bin; + this.customSettings = customSettings; + this.codeVersion = codeVersion; + this.cleanup = cleanup; + this.releaseType = releaseType; + } + + /** + * Set up mocha suite, add vscode instance handling, run tests + * @param testFilesPattern glob pattern of test files to run + * @param logLevel The logging level for the Webdriver + * @return The exit code of the mocha process + */ + runTests(testFilesPattern: string[], code: CodeUtil, logLevel: logging.Level = logging.Level.INFO, resources: string[]): Promise { + return new Promise(resolve => { + let self = this; + let browser: VSBrowser = new VSBrowser(this.codeVersion, this.releaseType, this.customSettings, logLevel); + + const testFiles = new Set(); + for (const pattern of testFilesPattern) { + const universalPattern = pattern.replace(/'/g, ''); + glob.sync(universalPattern).reverse() + .forEach((val) => testFiles.add(val)); + } + + testFiles.forEach((file) => this.mocha.addFile(file)); + this.mocha.suite.afterEach(async function () { + if (this.currentTest && this.currentTest.state !== 'passed') { + try { + const filename = sanitize(this.currentTest.fullTitle()); + await browser.takeScreenshot(filename); + } catch (err) { + console.log('Screenshot capture failed.', err); + } + } + }); + + this.mocha.suite.beforeAll(async function () { + this.timeout(180000); + const start = Date.now(); + const binPath = process.platform === 'darwin' ? await self.createShortcut(code.getCodeFolder(), self.tmpLink) : self.chromeBin; + await browser.start(binPath); + await browser.openResources(...resources); + await browser.waitForWorkbench(); + await new Promise((res) => { setTimeout(res, 3000); }); + console.log(`Browser ready in ${Date.now() - start} ms`); + console.log('Launching tests...'); + }); + + this.mocha.suite.afterAll(async function () { + this.timeout(180000); + await browser.quit(); + if (process.platform === 'darwin') { + if (await fs.pathExists(self.tmpLink)) { + try { + fs.unlinkSync(self.tmpLink); + } catch (err) { + console.log(err); + } + } + } + + code.uninstallExtension(self.cleanup); + }); + + this.mocha.run((failures) => { + process.exitCode = failures ? 1 : 0; + if(process.exitCode) { + console.log( + '\x1b[33m%s\x1b[0m', + `INFO: Screenshots of failures can be found in: ${browser.getScreenshotsDir()}\n` + ); + } + resolve(process.exitCode); + }); + }); + } + + private async createShortcut(src: string, dest: string): Promise { + try { + await fs.ensureSymlink(src, dest, 'dir'); + } catch (err) { + return this.chromeBin; + } + + const dir = path.parse(src); + const segments = this.chromeBin.split(path.sep); + const newSegments = dest.split(path.sep); + + let found = false; + for (let i = 0; i < segments.length; i++) { + if (!found) { + found = segments[i] === dir.base; + } else { + newSegments.push(segments[i]); + } + } + return path.join(dir.root, ...newSegments); + } + + private loadConfig(config?: string): Mocha.MochaOptions { + const defaultFiles = ['.mocharc.js', '.mocharc.json', '.mocharc.yml', '.mocharc.yaml'] + let conf: Mocha.MochaOptions = {}; + let file = config; + if (!config) { + file = path.resolve('.') + for (let i = 0; i < defaultFiles.length; i++) { + if (fs.existsSync(path.join(file, defaultFiles[i]))) { + file = path.join(file, defaultFiles[i]); + break; + } + } + } + + if (file && fs.existsSync(file) && fs.statSync(file).isFile()) { + console.log(`Loading mocha configuration from ${file}`); + if (/\.(yml|yaml)$/.test(file)) { + try { + conf = yaml.load(fs.readFileSync(file, 'utf-8')) as Mocha.MochaOptions; + } catch (err) { + console.log('Invalid mocha configuration file, will be ignored'); + } + } else if (/\.(js|json)$/.test(file)) { + try { + conf = require(path.resolve(file)); + } catch (err) { + console.log('Invalid mocha configuration file, will be ignored'); + } + } else { + console.log('Unsupported mocha configuration file extension, make sure to use .js, .json, .yml or .yaml file'); + } + } + return conf; + } +} diff --git a/scripts/tester/suite/mochaHooks.ts b/scripts/tester/suite/mochaHooks.ts new file mode 100644 index 00000000..8d493506 --- /dev/null +++ b/scripts/tester/suite/mochaHooks.ts @@ -0,0 +1,156 @@ +import { Func } from 'mocha'; +import { VSBrowser } from '../exTester'; +import sanitize from 'sanitize-filename'; + +type HookType = 'before' | 'beforeEach' | 'after' | 'afterEach'; + +function vscodeBefore(fn: Function): void; +function vscodeBefore(name: string, fn: Function): void; +function vscodeBefore(name?: any, fn?: any): void { + callHook('before', name, fn); +} + +function vscodeBeforeEach(fn: Function): void; +function vscodeBeforeEach(name: string, fn: Function): void; +function vscodeBeforeEach(name?: any, fn?: any): void { + callHook('beforeEach', name, fn); +} + +function vscodeAfter(fn: Function): void; +function vscodeAfter(name: string, fn: Function): void; +function vscodeAfter(name?: any, fn?: any): void { + callHook('after', name, fn); +} + + +function vscodeAfterEach(fn: Function): void; +function vscodeAfterEach(name: string, fn: Function): void; +function vscodeAfterEach(name?: any, fn?: any): void { + callHook('afterEach', name, fn); +} + +/** + * Create new function which wraps original function. The wrapper function + * will be able to create screenshots in case of test crashes. + * @param hookType hook name + * @param fn callback function + * @returns wrapped function capable of doing screenshots on callback failure + */ +function createScreenshotCallbackFunction(name: string | undefined, hookType: HookType, fn: Function): Func { + const alternativeFileName: string = createAlternativeFileName(hookType); + + return async function (this: Mocha.Context) { + try { + await fn.call(this); + } + catch (e) { + if (this === undefined) throw e; + if (this.test === undefined) { + try { + await VSBrowser.instance.takeScreenshot(sanitize(alternativeFileName)); + } + catch (screenshotError) { + console.error(`Could not take screenshot. this.test is undefined. Reason:\n${screenshotError}\n\n`); + } + throw e; + } + + try { + const titlePath = this.test.titlePath(); + await VSBrowser.instance.takeScreenshot(sanitize(titlePath.join('.'))); + } + catch (screenshotError) { + console.error(`Could not take screenshot. Reason:\n${screenshotError}\n\n`); + } + throw e; + } + } +} + +/** + * Call Mocha hook function with given arguments. + * @param hookType hook name + * @param firstArgument hook description or a callback + * @param secondArgument callback to be called or undefined + */ +function callHook(hookType: HookType, firstArgument: string | Function | undefined, secondArgument: Function | undefined): void { + /* Disallowed combinations */ + if (typeof firstArgument === 'function' && secondArgument !== undefined) { + throw new Error(`${hookType}(func1, func2) If the first argument is a function, then the second argument must be undefined.`); + } + if (typeof firstArgument === 'string' && secondArgument === undefined) { + throw new Error(`${hookType}(${firstArgument}) required callback function as seconds argument.`); + } + if (firstArgument === undefined && secondArgument === undefined) { + throw new Error(`${hookType}() requires at least callback function.`); + } + /* Remaining 2 combinations are valid. eg. before(callback) and before(name, callback). */ + const name = (typeof firstArgument === 'string') ? (firstArgument) : (undefined); + const fn = (typeof firstArgument === 'function') ? (firstArgument) : (secondArgument); + + const hook = getHookFunction(hookType); + const callback = fn ? fn : (() => { }); + + if (name !== undefined) { + hook(name, createScreenshotCallbackFunction(name, hookType, callback)); + } + else { + hook(createScreenshotCallbackFunction(name, hookType, callback)); + } +} + +/** + * Get hook function from hook name. + * @param hookType name of wanted hook function + * @returns hook function + */ +function getHookFunction(hookType: HookType): Mocha.HookFunction { + switch (hookType) { + case 'before': return before; + case 'beforeEach': return beforeEach; + case 'after': return after; + case 'afterEach': return afterEach; + default: + throw new Error(`Unknown hook type "${hookType}".`); + } +} + +/** + * Create number generator [1..]. + */ +function* createScreenshotNameGenerator(): Generator { + let counter = 1; + + while (true) { + yield counter; + counter++; + } +} + +/** + * Create alternative filename if given hook does not have name. + * @param hookType hook name + * @returns alternative file name without extension + */ +function createAlternativeFileName(hookType: HookType): string { + const generator = screenshotNameGenerators.get(hookType); + + if (generator) { + return `${hookType} ${generator.next().value}`; + } + else { + throw new Error(`Unknown mocha hook type "${hookType}".`); + } +} + +/** + * Create number generator for each callback. + */ +const screenshotNameGenerators = new Map(Object.entries({ + "before": createScreenshotNameGenerator(), + "beforeEach": createScreenshotNameGenerator(), + "after": createScreenshotNameGenerator(), + "afterEach": createScreenshotNameGenerator() +})); + +export { vscodeBefore as before, vscodeBeforeEach as beforeEach, vscodeAfter as after, vscodeAfterEach as afterEach }; diff --git a/scripts/tester/util/codeUtil.ts b/scripts/tester/util/codeUtil.ts new file mode 100644 index 00000000..21e80b24 --- /dev/null +++ b/scripts/tester/util/codeUtil.ts @@ -0,0 +1,393 @@ +'use strict'; + +import { downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath } from "@vscode/test-electron"; +import * as child_process from 'child_process'; +import * as fs from 'fs-extra'; +import * as path from 'path'; +import * as vsce from '@vscode/vsce'; +import { VSRunner } from "../suite/VSRunner"; +import { logging } from "selenium-webdriver"; +import { Download } from './download'; +import { DEFAULT_STORAGE_FOLDER } from '../exTester'; + +export enum ReleaseQuality { + Stable = 'stable', + Insider = 'insider' +} + +export interface RunOptions { + /** version of VS Code to test against, defaults to latest */ + vscodeVersion?: string; + /** path to custom settings json file */ + settings?: string; + /** remove the extension's directory as well (if present) */ + cleanup?: boolean; + /** path to a custom mocha configuration file */ + config?: string; + /** logging level of the Webdriver */ + logLevel?: logging.Level; + /** try to perform all setup without internet connection, needs all requirements pre-downloaded manually */ + offline?: boolean; + /** list of resources to be opened by VS Code */ + resources: string[]; +} + +/** defaults for the [[RunOptions]] */ +export const DEFAULT_RUN_OPTIONS = { + vscodeVersion: 'latest', + settings: '', + logLevel: logging.Level.INFO, + offline: false, + resources: [] +} + +/** + * Handles the VS Code instance used for testing. + * Includes downloading, unpacking, launching, and version checks. + */ +export class CodeUtil { + private codeFolder: string; + private downloadPlatform: string; + private downloadFolder: string; + private releaseType: ReleaseQuality; + private executablePath!: string; + private cliPaths: string[] = []; + private cliEnv!: string; + private availableVersions: string[]; + private extensionsFolder: string | undefined; + + /** + * Create an instance of code handler + * @param folder Path to folder where all the artifacts will be stored. + * @param extensionsFolder Path to use as extensions directory by VS Code + */ + constructor( + folder: string = DEFAULT_STORAGE_FOLDER, + type: ReleaseQuality = ReleaseQuality.Stable, + extensionsFolder?: string, + ) { + this.availableVersions = []; + this.downloadPlatform = this.getPlatform(); + this.downloadFolder = path.resolve(folder); + this.extensionsFolder = extensionsFolder ? path.resolve(extensionsFolder) : undefined; + this.releaseType = type; + + if (type === ReleaseQuality.Stable) { + this.codeFolder = path.join( + this.downloadFolder, + process.platform === "darwin" ? "Visual Studio Code.app" : `VSCode-${this.downloadPlatform}`, + ); + } else { + this.codeFolder = path.join( + this.downloadFolder, + process.platform === "darwin" + ? "Visual Studio Code - Insiders.app" + : `VSCode-${this.downloadPlatform}-insider`, + ); + } + } + + /** + * Get all versions for the given release stream + */ + async getVSCodeVersions(): Promise { + const apiUrl = `https://update.code.visualstudio.com/api/releases/${this.releaseType}`; + const json = await Download.getText(apiUrl); + return json as unknown as string[]; + } + + /** + * Install your extension into the test instance of VS Code + */ + installExtension(vsix?: string, id?: string): void { + const pjson = require(path.resolve("package.json")); + if (id) { + return this.installExt(id); + } + const vsixPath = path.resolve(vsix ? vsix : `${pjson.name}-${pjson.version}.vsix`); + this.installExt(vsixPath); + } + + /** + * Install extension dependencies from marketplace + */ + installDependencies(): void { + const pjson = require(path.resolve("package.json")); + const deps = pjson.extensionDependencies; + if (!deps) { + return; + } + for (const id of deps as string[]) { + this.installExt(id); + } + } + + private getCliInitCommand(): string { + let cli = this.cliPaths.join(" "); + if (this.getExistingCodeVersion() >= "1.86.0") { + return cli; + } else { + return `${cli} --ms-enable-electron-run-as-node`; + } + } + + private installExt(pathOrID: string): void { + let command = `${this.getCliInitCommand()} --force --install-extension "${pathOrID}"`; + if (this.extensionsFolder) { + command += ` --extensions-dir=${this.extensionsFolder}`; + } + child_process.execSync(command, { stdio: "inherit" }); + } + + /** + * Open files/folders in running vscode + * @param paths vararg paths to files or folders to open + */ + open(...paths: string[]): void { + const segments = paths.map(f => `"${f}"`).join(" "); + let command = `${this.getCliInitCommand()} -r ${segments} --user-data-dir="${path.join( + this.downloadFolder, + "settings", + )}"`; + child_process.execSync(command); + } + + /** + * Download a vsix file + * @param vsixURL URL of the vsix file + */ + async downloadExtension(vsixURL: string): Promise { + fs.mkdirpSync(this.downloadFolder); + const fileName = path.basename(vsixURL); + const target = path.join(this.downloadFolder, fileName); + if (!fileName.endsWith(".vsix")) { + throw new Error("The URL does not point to a vsix file"); + } + + console.log(`Downloading ${fileName}`); + await Download.getFile(vsixURL, target); + console.log("Success!"); + return target; + } + + /** + * Package extension into a vsix file + * @param useYarn false to use npm as packaging system, true to use yarn instead + */ + async packageExtension(useYarn?: boolean): Promise { + await vsce.createVSIX({ + useYarn, + }); + } + + /** + * Uninstall the tested extension from the test instance of VS Code + * + * @param cleanup remove the extension's directory as well. + */ + uninstallExtension(cleanup?: boolean): void { + const pjson = require(path.resolve("package.json")); + const extension = `${pjson.publisher}.${pjson.name}`; + + if (cleanup) { + let command = `${this.getCliInitCommand()} --uninstall-extension "${extension}"`; + if (this.extensionsFolder) { + command += ` --extensions-dir=${this.extensionsFolder}`; + } + child_process.execSync(command, { stdio: "inherit" }); + } + } + + /** + * Run tests in your test environment using mocha + * + * @param testFilesPattern glob pattern of test files to run + * @param runOptions additional options for customizing the test run + * + * @return The exit code of the mocha process + */ + async runTests(testFilesPattern: string[], runOptions: RunOptions = DEFAULT_RUN_OPTIONS): Promise { + if (!runOptions.offline) { + await this.checkCodeVersion(runOptions.vscodeVersion ?? DEFAULT_RUN_OPTIONS.vscodeVersion); + } else { + this.availableVersions = [this.getExistingCodeVersion()]; + } + const literalVersion = + runOptions.vscodeVersion === undefined || runOptions.vscodeVersion === "latest" + ? this.availableVersions[0] + : runOptions.vscodeVersion; + + // add chromedriver to process' path + const finalEnv: NodeJS.ProcessEnv = {}; + Object.assign(finalEnv, process.env); + const key = "PATH"; + finalEnv[key] = [this.downloadFolder, process.env[key]].join(path.delimiter); + + process.env = finalEnv; + process.env.TEST_RESOURCES = this.downloadFolder; + process.env.EXTENSIONS_FOLDER = this.extensionsFolder; + const runner = new VSRunner( + this.executablePath, + literalVersion, + this.parseSettings(runOptions.settings ?? DEFAULT_RUN_OPTIONS.settings), + runOptions.cleanup, + this.releaseType, + runOptions.config, + ); + return await runner.runTests(testFilesPattern, this, runOptions.logLevel, runOptions.resources); + } + + /** + * Finds the version of chromium used for given VS Code version. + * Works only for versions 1.30+, older versions need to be checked manually + * + * @param codeVersion version of VS Code, default latest + * @param quality release stream, default stable + */ + async getChromiumVersion(codeVersion: string = "latest"): Promise { + await this.checkCodeVersion(codeVersion); + const literalVersion = codeVersion === "latest" ? this.availableVersions[0] : codeVersion; + let revision = literalVersion; + if (literalVersion.endsWith("-insider")) { + if (codeVersion === "latest") { + revision = "main"; + } else { + revision = literalVersion.substring(0, literalVersion.indexOf("-insider")); + revision = `release/${revision.substring(0, revision.lastIndexOf("."))}`; + } + } else { + revision = `release/${revision.substring(0, revision.lastIndexOf("."))}`; + } + + const fileName = "manifest.json"; + const url = `https://raw.githubusercontent.com/Microsoft/vscode/${revision}/cgmanifest.json`; + await Download.getFile(url, path.join(this.downloadFolder, fileName)); + + try { + const manifest = require(path.join(this.downloadFolder, fileName)); + return manifest.registrations[0].version; + } catch (err) { + let version = ""; + if (await fs.pathExists(this.codeFolder)) { + version = this.getChromiumVersionOffline(); + } + if (version === "") { + throw new Error("Unable to determine required ChromeDriver version"); + } + return version; + } + } + + /** + * Check if VS Code exists in local cache along with an appropriate version of chromedriver + * without internet connection + */ + checkOfflineRequirements(): string { + try { + this.getExistingCodeVersion(); + } catch (err) { + console.log("ERROR: Cannot find a local copy of VS Code in offline mode, exiting."); + throw err; + } + return this.getChromiumVersionOffline(); + } + + /** + * Attempt to get chromium version from a downloaded copy of vs code + */ + getChromiumVersionOffline(): string { + const manifestPath = path.join(this.codeFolder, "resources", "app", "ThirdPartyNotices.txt"); + const text = fs.readFileSync(manifestPath).toString(); + const matches = text.match(/chromium\sversion\s(.*)\s\(/); + if (matches && matches[1]) { + return matches[1]; + } + return ""; + } + + /** + * Get the root folder of VS Code instance + */ + getCodeFolder(): string { + return this.codeFolder; + } + + /** + * Check if given version is available in the given stream + */ + private async checkCodeVersion(vscodeVersion: string): Promise { + if (this.availableVersions.length < 1) { + this.availableVersions = await this.getVSCodeVersions(); + } + if (vscodeVersion !== "latest" && this.availableVersions.indexOf(vscodeVersion) < 0) { + throw new Error(`Version ${vscodeVersion} is not available in ${this.releaseType} stream`); + } + } + + /** + * Download VSCode of given version and release quality stream + * @param version version to download, default latest + */ + async downloadAndUnzipVSCode(version: string = "latest"): Promise { + this.executablePath = await downloadAndUnzipVSCode(version); + this.cliPaths = resolveCliArgsFromVSCodeExecutablePath(this.executablePath); + + return this.executablePath; + } + + /** + * Check what VS Code version is present in the testing folder + */ + private getExistingCodeVersion(): string { + let command = this.cliPaths.join(" "); + let out: Buffer; + try { + out = child_process.execSync(`${command} -v`); + } catch (error) { + out = child_process.execSync(`${command} --ms-enable-electron-run-as-node -v`); + } + return out.toString().split("\n")[0]; + } + + /** + * Construct the platform string based on OS + */ + private getPlatform(): string { + let platform: string = process.platform; + const arch = process.arch; + this.cliEnv = "ELECTRON_RUN_AS_NODE=1"; + + if (platform === "linux") { + platform += arch === "ia32" ? "-ia32" : `-${arch}`; + } else if (platform === "win32") { + platform += arch === "x64" ? `-${arch}` : ""; + platform += "-archive"; + this.cliEnv = `set ${this.cliEnv} &&`; + } else if (platform === "darwin") { + platform += "-universal"; + } + + return platform; + } + + /** + * Parse JSON from a file + * @param path path to json file + */ + private parseSettings(path: string): Object { + if (!path) { + return {}; + } + let text = ""; + try { + text = fs.readFileSync(path).toString(); + } catch (err) { + throw new Error(`Unable to read settings from ${path}:\n ${err}`); + } + try { + return JSON.parse(text); + } catch (err) { + throw new Error(`Error parsing the settings file from ${path}:\n ${err}`); + } + } +} diff --git a/scripts/tester/util/download.ts b/scripts/tester/util/download.ts new file mode 100644 index 00000000..d2a7b910 --- /dev/null +++ b/scripts/tester/util/download.ts @@ -0,0 +1,72 @@ +import * as fs from 'fs-extra'; +import * as https from 'https'; + +// import { promisify } from 'util'; +// import stream from 'stream'; +// const got$ = import('got'); +// import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; + +// const httpProxyAgent = !process.env.HTTP_PROXY ? undefined : new HttpProxyAgent({ +// proxy: process.env.HTTP_PROXY +// }); +// +// const httpsProxyAgent = !process.env.HTTPS_PROXY ? undefined : new HttpsProxyAgent({ +// proxy: process.env.HTTPS_PROXY +// }); +// +// const options = { +// headers: { +// 'user-agent': 'nodejs' +// }, +// } + +export class Download { + + static async getText(uri: string): Promise { + const body = await new Promise((resolve, reject) => { + https.get(uri, (response) => { + let data = ''; + + response.on('data', (chunk) => { + data += chunk; + }); + + response.on('end', () => { + resolve(data); + }); + }).on('error', (error) => { + reject(error); + }); + });; + return JSON.parse(body as string) + } + + static getFile(uri: string, destination: string, progress = false): Promise { + const writeStream = fs.createWriteStream(destination); + return new Promise((resolve, reject) => { + + https.get(uri, (response) => { + response.pipe(writeStream); + + writeStream.on('finish', () => { + writeStream.close(()=>{ + resolve(); + }); + }); + }).on('error', (error) => { + fs.unlink(destination, () => { + reject(error); + }); + }); + }); + // if (progress) { + // writeStream.on('downloadProgress', ({ transferred, total, percent }: any) => { + // const currentTime = Date.now(); + // if (total > 0 && (lastTick === 0 || transferred === total || currentTime - lastTick >= 2000)) { + // console.log(`progress: ${transferred}/${total} (${Math.floor(100 * percent)}%)`); + // lastTick = currentTime; + // } + // }); + // } + } +} diff --git a/scripts/tester/util/driverUtil.ts b/scripts/tester/util/driverUtil.ts new file mode 100644 index 00000000..c22926f1 --- /dev/null +++ b/scripts/tester/util/driverUtil.ts @@ -0,0 +1,184 @@ +'use strict'; + +import * as fs from 'fs-extra'; +import * as path from 'path'; +import * as child_process from 'child_process'; +import { Unpack } from './unpack'; +import { Download } from './download'; +import { DEFAULT_STORAGE_FOLDER } from '../exTester'; + +/** + * Handles version checks and download of ChromeDriver + */ +export class DriverUtil { + private downloadFolder: string; + + /** + * Create an instance of chrome driver handler + * @param folder path to a folder to store all artifacts + */ + constructor(folder: string = DEFAULT_STORAGE_FOLDER) { + this.downloadFolder = path.resolve(folder); + } + + /** + * Find a matching ChromeDriver version for a given Chromium version and download it. + * @param chromiumVersion version of Chromium to match the ChromeDriver against + */ + async downloadChromeDriverForChromiumVersion(chromiumVersion: string): Promise { + const version = await this.getChromeDriverVersion(chromiumVersion); + return await this.downloadChromeDriver(version); + } + + /** + * Download a given version ChromeDriver + * @param version version to download + */ + async downloadChromeDriver(version: string): Promise { + const url = this.getChromeDriverURL(version); + const driverBinary = this.getChromeDriverBinaryPath(version); + if (fs.existsSync(driverBinary)) { + let localVersion = ''; + try { + localVersion = await this.getLocalDriverVersion(version); + } catch (err) { + // ignore and download + } + if (localVersion.startsWith(version)) { + console.log(`ChromeDriver ${version} exists in local cache, skipping download`); + return ''; + } + } + fs.mkdirpSync(this.downloadFolder); + + const fileName = path.join(this.downloadFolder, path.basename(url)); + console.log(`Downloading ChromeDriver ${version} from: ${url}`); + await Download.getFile(url, fileName, true); + + console.log(`Unpacking ChromeDriver ${version} into ${this.downloadFolder}`); + await Unpack.unpack(fileName, this.downloadFolder); + if (process.platform !== 'win32') { + fs.chmodSync(driverBinary, 0o755); + } + console.log('Success!'); + return driverBinary; + } + + private getChromeDriverBinaryPath(version: string): string { + const majorVersion = this.getMajorVersion(version); + const binary = process.platform === 'win32' ? 'chromedriver.exe' : 'chromedriver'; + let driverBinaryPath = path.join(this.downloadFolder, binary); + if (+majorVersion > 114) { + driverBinaryPath = path.join(this.downloadFolder, `chromedriver-${DriverUtil.getChromeDriverPlatform()}`, binary); + } + return driverBinaryPath; + } + + static getChromeDriverPlatform(): string | undefined { + switch (process.platform) { + case 'darwin': + return `mac-${process.arch}`; + case 'win32': + return process.arch === 'x64' ? 'win64' : 'win32'; + case 'linux': + return 'linux64'; + default: + break; + } + return undefined; + } + + private static getChromeDriverPlatformOLD(): string | undefined { + switch (process.platform) { + case 'darwin': + return process.arch === 'arm64' ? 'mac_arm64' : 'mac64'; + case 'win32': + return 'win32'; + case 'linux': + return 'linux64'; + default: + break; + } + return undefined; + } + + private getChromeDriverURL(version: string): string { + const majorVersion = this.getMajorVersion(version); + let driverPlatform = DriverUtil.getChromeDriverPlatformOLD(); + let url = `https://chromedriver.storage.googleapis.com/${version}/chromedriver_${driverPlatform}.zip`; + if (+majorVersion > 114) { + driverPlatform = DriverUtil.getChromeDriverPlatform(); + url = `https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/${version}/${driverPlatform}/chromedriver-${driverPlatform}.zip` + } + return url; + } + + async checkDriverVersionOffline(version: string): Promise { + try { + return await this.getLocalDriverVersion(version); + } catch (err) { + console.log('ERROR: Cannot find a copy of ChromeDriver in local cache in offline mode, exiting.') + throw err; + } + } + + /** + * Check local chrome driver version + */ + private async getLocalDriverVersion(version: string): Promise { + const command = `${this.getChromeDriverBinaryPath(version)} -v`; + return new Promise((resolve, reject) => { + child_process.exec(command, (err, stdout) => { + if (err) return reject(err); + resolve(stdout.split(' ')[1]); + }); + }); + + } + + /** + * Find a matching version of ChromeDriver for a given Chromium version + * @param chromiumVersion Chromium version to check against + */ + private async getChromeDriverVersion(chromiumVersion: string): Promise { + const majorVersion = this.getMajorVersion(chromiumVersion); + + // chrome driver versioning has changed for chrome 70+ + if (+majorVersion < 70) { + if (this.chromiumVersionMap[+majorVersion]) { + return this.chromiumVersionMap[+majorVersion]; + } else { + throw new Error(`Chromium version ${chromiumVersion} not supported`); + } + } + let url = `https://chromedriver.storage.googleapis.com/LATEST_RELEASE_${majorVersion}`; + if (+majorVersion > 114) { + url = `https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_${majorVersion}`; + } + const fileName = 'driverVersion'; + await Download.getFile(url, path.join(this.downloadFolder, fileName)); + return fs.readFileSync(path.join(this.downloadFolder, fileName)).toString(); + } + + private getMajorVersion(version: string): string { + return version.split('.')[0]; + } + + // older chromedriver versions do not match chrome versions + private readonly chromiumVersionMap: VersionMap = { + 69: '2.38', + 68: '2.38', + 67: '2.38', + 66: '2.38', + 65: '2.37', + 64: '2.36', + 63: '2.35', + 62: '2.34', + 61: '2.33', + 60: '2.32' + } +} + +interface VersionMap { + [key:number]: string +} diff --git a/scripts/tester/util/unpack.ts b/scripts/tester/util/unpack.ts new file mode 100644 index 00000000..b5bc5562 --- /dev/null +++ b/scripts/tester/util/unpack.ts @@ -0,0 +1,42 @@ +import * as fs from 'fs-extra'; +import { exec } from 'child_process'; +import * as targz from 'targz'; + +export class Unpack { + static unpack(input: fs.PathLike, target: fs.PathLike): Promise { + return new Promise((resolve, reject) => { + if (input.toString().endsWith('.tar.gz')) { + targz.decompress({ + src: input.toString(), + dest: target.toString() + }, (err: string | Error | null) => { + err ? reject(err) : resolve(); + }); + } + else if (input.toString().endsWith('.zip')) { + fs.mkdirpSync(target.toString()); + if(process.platform === 'darwin' || process.platform === 'linux') { + exec(`cd ${target} && unzip -qo ${input.toString()}`, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + } + else { + exec(`cd ${target} && tar -xvf ${input.toString()}`, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + } + } + else { + reject(`Unsupported extension for '${input}'`); + } + }); + } +} diff --git a/src/test/common.ts b/src/test/common.ts index dbf0d67a..0407429c 100644 --- a/src/test/common.ts +++ b/src/test/common.ts @@ -34,7 +34,7 @@ export const extensionInstalledDirectory = path.join( homeDirectory, ".vscode", "extensions", - `${extensionPublisher.toLowerCase()}.${extensionId.toLowerCase()}-${extensionVersion.toLowerCase()}`, + `${extensionPublisher.toLowerCase()}.${extensionId.toLowerCase()}-${extensionVersion.toLowerCase()}-win32-x64`, ); export const NugetBaseFolderName: string = ExtensionConstants.NugetBaseFolder; diff --git a/src/test/commonSuite/NewProject.spec.ts b/src/test/commonSuite/NewProject.spec.ts index 82944ece..28b260ec 100644 --- a/src/test/commonSuite/NewProject.spec.ts +++ b/src/test/commonSuite/NewProject.spec.ts @@ -7,7 +7,7 @@ import * as chai from "chai"; -import { Workbench } from "vscode-extension-tester"; +import { Workbench } from "vscode-extension-tester-monaco-page-objects"; import { ConnectorProjects, @@ -26,7 +26,7 @@ import { tryRemoveDirectoryRecursively } from "../../utils/files"; const expect = chai.expect; -describe("New extension project Tests", () => { +describe.skip("New extension project Tests", () => { describe("FirstConn project", () => { const newExtensionName: string = "FirstConn"; let oneTmpDir: string | undefined = makeOneTmpDir(); diff --git a/src/test/commonSuite/PqSdkToolAcquisition.spec.ts b/src/test/commonSuite/PqSdkToolAcquisition.spec.ts index f7c5a1a7..dae7e3ed 100644 --- a/src/test/commonSuite/PqSdkToolAcquisition.spec.ts +++ b/src/test/commonSuite/PqSdkToolAcquisition.spec.ts @@ -5,7 +5,7 @@ * LICENSE file in the root of this projects source tree. */ -import { Workbench } from "vscode-extension-tester"; +import { Workbench } from "vscode-extension-tester-monaco-page-objects"; import { PqSdkNugetPackages } from "../utils"; diff --git a/src/test/runTest.ts b/src/test/runTest.ts index 1e4916bc..dada06da 100644 --- a/src/test/runTest.ts +++ b/src/test/runTest.ts @@ -34,4 +34,4 @@ async function main(): Promise { } } -main(); +void main(); diff --git a/src/test/utils/connectorProjects.ts b/src/test/utils/connectorProjects.ts index 9a322f30..3e912bdf 100644 --- a/src/test/utils/connectorProjects.ts +++ b/src/test/utils/connectorProjects.ts @@ -9,7 +9,7 @@ import * as chai from "chai"; import * as fs from "fs"; import * as path from "path"; -import { InputBox, Key, Workbench } from "vscode-extension-tester"; +import { InputBox, Key, Workbench } from "vscode-extension-tester-monaco-page-objects"; import { defaultPqCommandCategory, rootI18n } from "../common"; import { delay } from "../../utils/pids"; diff --git a/src/test/utils/editorUtils.ts b/src/test/utils/editorUtils.ts index 1062a383..4316d44d 100644 --- a/src/test/utils/editorUtils.ts +++ b/src/test/utils/editorUtils.ts @@ -6,7 +6,7 @@ */ import * as chai from "chai"; -import { EditorView, Workbench } from "vscode-extension-tester"; +import { EditorView, Workbench } from "vscode-extension-tester-monaco-page-objects"; import { extensionI18n, rootI18n } from "../common"; const expect = chai.expect; diff --git a/src/test/utils/notificationUtils.ts b/src/test/utils/notificationUtils.ts index 748d6d08..2ef4b8ef 100644 --- a/src/test/utils/notificationUtils.ts +++ b/src/test/utils/notificationUtils.ts @@ -7,7 +7,7 @@ import * as chai from "chai"; -import { Workbench } from "vscode-extension-tester"; +import { Workbench } from "vscode-extension-tester-monaco-page-objects"; const expect = chai.expect; diff --git a/src/test/utils/outputChannelUtils.ts b/src/test/utils/outputChannelUtils.ts index 086ce226..579d94df 100644 --- a/src/test/utils/outputChannelUtils.ts +++ b/src/test/utils/outputChannelUtils.ts @@ -7,7 +7,7 @@ import * as chai from "chai"; -import { BottomBarPanel, OutputView } from "vscode-extension-tester"; +import { BottomBarPanel, OutputView } from "vscode-extension-tester-monaco-page-objects"; import { delay } from "../../utils/pids"; import { pqSdkOutputChannelName } from "../common"; diff --git a/src/test/utils/settingUtils.ts b/src/test/utils/settingUtils.ts index 8d477e52..81f0b9d9 100644 --- a/src/test/utils/settingUtils.ts +++ b/src/test/utils/settingUtils.ts @@ -5,7 +5,7 @@ * LICENSE file in the root of this projects source tree. */ -import { Setting, SettingsEditor, Workbench } from "vscode-extension-tester"; +import { Setting, SettingsEditor, Workbench } from "vscode-extension-tester-monaco-page-objects"; import { delay } from "../../utils/pids"; export module VscSettings { diff --git a/src/test/utils/sideBarUtils.ts b/src/test/utils/sideBarUtils.ts index 08537726..1102014c 100644 --- a/src/test/utils/sideBarUtils.ts +++ b/src/test/utils/sideBarUtils.ts @@ -5,7 +5,13 @@ * LICENSE file in the root of this projects source tree. */ -import { DefaultTreeSection, InputBox, SideBarView, ViewSection, Workbench } from "vscode-extension-tester"; +import { + DefaultTreeSection, + InputBox, + SideBarView, + ViewSection, + Workbench, +} from "vscode-extension-tester-monaco-page-objects"; import { extensionI18n, rootI18n } from "../common"; import { delay } from "../../utils/pids"; diff --git a/src/test/utils/titleBarUtils.ts b/src/test/utils/titleBarUtils.ts index e5055d7c..13386e70 100644 --- a/src/test/utils/titleBarUtils.ts +++ b/src/test/utils/titleBarUtils.ts @@ -5,7 +5,7 @@ * LICENSE file in the root of this projects source tree. */ -import { TitleBar, Workbench } from "vscode-extension-tester"; +import { TitleBar, Workbench } from "vscode-extension-tester-monaco-page-objects"; import { delay } from "../../utils/pids"; diff --git a/vscode-extension-tester/locators/index.ts b/vscode-extension-tester/locators/index.ts new file mode 100644 index 00000000..fe647967 --- /dev/null +++ b/vscode-extension-tester/locators/index.ts @@ -0,0 +1,5 @@ +import * as path from 'path'; + +export function getLocatorsPath() { + return path.join(__dirname, 'lib'); +} diff --git a/vscode-extension-tester/locators/lib/1.37.0.ts b/vscode-extension-tester/locators/lib/1.37.0.ts new file mode 100644 index 00000000..27b151f3 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.37.0.ts @@ -0,0 +1,494 @@ +import { Locators, ViewSection, fromAttribute, fromText, hasAttribute, hasClass, hasElement, hasNotClass } from "vscode-extension-tester-monaco-page-objects"; +import { By, WebElement } from "selenium-webdriver"; + +const abstractElement = { + AbstractElement: { + enabled: hasNotClass("disabled"), + selected: hasAttribute('aria-selected', 'true') + } +} + +const activityBar = { + ActivityBar: { + constructor: By.id('workbench.parts.activitybar'), + viewContainer: By.xpath(`.//ul[@aria-label='Active View Switcher']`), + label: 'aria-label', + actionsContainer: By.xpath(`.//ul[@aria-label='Manage']`), + actionItem: By.className('action-item') + }, + ViewControl: { + attribute: 'class', + klass: 'checked', + scmId: By.id('workbench.view.scm'), + debugId: By.id('workbench.view.debug'), + badge: By.className('badge') + } +} + +const bottomBar = { + BottomBarPanel: { + constructor: By.id('workbench.parts.panel'), + problemsTab: 'Problems', + outputTab: 'Output', + debugTab: 'Debug Console', + terminalTab: 'Terminal', + maximize: 'Maximize Panel Size', + restore: 'Restore Panel Size', + close: 'Close Panel', + tabContainer: By.className('panel-switcher-container'), + tab: (title: string) => By.xpath(`.//li[starts-with(@title, '${title}')]`), + actions: By.className('title-actions'), + globalActions: By.className('title-actions'), + action: (label: string) => By.xpath(`.//a[starts-with(@title, '${label}')]`), + closeAction: By.className('codicon-panel-close') + }, + BottomBarViews: { + actionsContainer: (label: string) => By.xpath(`.//ul[@aria-label='${label}']`), + channelOption: By.css('option'), + channelCombo: By.css('select'), + channelText: By.className('option-text'), + channelRow: By.className('monaco-list-row'), + textArea: By.css('textarea'), + clearText: By.className('clear-output') + }, + ProblemsView: { + constructor: By.id('workbench.panel.markers'), + markersFilter: By.className('markers-panel-action-filter'), + input: By.css('input'), + collapseAll: By.className('collapse-all'), + markerRow: By.className('monaco-list-row'), + rowLabel: 'aria-label', + label: By.className('monaco-highlighted-label'), + markerTwistie: By.className('monaco-tl-twistie'), + changeCount: By.className('monaco-count-badge') + }, + TerminalView: { + constructor: By.id('workbench.panel.terminal'), + actionsLabel: 'Terminal actions', + textArea: By.className('xterm-helper-textarea'), + killTerminal: By.xpath(`.//a[@title='Kill Terminal']`), + newTerminal: By.xpath(`.//a[starts-with(@title,'New Terminal')]`), + tabList: By.className('tabs-list'), + singleTab: By.className('single-terminal-tab'), + selectedRow: By.className('monaco-list-row selected'), + row: By.className('monaco-list-row'), + newCommand: 'terminal: create new integrated terminal' + }, + DebugConsoleView: { + constructor: By.id('workbench.panel.repl') + }, + OutputView: { + constructor: By.id('workbench.panel.output'), + actionsLabel: 'Output actions', + optionByName: (name: string) => By.xpath(`.//option[@value='${name}']`) + }, + WebviewView: { + iframe: By.xpath(`//div[not(@class)]/iframe[@class='webview ready' and not(@data-parent-flow-to-element-id)]`) + } +} + +const editor = { + EditorView: { + constructor: By.id('workbench.parts.editor'), + editorGroup: By.className('editor-group-container'), + settingsEditor: By.id('workbench.editor.settings2'), + webView: By.id('WebviewEditor'), + diffEditor: By.className('monaco-diff-editor'), + tab: By.className('tab'), + closeTab: By.className('tab-close'), + tabTitle: 'title', + tabSeparator: ', tab', + tabLabel: 'aria-label', + actionContainer: By.className('editor-actions'), + actionItem: By.className('action-label'), + attribute: 'title' + }, + Editor: { + constructor: By.className('editor-instance'), + inputArea: By.className('inputarea'), + title: By.className('label-name') + }, + TextEditor: { + activeTab: By.css('div.tab.active'), + breakpoint: { + pauseSelector: By.className('codicon-debug-stackframe'), + generalSelector: By.className('codicon-debug-breakpoint'), + properties: { + enabled: hasNotClass('codicon-debug-breakpoint-unverified'), + line: { + selector: By.className('line-numbers'), + number: (line: WebElement) => line.getText().then((line) => Number.parseInt(line)) + }, + paused: hasClass('codicon-debug-stackframe'), + } + }, + editorContainer: By.className('monaco-editor'), + dataUri: 'data-uri', + formatDoc: 'Format Document', + marginArea: By.className('margin-view-overlays'), + lineNumber: (line: number) => By.xpath(`.//div[contains(@class, 'line-numbers') and text() = '${line}']`), + lineOverlay: (line: number) => By.xpath(`.//div[contains(@class, 'line-numbers') and text() = '${line}']/..`), + debugHint: By.className('codicon-debug-hint'), + selection: By.className('cslr selected-text top-left-radius bottom-left-radius top-right-radius bottom-right-radius'), + findWidget: By.className('find-widget') + }, + FindWidget: { + toggleReplace: By.xpath(`.//div[@title="Toggle Replace mode"]`), + replacePart: By.className('replace-part'), + findPart: By.className('find-part'), + matchCount: By.className('matchesCount'), + input: By.css('textarea'), + content: By.className('mirror'), + button: (title: string) => By.xpath(`.//div[@role='button' and starts-with(@title, "${title}")]`), + checkbox: (title: string) => By.xpath(`.//div[@role='checkbox' and starts-with(@title, "${title}")]`) + }, + ContentAssist: { + constructor: By.className('suggest-widget'), + message: By.className('message'), + itemRows: By.className('monaco-list-rows'), + itemRow: By.className('monaco-list-row'), + itemLabel: By.className('label-name'), + itemText: By.xpath(`./span/span`), + itemList: By.className('monaco-list'), + firstItem: By.xpath(`.//div[@data-index='0']`) + }, + SettingsEditor: { + title: 'Settings', + itemRow: By.className('monaco-list-row'), + header: By.className('settings-header'), + tabs: By.className('settings-tabs-widget'), + actions: By.className('actions-container'), + action: (label: string) => By.xpath(`.//a[@title='${label}']`), + settingConstructor: (title: string, category: string) => By.xpath(`.//div[@class='monaco-tl-row' and .//span/text()='${title}' and .//span/text()='${category}: ']`), + settingDesctiption: By.className('setting-item-description'), + settingLabel: By.className('setting-item-label'), + settingCategory: By.className('setting-item-category'), + comboSetting: By.css('select'), + comboOption: By.className('option-text'), + comboValue: 'title', + textSetting: By.css('input'), + checkboxSetting: By.className('setting-value-checkbox'), + checkboxChecked: 'aria-checked', + linkButton: By.className('edit-in-settings-button'), + itemCount: By.className('settings-count-widget') + }, + DiffEditor: { + originalEditor: By.className('original-in-monaco-diff-editor'), + modifiedEditor: By.className('modified-in-monaco-diff-editor') + }, + WebView: { + iframe: By.css(`iframe[class='webview ready']`), + activeFrame: By.id('active-frame'), + container: (id: string) => By.id(id), + attribute: 'aria-flowto' + } +} + +const menu = { + ContextMenu: { + contextView: By.className('context-view'), + constructor: By.className('monaco-menu-container'), + itemConstructor: (label: string) => By.xpath(`.//li[a/span/@aria-label="${label}"]`), + itemElement: By.className('action-item'), + itemLabel: By.className('action-label'), + itemText: 'aria-label', + itemNesting: By.className('submenu-indicator'), + viewBlock: By.className('context-view-block') + }, + TitleBar: { + constructor: By.id('workbench.parts.titlebar'), + itemConstructor: (label: string) => By.xpath(`.//div[@aria-label="${label}"]`), + itemElement: By.className('menubar-menu-button'), + itemLabel: 'aria-label', + title: By.className('window-title') + }, + WindowControls: { + constructor: By.className('window-controls-container'), + minimize: By.className('window-minimize'), + maximize: By.className('window-maximize'), + restore: By.className('window-unmaximize'), + close: By.className('window-close') + } +} + +const sideBar = { + SideBarView: { + constructor: By.id('workbench.parts.sidebar') + }, + ViewTitlePart: { + constructor: By.className('composite title'), + title: By.css('h2'), + action: By.className(`action-label`), + actionLabel: 'title', + actionConstructor: (title: string) => By.xpath(`.//a[@title='${title}']`) + }, + ViewContent: { + constructor: By.className('content'), + progress: By.className('monaco-progress-container'), + section: By.className('split-view-view'), + defaultView: By.className('explorer-folders-view'), + extensionsView: By.className('extensions-list') + }, + ViewSection: { + title: By.className('title'), + titleText: 'textContent', + header: By.className('panel-header'), + headerExpanded: 'aria-expanded', + actions: By.className('actions'), + actionConstructor: (label: string) => By.xpath(`.//a[contains(@class, 'action-label') and @role='button' and @title='${label}']`), + button: By.xpath(`.//a[@role='button']`), + buttonLabel: 'title', + level: 'aria-level', + index: 'data-index', + welcomeContent: By.className('welcome-view') + }, + TreeItem: { + actions: By.className('actions-container'), + actionLabel: By.className('action-label'), + actionTitle: 'title', + twistie: By.className('monaco-tl-twistie') + }, + DefaultTreeSection: { + itemRow: By.className('monaco-list-row'), + itemLabel: 'aria-label', + rowContainer: By.className('monaco-list'), + rowWithLabel: (label: string) => By.xpath(`.//div[@role='treeitem' and @aria-label='${label}']`), + lastRow: By.xpath(`.//div[@data-last-element='true']`), + type: { + default: hasElement((locators: Locators) => locators.ViewContent.defaultView), + marketplace: { + extension: hasElement((locators: Locators) => locators.ViewContent.extensionsView) + } + } + }, + DefaultTreeItem: { + ctor: (label: string) => By.xpath(`.//div[@role='treeitem' and @aria-label='${label}']`), + twistie: By.className('monaco-tl-twistie'), + tooltip: By.className('explorer-item'), + labelAttribute: 'title' + }, + CustomTreeSection: { + itemRow: By.className('monaco-list-row'), + itemLabel: By.className('monaco-highlighted-label'), + rowContainer: By.className('monaco-list'), + rowWithLabel: (label: string) => By.xpath(`.//span[text()='${label}']`) + }, + CustomTreeItem: { + constructor: (label: string) => By.xpath(`.//div[@role='treeitem' and .//span[text()='${label}']]`), + tooltipAttribute: 'aria-label', + expandedAttr: 'aria-expanded', + expandedValue: 'true', + description: By.className('label-description'), + }, + DebugBreakpointSection: { + predicate: async (section: ViewSection) => (await section.getTitle()).toLowerCase() === 'breakpoints' + }, + BreakpointSectionItem: { + breakpoint: { + constructor: By.className('codicon') + }, + breakpointCheckbox: { + constructor: By.css('input[type=checkbox'), + value: (el: WebElement) => el.isSelected() + }, + label: { + constructor: By.className('name'), + value: fromText() + }, + filePath: { + constructor: By.className('file-path'), + value: fromText() + }, + lineNumber: { + constructor: By.className('line-number'), + value: fromText() + } + }, + DebugVariableSection: { + predicate: async (section: ViewSection) => (await section.getTitle()).toLowerCase() === 'variables' + }, + VariableSectionItem: { + label: fromText(By.className('monaco-highlighted-label')), + name: { + constructor: By.className('name'), + value: fromText(), + tooltip: fromAttribute('title', By.className('monaco-highlighted-label')) + }, + value: { + constructor: By.className('value'), + value: fromText(), + tooltip: fromAttribute('title') + } + }, + ExtensionsViewSection: { + items: By.className('monaco-list-rows'), + itemRow: By.className('monaco-list-row'), + itemTitle: By.className('name'), + searchBox: By.className('inputarea'), + textContainer: By.className('view-line'), + textField: By.className('mtk1') + }, + ExtensionsViewItem: { + version: By.className('version'), + author: By.className('author'), + description: By.className('description'), + install: By.className('install'), + manage: By.className('manage') + }, + ScmView: { + providerHeader: By.css(`div[class*='panel-header scm-provider']`), + providerRelative: By.xpath(`./..`), + initButton: By.xpath(`.//a[text()='Initialize Repository']`), + providerTitle: By.className('title'), + providerType: By.className('type'), + action: By.className('action-label'), + actionConstructor: (title: string) => By.xpath(`.//a[@title='${title}']`), + actionLabel: 'title', + inputField: By.css('textarea'), + changeItem: By.xpath(`.//div[@role='treeitem']`), + changeName: By.className('name'), + changeCount: By.className('monaco-count-badge'), + changeLabel: By.className('label-name'), + changeDesc: By.className('label-description'), + resource: By.className('resource'), + changes: By.xpath(`.//div[@role="treeitem" and .//div/text()="CHANGES"]`), + stagedChanges: By.xpath(`.//div[@role="treeitem" and .//div/text()="STAGED CHANGES"]`), + expand: By.className('monaco-tl-twistie'), + more: By.className('toolbar-toggle-more'), + multiMore: By.className('codicon-toolbar-more'), + multiScmProvider: By.className('scm-provider'), + singleScmProvider: By.className(`scm-view`), + multiProviderItem: By.xpath(`.//div[@role='treeitem' and @aria-level='1']`), + itemLevel: (level: number) => By.xpath(`.//div[@role='treeitem' and @aria-level='${level}']`), + itemIndex: (index: number) => By.xpath(`.//div[@role='treeitem' and @data-index='${index}']`) + }, + DebugView: { + launchCombo: By.className('start-debug-action-item'), + launchSelect: By.css('select'), + launchOption: By.css('option'), + optionByName: (name: string) => By.xpath(`.//option[@value='${name}']`), + startButton: By.className('codicon-debug-start') + } +} + +const statusBar = { + StatusBar: { + constructor: By.id('workbench.parts.statusbar'), + language: By.id('status.editor.mode'), + lines: By.id('status.editor.eol'), + encoding: By.id('status.editor.encoding'), + indent: By.id('status.editor.indentation'), + selection: By.id('status.editor.selection'), + notifications: By.className('notifications-center'), + bell: By.id('status.notifications'), + item: By.className('statusbar-item'), + itemTitle: 'aria-label' + } +} + +const workbench = { + Workbench: { + constructor: By.className('monaco-workbench'), + notificationContainer: By.className('notification-toast-container'), + notificationItem: By.className('monaco-list-row') + }, + Notification: { + message: By.className('notification-list-item-message'), + icon: By.className('notification-list-item-icon'), + source: By.className('notification-list-item-source'), + progress: By.className('monaco-progress-container'), + dismiss: By.className('clear-notification-action'), + expand: By.className('codicon-notifications-expand'), + actions: By.className('notification-list-item-buttons-container'), + action: By.className('monaco-button'), + actionLabel: { + value: fromAttribute('title') + }, + standalone: (id: string) => By.xpath(`.//div[contains(@class, 'monaco-list-row') and @id='${id}']`), + standaloneContainer: By.className('notifications-toasts'), + center: (index: number) => By.xpath(`.//div[contains(@class, 'monaco-list-row') and @data-index='${index}']`), + buttonConstructor: (title: string) => By.xpath(`.//a[@role='button' and @title='${title}']`) + }, + NotificationsCenter: { + constructor: By.className('notifications-center'), + close: By.className('hide-all-notifications-action'), + clear: By.className('clear-all-notifications-action'), + row: By.className('monaco-list-row') + }, + DebugToolbar: { + ctor: By.className('debug-toolbar'), + button: (title: string) => By.className(`codicon-debug-${title}`) + } +} + +const input = { + Input: { + inputBox: By.className('monaco-inputbox'), + input: By.className('input'), + quickPickIndex: (index: number) => By.xpath(`.//div[@role='treeitem' and @data-index='${index}']`), + quickPickPosition: (index: number) => By.xpath(`.//div[@role='treeitem' and @aria-posinset='${index}']`), + quickPickLabel: By.className('label-name'), + quickPickDescription: By.className('label-description'), + quickPickSelectAll: By.className('quick-input-check-all'), + titleBar: By.className('quick-input-titlebar'), + title: By.className('quick-input-title'), + backButton: By.className('codicon-quick-input-back'), + multiSelectIndex: (index: number) => By.xpath(`.//div[@role='treeitem' and @data-index='${index}']`) + }, + InputBox: { + constructor: By.className('quick-input-widget'), + message: By.className('quick-input-message'), + progress: By.className('quick-input-progress'), + quickList: By.className('quick-input-list'), + rows: By.className('monaco-list-rows'), + row: By.className('monaco-list-row') + }, + QuickOpenBox: { + constructor: By.className('monaco-quick-open-widget'), + progress: By.className('monaco-progress-container'), + quickList: By.className('quick-open-tree'), + row: By.xpath(`.//div[@role='treeitem']`) + } +} + +const dialog = { + Dialog: { + constructor: By.className('monaco-dialog-box'), + message: By.className('dialog-message-text'), + details: By.className('dialog-message-detail'), + buttonContainer: By.className('dialog-buttons-row'), + button: By.className('monaco-text-button'), + closeButton: By.className('codicon-dialog-close'), + buttonLabel: { + value: fromAttribute('title') + } + } +} + +const welcomeContentButtonSelector = ".//a[@class='monaco-button monaco-text-button']" +const welcomeContentTextSelector = ".//p" + +const welcome = { + WelcomeContent: { + button: By.xpath(welcomeContentButtonSelector), + buttonOrText: By.xpath(`${welcomeContentButtonSelector} | ${welcomeContentTextSelector}`), + text: By.xpath(welcomeContentTextSelector) + } +} + +/** + * All available locators for vscode version 1.37.0 + */ +export const locators: Locators = { + ...abstractElement, + ...activityBar, + ...bottomBar, + ...editor, + ...menu, + ...sideBar, + ...statusBar, + ...workbench, + ...input, + ...dialog, + ...welcome +} diff --git a/vscode-extension-tester/locators/lib/1.38.0.ts b/vscode-extension-tester/locators/lib/1.38.0.ts new file mode 100644 index 00000000..55dd82e7 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.38.0.ts @@ -0,0 +1,11 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +import { By } from "selenium-webdriver"; + +export const diff: LocatorDiff = { + locators: { + EditorView: { + settingsEditor: By.xpath(`.//div[@data-editor-id='workbench.editor.settings2']`), + webView: By.xpath(`.//div[@data-editor-id='WebviewEditor']`) + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.39.0.ts b/vscode-extension-tester/locators/lib/1.39.0.ts new file mode 100644 index 00000000..ad55ba12 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.39.0.ts @@ -0,0 +1,20 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +import { By } from "selenium-webdriver"; + +export const diff: LocatorDiff = { + locators: { + BottomBarViews: { + clearText: By.className('codicon-clear-all') + }, + NotificationsCenter: { + close: By.className('codicon-chevron-down'), + clear: By.className('codicon-close-all') + }, + Notification: { + dismiss: By.className('codicon-close') + }, + ScmView: { + more: By.className('codicon-more') + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.40.0.ts b/vscode-extension-tester/locators/lib/1.40.0.ts new file mode 100644 index 00000000..5963e83e --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.40.0.ts @@ -0,0 +1,11 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +import { By } from "selenium-webdriver"; + +export const diff: LocatorDiff = { + locators: { + NotificationsCenter: { + close: By.className('codicon-close'), + clear: By.className('codicon-clear-all') + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.41.0.ts b/vscode-extension-tester/locators/lib/1.41.0.ts new file mode 100644 index 00000000..4ea0a33c --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.41.0.ts @@ -0,0 +1,13 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +import { By } from "selenium-webdriver"; + +export const diff: LocatorDiff = { + locators: { + ViewSection: { + header: By.className('pane-header') + }, + ScmView: { + providerHeader: By.css(`div[class*='pane-header scm-provider']`) + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.43.0.ts b/vscode-extension-tester/locators/lib/1.43.0.ts new file mode 100644 index 00000000..ef5ba48e --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.43.0.ts @@ -0,0 +1,14 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +import { By } from "selenium-webdriver"; + +export const diff: LocatorDiff = { + locators: { + Input: { + quickPickIndex: (index: number) => By.xpath(`.//div[@role='listitem' and @data-index='${index}']`), + multiSelectIndex: (index: number) => By.xpath(`.//div[@role='listitem' and @data-index='${index}']`) + }, + NotificationsCenter: { + close: By.className('codicon-chevron-down') + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.44.0.ts b/vscode-extension-tester/locators/lib/1.44.0.ts new file mode 100644 index 00000000..1f868b9e --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.44.0.ts @@ -0,0 +1,11 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +import { By } from "selenium-webdriver"; + +export const diff: LocatorDiff = { + locators: { + Input: { + quickPickIndex: (index: number) => By.xpath(`.//div[@role='option' and @data-index='${index}']`), + multiSelectIndex: (index: number) => By.xpath(`.//div[@role='option' and @data-index='${index}']`) + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.45.0.ts b/vscode-extension-tester/locators/lib/1.45.0.ts new file mode 100644 index 00000000..1bda2907 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.45.0.ts @@ -0,0 +1,20 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +import { By } from "selenium-webdriver"; + +export const diff: LocatorDiff = { + locators: { + EditorView: { + tabSeparator: '' + }, + NotificationsCenter: { + clear: By.className('codicon-notifications-clear-all'), + close: By.className('codicon-notifications-hide') + }, + Notification: { + dismiss: By.className('codicon-notifications-clear') + }, + ScmView: { + more: By.className('codicon-toolbar-more') + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.46.0.ts b/vscode-extension-tester/locators/lib/1.46.0.ts new file mode 100644 index 00000000..3f904a30 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.46.0.ts @@ -0,0 +1,10 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +import { By } from "selenium-webdriver"; + +export const diff: LocatorDiff = { + locators: { + CustomTreeItem: { + constructor: (label: string) => By.xpath(`.//div[@role='listitem' and .//span[text()='${label}']]`) + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.47.0.ts b/vscode-extension-tester/locators/lib/1.47.0.ts new file mode 100644 index 00000000..e0de3379 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.47.0.ts @@ -0,0 +1,16 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +import { By } from "selenium-webdriver"; + +export const diff: LocatorDiff = { + locators: { + CustomTreeItem: { + constructor: (label: string) => By.xpath(`.//div[@role='treeitem' and .//span[text()='${label}']]`) + }, + ScmView: { + changes: By.xpath(`.//div[@role="treeitem" and .//div/text()="Changes"]`), + stagedChanges: By.xpath(`.//div[@role="treeitem" and .//div/text()="Staged Changes"]`), + providerTitle: By.className('name'), + providerType: By.className('description'), + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.49.0.ts b/vscode-extension-tester/locators/lib/1.49.0.ts new file mode 100644 index 00000000..3b931703 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.49.0.ts @@ -0,0 +1,10 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +import { By } from "selenium-webdriver"; + +export const diff: LocatorDiff = { + locators: { + TerminalView: { + constructor: By.className('terminal-outer-container') + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.50.0.ts b/vscode-extension-tester/locators/lib/1.50.0.ts new file mode 100644 index 00000000..0a623b77 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.50.0.ts @@ -0,0 +1,10 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +import { By } from "selenium-webdriver"; + +export const diff: LocatorDiff = { + locators: { + EditorView: { + closeTab: By.className('codicon-close') + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.52.0.ts b/vscode-extension-tester/locators/lib/1.52.0.ts new file mode 100644 index 00000000..d9f39e97 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.52.0.ts @@ -0,0 +1,10 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +import { By } from "selenium-webdriver"; + +export const diff: LocatorDiff = { + locators: { + DefaultTreeItem: { + tooltip: By.className('monaco-icon-label-container') + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.54.0.ts b/vscode-extension-tester/locators/lib/1.54.0.ts new file mode 100644 index 00000000..b568fed8 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.54.0.ts @@ -0,0 +1,11 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +import { By } from "selenium-webdriver"; + +export const diff: LocatorDiff = { + locators: { + TerminalView: { + newTerminal: By.xpath(`.//a[starts-with(@title, 'Create New Integrated Terminal')]`), + killTerminal: By.xpath(`.//a[@title='Kill the Active Terminal Instance']`) + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.56.0.ts b/vscode-extension-tester/locators/lib/1.56.0.ts new file mode 100644 index 00000000..925b083f --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.56.0.ts @@ -0,0 +1,13 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +import { By } from "selenium-webdriver"; + +export const diff: LocatorDiff = { + locators: { + EditorView: { + webView: By.xpath(`.//div[starts-with(@id, 'webview-editor')]`) + }, + TerminalView: { + newTerminal: By.xpath(`.//a[@title='New Terminal']`) + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.57.0.ts b/vscode-extension-tester/locators/lib/1.57.0.ts new file mode 100644 index 00000000..0a526e4e --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.57.0.ts @@ -0,0 +1,13 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +import { By } from "selenium-webdriver"; + +export const diff: LocatorDiff = { + locators: { + EditorView: { + settingsEditor: By.className('settings-editor') + }, + TerminalView: { + constructor: By.className('integrated-terminal') + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.59.0.ts b/vscode-extension-tester/locators/lib/1.59.0.ts new file mode 100644 index 00000000..89507772 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.59.0.ts @@ -0,0 +1,9 @@ +import { By, LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; + +export const diff: LocatorDiff = { + locators: { + FindWidget: { + toggleReplace: By.xpath(`.//div[@title="Toggle Replace"]`), + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.60.0.ts b/vscode-extension-tester/locators/lib/1.60.0.ts new file mode 100644 index 00000000..9ddcd151 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.60.0.ts @@ -0,0 +1,9 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; + +export const diff: LocatorDiff = { + locators: { + TerminalView: { + newCommand: 'terminal: create new terminal' + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.61.0.ts b/vscode-extension-tester/locators/lib/1.61.0.ts new file mode 100644 index 00000000..7a0606c9 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.61.0.ts @@ -0,0 +1,12 @@ +import { By, LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; + +export const diff: LocatorDiff = { + locators: { + BottomBarPanel: { + globalActions: By.className('global-actions') + }, + DefaultTreeItem: { + tooltip: By.className('monaco-icon-label') + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.66.0.ts b/vscode-extension-tester/locators/lib/1.66.0.ts new file mode 100644 index 00000000..7ef30b24 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.66.0.ts @@ -0,0 +1,13 @@ +import { By, LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; + +export const diff: LocatorDiff = { + locators: { + ContextMenu: { + constructor: By.className('monaco-menu') + }, + BottomBarPanel: { + close: 'Close Panel', + closeAction: By.className('codicon-panel-close') + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.70.0.ts b/vscode-extension-tester/locators/lib/1.70.0.ts new file mode 100644 index 00000000..a1c4b390 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.70.0.ts @@ -0,0 +1,23 @@ +import { By, LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; + +export const diff: LocatorDiff = { + locators: { + BottomBarPanel: { + action: (label: string) => By.xpath(`.//li[starts-with(@title, '${label}')]`) + }, + ViewSection: { + buttonLabel: 'aria-label' + }, + ViewTitlePart: { + action: By.className(`action-label`), + actionConstructor: (title: string) => By.xpath(`.//a[@title='${title}']`) + }, + ScmView: { + action: By.className('action-item menu-entry'), + actionConstructor: (title: string) => By.xpath(`.//li[@title='${title}']`) + }, + EditorView: { + attribute: 'aria-label' + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.71.0.ts b/vscode-extension-tester/locators/lib/1.71.0.ts new file mode 100644 index 00000000..4036ce09 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.71.0.ts @@ -0,0 +1,16 @@ +import { LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +import { By } from "selenium-webdriver"; + +export const diff: LocatorDiff = { + locators: { + EditorView: { + attribute: 'aria-label' + }, + TreeItem: { + actionTitle: 'aria-label' + }, + Input: { + multiSelectIndex: (index: number) => By.xpath(`.//div[@role='checkbox' and @data-index='${index}']`) + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.73.0.ts b/vscode-extension-tester/locators/lib/1.73.0.ts new file mode 100644 index 00000000..3fa197e4 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.73.0.ts @@ -0,0 +1,8 @@ +import { By, LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +export const diff: LocatorDiff = { + locators: { + ProblemsView: { + markersFilter: By.className('viewpane-filter') + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.74.0.ts b/vscode-extension-tester/locators/lib/1.74.0.ts new file mode 100644 index 00000000..ce7b7ff0 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.74.0.ts @@ -0,0 +1,9 @@ +import { By, LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +export const diff: LocatorDiff = { + locators: { + WebView: { + container: (id: string) => By.xpath(`.//div[@data-parent-flow-to-element-id='${id}']`), + attribute: 'id' + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.83.0.ts b/vscode-extension-tester/locators/lib/1.83.0.ts new file mode 100644 index 00000000..54963e02 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.83.0.ts @@ -0,0 +1,8 @@ +import { By, LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +export const diff: LocatorDiff = { + locators: { + ViewSection: { + actionConstructor: (label: string) => By.xpath(`.//a[contains(@class, 'action-label') and @role='button' and @aria-label='${label}']`), + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.84.0.ts b/vscode-extension-tester/locators/lib/1.84.0.ts new file mode 100644 index 00000000..749b1e0c --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.84.0.ts @@ -0,0 +1,8 @@ +import { By, LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +export const diff: LocatorDiff = { + locators: { + BottomBarPanel: { + tabContainer: By.className('composite-bar-container') + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.85.0.ts b/vscode-extension-tester/locators/lib/1.85.0.ts new file mode 100644 index 00000000..06a622f3 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.85.0.ts @@ -0,0 +1,11 @@ +import { By, LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +export const diff: LocatorDiff = { + locators: { + Workbench: { + notificationContainer: By.className('notifications-list-container') + }, + ViewTitlePart: { + actionConstructor: (title: string) => By.xpath(`.//a[@aria-label='${title}']`) + }, + } +} diff --git a/vscode-extension-tester/locators/lib/1.87.0.ts b/vscode-extension-tester/locators/lib/1.87.0.ts new file mode 100644 index 00000000..df4460b4 --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.87.0.ts @@ -0,0 +1,38 @@ +import { By, fromText, LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +export const diff: LocatorDiff = { + locators: { + BottomBarPanel: { + close: 'Hide Panel', + globalActions: By.className('global-actions'), + action: (label: string) => By.xpath(`.//a[starts-with(@aria-label, '${label}')]`) + }, + SettingsEditor: { + comboValue: 'value' + }, + FindWidget: { + checkbox: (title: string) => By.xpath(`.//div[@role='checkbox' and starts-with(@aria-label, "${title}")]`) + }, + Notification: { + buttonConstructor: (title: string) => By.xpath(`.//a[@role='button' and text()='${title}']`), + actionLabel: { + value: fromText() + } + }, + ScmView: { + action: By.className('action-label'), + actionConstructor: (title: string) => By.xpath(`.//a[@aria-label='${title}']`), + actionLabel: 'aria-label' + }, + ViewTitlePart: { + actionLabel: 'aria-label' + }, + DefaultTreeItem: { + labelAttribute: 'aria-label' + }, + Dialog: { + buttonLabel: { + value: fromText() + } + } + } +} diff --git a/vscode-extension-tester/locators/lib/1.87.2.ts b/vscode-extension-tester/locators/lib/1.87.2.ts new file mode 100644 index 00000000..8bd49b1f --- /dev/null +++ b/vscode-extension-tester/locators/lib/1.87.2.ts @@ -0,0 +1,9 @@ +import { By, LocatorDiff } from "vscode-extension-tester-monaco-page-objects"; +export const diff: LocatorDiff = { + locators: { + Input: { + quickPickIndex: (index: number) => By.xpath(`.//div[@role='option' and @data-index='${index}']`), + }, + + } +} diff --git a/vscode-extension-tester/locators/package.json b/vscode-extension-tester/locators/package.json new file mode 100644 index 00000000..b6df6809 --- /dev/null +++ b/vscode-extension-tester/locators/package.json @@ -0,0 +1,24 @@ +{ + "name": "vscode-extension-tester-locators", + "private": true, + "description": "Pluggable Page Objects locators for ExTester", + "main": "out/index.js", + "types": "out/index.d.ts", + "files": [ + "out/**/*.js", + "out/**/*.d.ts" + ], + "scripts": { + "build": "rimraf out/ && tsc" + }, + "devDependencies": { + "@types/node": "^18.19.21", + "@types/selenium-webdriver": "^4.1.22", + "rimraf": "^5.0.5", + "typescript": "5.4.2" + }, + "peerDependencies": { + "vscode-extension-tester-monaco-page-objects": "*", + "selenium-webdriver": ">=4.6.1" + } +} diff --git a/vscode-extension-tester/locators/tsconfig.json b/vscode-extension-tester/locators/tsconfig.json new file mode 100644 index 00000000..b3bcc1db --- /dev/null +++ b/vscode-extension-tester/locators/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "out", + "rootDir": "." + }, + "include": [ + "index.ts", + "lib" + ] +} diff --git a/vscode-extension-tester/page-objects/package.json b/vscode-extension-tester/page-objects/package.json new file mode 100644 index 00000000..3cb83b4a --- /dev/null +++ b/vscode-extension-tester/page-objects/package.json @@ -0,0 +1,33 @@ +{ + "name": "vscode-extension-tester-monaco-page-objects", + "private": true, + "description": "Page Objects for Monaco Editor", + "main": "out/index.js", + "types": "out/index.d.ts", + "files": [ + "out/**/*.js", + "out/**/*.d.ts" + ], + "scripts": { + "build": "rimraf out/ && tsc" + }, + "devDependencies": { + "@types/clone-deep": "^4.0.4", + "@types/fs-extra": "^11.0.4", + "@types/node": "^18.19.21", + "@types/selenium-webdriver": "^4.1.22", + "rimraf": "^5.0.5", + "typescript": "*" + }, + "dependencies": { + "clipboardy": "^4.0.0", + "clone-deep": "^4.0.1", + "compare-versions": "^6.1.0", + "fs-extra": "^11.2.0", + "ts-essentials": "^9.4.1" + }, + "peerDependencies": { + "selenium-webdriver": ">=4.6.1", + "typescript": ">=4.6.2" + } +} diff --git a/vscode-extension-tester/page-objects/src/components/AbstractElement.ts b/vscode-extension-tester/page-objects/src/components/AbstractElement.ts new file mode 100644 index 00000000..0585d0e7 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/AbstractElement.ts @@ -0,0 +1,75 @@ +import { By, Key, Locator, until, WebDriver, WebElement } from "selenium-webdriver"; +import { Locators } from "../locators/locators"; + +/** + * Default wrapper for webelement + */ +export abstract class AbstractElement extends WebElement { + public static ctlKey: string = process.platform === "darwin" ? Key.COMMAND : Key.CONTROL; + protected static driver: WebDriver; + protected static locators: Locators; + protected static versionInfo: { version: string; browser: string }; + protected enclosingItem: WebElement; + + /** + * Constructs a new element from a Locator or an existing WebElement + * @param base WebDriver compatible Locator for the given element or a reference to an existing WeBelement + * @param enclosingItem Locator or a WebElement reference to an element containing the element being constructed + * this will be used to narrow down the search for the underlying DOM element + */ + constructor(base: Locator | WebElement, enclosingItem?: WebElement | Locator) { + let item: WebElement = AbstractElement.driver.findElement(By.css("html")); + + if (!enclosingItem) { + enclosingItem = item; + } + + if (enclosingItem instanceof WebElement) { + item = enclosingItem; + } else { + item = AbstractElement.driver.findElement(enclosingItem); + } + + if (base instanceof WebElement) { + super(AbstractElement.driver, base.getId()); + } else { + const toFind = item.findElement(base); + const id = toFind.getId(); + super(AbstractElement.driver, id); + } + + this.enclosingItem = item; + } + + override async isEnabled(): Promise { + return (await super.isEnabled()) && (await AbstractElement.locators.AbstractElement.enabled(this)); + } + + override async isSelected(): Promise { + return (await super.isSelected()) && (await AbstractElement.locators.AbstractElement.selected(this)); + } + + /** + * Wait for the element to become visible + * @param timeout custom timeout for the wait + * @returns thenable self reference + */ + async wait(timeout: number = 5000): Promise { + await this.getDriver().wait(until.elementIsVisible(this), timeout); + + return this; + } + + /** + * Return a reference to the WebElement containing this element + */ + getEnclosingElement(): WebElement { + return this.enclosingItem; + } + + static init(locators: Locators, driver: WebDriver, browser: string, version: string): void { + AbstractElement.locators = locators; + AbstractElement.driver = driver; + AbstractElement.versionInfo = { version, browser }; + } +} diff --git a/vscode-extension-tester/page-objects/src/components/ElementWithContextMenu.ts b/vscode-extension-tester/page-objects/src/components/ElementWithContextMenu.ts new file mode 100644 index 00000000..4881c419 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/ElementWithContextMenu.ts @@ -0,0 +1,41 @@ +import { AbstractElement } from "./AbstractElement"; +import { ContextMenu } from ".."; +import { error, until } from "selenium-webdriver"; + +/** + * Abstract element that has a context menu + */ +export abstract class ElementWithContexMenu extends AbstractElement { + /** + * Open context menu on the element + */ + async openContextMenu(): Promise { + const workbench = await this.getDriver().findElement(ElementWithContexMenu.locators.Workbench.constructor); + const menus = await workbench.findElements(ElementWithContexMenu.locators.ContextMenu.contextView); + + if (menus.length < 1) { + await this.getDriver().actions().contextClick(this).perform(); + + await this.getDriver().wait( + until.elementLocated(ElementWithContexMenu.locators.ContextMenu.contextView), + 2000, + ); + + return new ContextMenu(workbench).wait(); + } else if ((await workbench.findElements(ElementWithContexMenu.locators.ContextMenu.viewBlock)).length > 0) { + await this.getDriver().actions().contextClick(this).perform(); + + try { + await this.getDriver().wait(until.elementIsNotVisible(this), 1000); + } catch (err) { + if (!(err instanceof error.StaleElementReferenceError)) { + throw err; + } + } + } + + await this.getDriver().actions().contextClick(this).perform(); + + return new ContextMenu(workbench).wait(); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/WebviewMixin.ts b/vscode-extension-tester/page-objects/src/components/WebviewMixin.ts new file mode 100644 index 00000000..210612b0 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/WebviewMixin.ts @@ -0,0 +1,105 @@ +import { Locator, WebElement, until } from "selenium-webdriver"; +import { AbstractElement } from "./AbstractElement"; + +/** + * Heavily inspired by https://stackoverflow.com/a/65418734 + */ + +type Constructor = new (...args: any[]) => T; + +/** + * The interface that a class is required to have in order to use the Webview mixin. + */ +interface WebviewMixable extends AbstractElement { + getViewToSwitchTo(handle: string): Promise; +} + +/** + * The interface that is exposed by applying this mixin. + */ +export interface WebviewMixinType { + findWebElement(locator: Locator): Promise; + findWebElements(locator: Locator): Promise; + switchToFrame(timeout?: number): Promise; + switchBack(): Promise; +} + +/** + * Returns a class that has the ability to access a webview. + * + * @param Base the class to mixin + * @returns a class that has the ability to access a webview + */ +export default function >( + Base: TBase +): Constructor & WebviewMixinType> { + return class extends Base implements WebviewMixinType { + /** + * Cannot use static element, since this class is unnamed. + */ + private handle: string | undefined; + + /** + * Search for an element inside the webview iframe. + * Requires webdriver being switched to the webview iframe first. + * (Will attempt to search from the main DOM root otherwise) + * + * @param locator webdriver locator to search by + * @returns promise resolving to WebElement when found + */ + async findWebElement(locator: Locator): Promise { + return await this.getDriver().findElement(locator); + } + + /** + * Search for all element inside the webview iframe by a given locator + * Requires webdriver being switched to the webview iframe first. + * (Will attempt to search from the main DOM root otherwise) + * + * @param locator webdriver locator to search by + * @returns promise resolving to a list of WebElement objects + */ + async findWebElements(locator: Locator): Promise { + return await this.getDriver().findElements(locator); + } + + /** + * Switch the underlying webdriver context to the webview iframe. + * This allows using the findWebElement methods. + * Note that only elements inside the webview iframe will be accessible. + * Use the switchBack method to switch to the original context. + */ + async switchToFrame(timeout: number = 5000): Promise { + if (!this.handle) { + this.handle = await this.getDriver().getWindowHandle(); + } + + const view = await this.getViewToSwitchTo(this.handle); + + if (!view) { + return; + } + + await this.getDriver().switchTo().frame(view); + + await this.getDriver().wait( + until.elementLocated(AbstractElement.locators.WebView.activeFrame), + timeout + ); + const frame = await this.getDriver().findElement( + AbstractElement.locators.WebView.activeFrame + ); + await this.getDriver().switchTo().frame(frame); + } + + /** + * Switch the underlying webdriver back to the original window + */ + async switchBack(): Promise { + if (!this.handle) { + this.handle = await this.getDriver().getWindowHandle(); + } + return await this.getDriver().switchTo().window(this.handle); + } + } as unknown as Constructor & WebviewMixinType>; +} diff --git a/vscode-extension-tester/page-objects/src/components/activityBar/ActionsControl.ts b/vscode-extension-tester/page-objects/src/components/activityBar/ActionsControl.ts new file mode 100644 index 00000000..8474dfdd --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/activityBar/ActionsControl.ts @@ -0,0 +1,27 @@ +import { WebElement } from "selenium-webdriver"; +import { ActivityBar, ContextMenu } from "../.."; +import { ElementWithContexMenu } from "../ElementWithContextMenu"; + +/** + * Page object representing the global action controls on the bottom of the action bar + */ +export class ActionsControl extends ElementWithContexMenu { + constructor(element: WebElement, bar: ActivityBar) { + super(element, bar); + } + + /** + * Open the context menu bound to this global action + * @returns Promise resolving to ContextMenu object representing the action's menu + */ + async openActionMenu(): Promise { + return await this.openContextMenu(); + } + + /** + * Returns the title of the associated action + */ + async getTitle(): Promise { + return await this.getAttribute('aria-label'); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/activityBar/ActivityBar.ts b/vscode-extension-tester/page-objects/src/components/activityBar/ActivityBar.ts new file mode 100644 index 00000000..96cf74fa --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/activityBar/ActivityBar.ts @@ -0,0 +1,71 @@ +import { ActionsControl, ViewControl } from "../.."; +import { ElementWithContexMenu } from "../ElementWithContextMenu"; + +/** + * Page object representing the left side activity bar in VS Code + */ +export class ActivityBar extends ElementWithContexMenu { + constructor() { + super(ActivityBar.locators.ActivityBar.constructor, ActivityBar.locators.Workbench.constructor); + } + + /** + * Find all view containers displayed in the activity bar + * @returns Promise resolving to array of ViewControl objects + */ + async getViewControls(): Promise { + const views: ViewControl[] = []; + const viewContainer = await this.findElement(ActivityBar.locators.ActivityBar.viewContainer); + for(const element of await viewContainer.findElements(ActivityBar.locators.ActivityBar.actionItem)) { + views.push(await new ViewControl(element, this).wait()); + } + return views; + } + + /** + * Find a view container with a given title + * @param name title of the view + * @returns Promise resolving to ViewControl object representing the view selector, undefined if not found + */ + async getViewControl(name: string): Promise { + const controls = await this.getViewControls(); + const names = await Promise.all(controls.map(async (item) => { + return await item.getTitle(); + })); + const index = names.findIndex((value) => value.indexOf(name) > -1); + if (index > -1) { + return controls[index]; + } + return undefined; + } + + /** + * Find all global action controls displayed on the bottom of the activity bar + * @returns Promise resolving to array of ActionsControl objects + */ + async getGlobalActions(): Promise { + const actions: ActionsControl[] = []; + const actionContainer = await this.findElement(ActivityBar.locators.ActivityBar.actionsContainer); + for(const element of await actionContainer.findElements(ActivityBar.locators.ActivityBar.actionItem)) { + actions.push(await new ActionsControl(element, this).wait()); + } + return actions; + } + + /** + * Find an action control with a given title + * @param name title of the global action + * @returns Promise resolving to ActionsControl object representing the action selector, undefined if not found + */ + async getGlobalAction(name: string): Promise { + const actions = await this.getGlobalActions(); + const names = await Promise.all(actions.map(async (item) => { + return await item.getTitle(); + })); + const index = names.findIndex((value) => value.indexOf(name) > -1); + if (index > -1) { + return actions[index]; + } + return undefined; + } +} diff --git a/vscode-extension-tester/page-objects/src/components/activityBar/ViewControl.ts b/vscode-extension-tester/page-objects/src/components/activityBar/ViewControl.ts new file mode 100644 index 00000000..4f9bab64 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/activityBar/ViewControl.ts @@ -0,0 +1,56 @@ +import { ActivityBar, DebugView, SideBarView, ScmView } from "../.."; +import { ElementWithContexMenu } from "../ElementWithContextMenu"; +import { WebElement } from "selenium-webdriver"; +import { NewScmView } from "../sidebar/scm/NewScmView"; + +/** + * Page object representing a view container item in the activity bar + */ +export class ViewControl extends ElementWithContexMenu { + constructor(element: WebElement, bar: ActivityBar) { + super(element, bar); + } + + /** + * Opens the associated view if not already open + * @returns Promise resolving to SideBarView object representing the opened view + */ + async openView(): Promise { + // Check whether view is already open + const klass = await this.getAttribute(ViewControl.locators.ViewControl.attribute); + if (klass.indexOf(ViewControl.locators.ViewControl.klass) < 0) { + await this.click(); + await ViewControl.driver.sleep(500); + } + const view = await new SideBarView().wait(); + if ((await view.findElements(ViewControl.locators.ViewControl.scmId)).length > 0) { + if (ViewControl.versionInfo.browser === 'vscode' && ViewControl.versionInfo.version >= '1.47.0') { + return new NewScmView().wait(); + } + return new ScmView().wait(); + } + if ((await view.findElements(ViewControl.locators.ViewControl.debugId)).length > 0) { + return new DebugView().wait(); + } + return view; + } + + /** + * Closes the associated view if not already closed + * @returns Promise resolving when the view closes + */ + async closeView(): Promise { + const klass = await this.getAttribute(ViewControl.locators.ViewControl.attribute); + if (klass.indexOf(ViewControl.locators.ViewControl.klass) > -1) { + await this.click(); + } + } + + /** + * Returns the title of the associated view + */ + async getTitle(): Promise { + const badge = await this.findElement(ViewControl.locators.ViewControl.badge); + return await badge.getAttribute('aria-label'); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/bottomBar/AbstractViews.ts b/vscode-extension-tester/page-objects/src/components/bottomBar/AbstractViews.ts new file mode 100644 index 00000000..09b2348c --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/bottomBar/AbstractViews.ts @@ -0,0 +1,91 @@ +import { Key } from 'selenium-webdriver'; +import { ElementWithContexMenu } from "../ElementWithContextMenu"; + +/** + * View with channel selector + */ +export abstract class ChannelView extends ElementWithContexMenu { + protected actionsLabel!: string; + + /** + * Get names of all selectable channels + * @returns Promise resolving to array of strings - channel names + */ + async getChannelNames(): Promise { + const names: string[] = []; + const elements = await (await this.enclosingItem.findElement(ChannelView.locators.BottomBarViews.actionsContainer(this.actionsLabel))) + .findElements(ChannelView.locators.BottomBarViews.channelOption); + + await Promise.all(elements.map(async element => { + const disabled = await element.getAttribute('disabled'); + if (!disabled) { + names.push(await element.getAttribute('value')); + } + })); + + return names; + } + + /** + * Get name of the current channel + * @returns Promise resolving to the current channel name + */ + async getCurrentChannel(): Promise { + if(ChannelView.versionInfo.version >= '1.87.0' && process.platform !== 'darwin') { + throw Error(`The 'ChannelView.getCurrentChannel' method is broken! Read more information in 'Known Issues > Limitations in testing with VS Code 1.87+' - https://github.com/microsoft/vscode/issues/206897.`); + } + const combo = await this.enclosingItem.findElement(ChannelView.locators.BottomBarViews.channelCombo); + return await combo.getAttribute('title'); + } + + /** + * Select a channel using the selector combo + * @param name name of the channel to open + */ + async selectChannel(name: string): Promise { + const combo = await this.enclosingItem.findElement(ChannelView.locators.BottomBarViews.channelCombo); + const option = await combo.findElement(ChannelView.locators.OutputView.optionByName(name)); + await option.click(); + } +} + +/** + * View with channel selection and text area + */ +export abstract class TextView extends ChannelView { + declare protected actionsLabel: string; + + /** + * Get all text from the currently open channel + * @returns Promise resolving to the view's text + */ + override async getText(): Promise { + const clipboard = (await import('clipboardy')).default; + let originalClipboard = ''; + try { + originalClipboard = clipboard.readSync(); + } catch (error) { + // workaround issue https://github.com/redhat-developer/vscode-extension-tester/issues/835 + // do not fail if clipboard is empty + } + let textarea = await this.findElement(ChannelView.locators.BottomBarViews.textArea); + await textarea.sendKeys(Key.chord(TextView.ctlKey, 'a')); + await textarea.sendKeys(Key.chord(TextView.ctlKey, 'c')); + const text = clipboard.readSync(); + // workaround as we are getting "element click intercepted" during the send keys actions. + // await textarea.click(); + if(originalClipboard.length > 0) { + clipboard.writeSync(originalClipboard); + } + return text; + } + + /** + * Clear the text in the current channel + * @returns Promise resolving when the clear text button is pressed + */ + async clearText(): Promise { + await this.enclosingItem.findElement(ChannelView.locators.BottomBarViews.actionsContainer(this.actionsLabel)) + .findElement(ChannelView.locators.BottomBarViews.clearText).click(); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/bottomBar/BottomBarPanel.ts b/vscode-extension-tester/page-objects/src/components/bottomBar/BottomBarPanel.ts new file mode 100644 index 00000000..cb2ef761 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/bottomBar/BottomBarPanel.ts @@ -0,0 +1,124 @@ +import { AbstractElement } from "../AbstractElement"; +import { By, until, WebElement } from "selenium-webdriver"; +import { ProblemsView, OutputView, DebugConsoleView, TerminalView, EditorView, Workbench } from "../.."; + +/** + * Page object for the bottom view panel + */ +export class BottomBarPanel extends AbstractElement { + constructor() { + super(BottomBarPanel.locators.BottomBarPanel.constructor, BottomBarPanel.locators.Workbench.constructor); + } + + /** + * Open/Close the bottom bar panel + * @param open true to open. false to close + * @returns Promise resolving when the view visibility is toggled + */ + async toggle(open: boolean): Promise { + try { + const tab = await new EditorView().getActiveTab(); + await tab?.click(); + } catch (err) { + // ignore and move on + } + const height = (await this.getRect()).height; + if ((open && height === 0) || !open && height > 0) { + if (open) { + await this.getDriver().actions().clear(); + await this.getDriver().actions().keyDown(BottomBarPanel.ctlKey).sendKeys('j').perform(); + await this.wait(); + } else { + await this.closePanel(); + await this.getDriver().wait(until.elementIsNotVisible(this)); + } + } + } + + /** + * Open the Problems view in the bottom panel + * @returns Promise resolving to a ProblemsView object + */ + async openProblemsView(): Promise { + await this.openTab(BottomBarPanel.locators.BottomBarPanel.problemsTab); + return new ProblemsView(this).wait(); + } + + /** + * Open the Output view in the bottom panel + * @returns Promise resolving to OutputView object + */ + async openOutputView(): Promise { + await this.openTab(BottomBarPanel.locators.BottomBarPanel.outputTab); + return new OutputView(this).wait(); + } + + /** + * Open the Debug Console view in the bottom panel + * @returns Promise resolving to DebugConsoleView object + */ + async openDebugConsoleView(): Promise { + await this.openTab(BottomBarPanel.locators.BottomBarPanel.debugTab); + return new DebugConsoleView(this).wait(); + } + + /** + * Open the Terminal view in the bottom panel + * @returns Promise resolving to TerminalView object + */ + async openTerminalView(): Promise { + await this.openTab(BottomBarPanel.locators.BottomBarPanel.terminalTab); + return new TerminalView(this).wait(); + } + + /** + * Maximize the the bottom panel if not maximized + * @returns Promise resolving when the maximize button is pressed + */ + async maximize(): Promise { + await this.resize(BottomBarPanel.locators.BottomBarPanel.maximize); + } + + /** + * Restore the the bottom panel if maximized + * @returns Promise resolving when the restore button is pressed + */ + async restore(): Promise { + await this.resize(BottomBarPanel.locators.BottomBarPanel.restore); + } + + private async openTab(title: string) { + await this.toggle(true); + const tabContainer = await this.findElement(BottomBarPanel.locators.BottomBarPanel.tabContainer); + try { + const tabs = await tabContainer.findElements(BottomBarPanel.locators.BottomBarPanel.tab(title)); + if (tabs.length > 0) { + await tabs[0].click(); + } else { + const label = await tabContainer.findElement(By.xpath(`.//a[starts-with(@aria-label, '${title}')]`)); + await label.click(); + } + } catch (err) { + await new Workbench().executeCommand(`${title}: Focus on ${title} View`); + } + } + + private async resize(label: string) { + await this.toggle(true); + let action!: WebElement; + try { + action = await this.findElement(BottomBarPanel.locators.BottomBarPanel.globalActions) + .findElement(BottomBarPanel.locators.BottomBarPanel.action(label)); + } catch (err) { + // the panel is already maximized + } + if (action) { + await action.click(); + } + } + + public async closePanel() { + let closeButton = await this.findElement(BottomBarPanel.locators.BottomBarPanel.closeAction); + await closeButton.click(); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/bottomBar/ProblemsView.ts b/vscode-extension-tester/page-objects/src/components/bottomBar/ProblemsView.ts new file mode 100644 index 00000000..446b99a1 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/bottomBar/ProblemsView.ts @@ -0,0 +1,156 @@ +import { BottomBarPanel } from "../.."; +import { AbstractElement } from "../AbstractElement"; +import { WebElement } from 'selenium-webdriver'; +import { ElementWithContexMenu } from "../ElementWithContextMenu"; + +/** + * Problems view in the bottom panel + */ +export class ProblemsView extends AbstractElement { + constructor(panel: BottomBarPanel = new BottomBarPanel()) { + super(ProblemsView.locators.ProblemsView.constructor, panel); + } + + /** + * Set the filter using the input box on the problems view + * @param pattern filter to use, preferably a glob pattern + * @returns Promise resolving when the filter pattern is filled in + */ + async setFilter(pattern: string): Promise { + const filterField = await this.clearFilter(); + await filterField.sendKeys(pattern); + } + + /** + * Clear all filters + * @returns Promise resolving to the filter field WebElement + */ + async clearFilter(): Promise { + const filterField = await this.enclosingItem.findElement(ProblemsView.locators.BottomBarPanel.actions) + .findElement(ProblemsView.locators.ProblemsView.markersFilter) + .findElement(ProblemsView.locators.ProblemsView.input); + await filterField.clear(); + return filterField; + } + + /** + * Collapse all collapsible markers in the problems view + * @returns Promise resolving when the collapse all button is pressed + */ + async collapseAll(): Promise { + const button = await this.enclosingItem.findElement(ProblemsView.locators.BottomBarPanel.actions) + .findElement(ProblemsView.locators.ProblemsView.collapseAll); + await button.click(); + } + + /** + * @deprecated The method should not be used and getAllVisibleMarkers() should be used instead. + */ + async getAllMarkers(type: MarkerType): Promise { + return this.getAllVisibleMarkers(type); + } + + /** + * Get all visible markers from the problems view with the given type. + * Warning: this only returns the markers that are visible, and not the + * entire list, so calls to this function may change depending on the + * environment in which the tests are running in. + * To get all markers regardless of type, use MarkerType.Any + * @param type type of markers to retrieve + * @returns Promise resolving to array of Marker objects + */ + async getAllVisibleMarkers(type: MarkerType): Promise { + const markers: Marker[] = []; + const elements = await this.findElements(ProblemsView.locators.ProblemsView.markerRow); + for (const element of elements) { + let marker: Marker; + marker = await new Marker(element, this).wait(); + if (type === MarkerType.Any || type === await marker.getType()) { + markers.push(marker); + } + } + return markers; + } + + /** + * Gets the count badge + * @returns Promise resolving to the WebElement representing the count badge + */ + async getCountBadge(): Promise { + return await this.findElement(ProblemsView.locators.ProblemsView.changeCount); + } +} + +/** + * Page object for a Marker in Problems view + */ +export class Marker extends ElementWithContexMenu { + + constructor(element: WebElement, view: ProblemsView) { + super(element, view); + } + + /** + * Get the type of the marker. Possible types are: + * - File + * - Error + * - Warning + * @returns Promise resolving to a MarkerType + */ + async getType(): Promise { + const twist = await this.findElement(ProblemsView.locators.ProblemsView.markerTwistie); + if ((await twist.getAttribute('class')).indexOf('collapsible') > -1) { + return MarkerType.File; + } + const text = await this.getText(); + if (text.startsWith('Error')) { + return MarkerType.Error; + } else { + return MarkerType.Warning; + } + } + + /** + * Get the full text of the Marker row + * @returns Promise resolving to a Marker row text + */ + override async getText(): Promise { + return await this.getAttribute(ProblemsView.locators.ProblemsView.rowLabel); + } + + /** + * Get the Marker label text + * @returns Promise resolving to a Marker label + */ + async getLabel(): Promise { + return await (await this.findElement(ProblemsView.locators.ProblemsView.label)).getText(); + } + + /** + * Expand/Collapse the Marker if possible + * @param expand True to expand, False to collapse + * @returns Promise resolving when the expand/collapse twistie is clicked + */ + async toggleExpand(expand: boolean): Promise { + if (await this.getType() === MarkerType.File) { + const klass = await this.findElement(ProblemsView.locators.ProblemsView.markerTwistie).getAttribute('class'); + if ((klass.indexOf('collapsed') > -1) === expand) { + await this.click(); + } + } + } +} + +/** + * Possible types of markers + * - File = expandable item representing a file + * - Error = an error marker + * - Warning = a warning marker + * - Any = any of the above + */ +export enum MarkerType { + File = 'file', + Error = 'error', + Warning = 'warning', + Any = 'any' +} diff --git a/vscode-extension-tester/page-objects/src/components/bottomBar/Views.ts b/vscode-extension-tester/page-objects/src/components/bottomBar/Views.ts new file mode 100644 index 00000000..a0f80953 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/bottomBar/Views.ts @@ -0,0 +1,206 @@ +import { Key, until } from "selenium-webdriver"; +import { BottomBarPanel, ContentAssist, Workbench } from "../.."; +import { TextView, ChannelView } from "./AbstractViews"; +import { ElementWithContexMenu } from "../ElementWithContextMenu"; + +/** + * Output view of the bottom panel + */ +export class OutputView extends TextView { + constructor(panel: BottomBarPanel = new BottomBarPanel()) { + super(OutputView.locators.OutputView.constructor, panel); + this.actionsLabel = OutputView.locators.OutputView.actionsLabel; + } + + /** + * Select a channel using the selector combo + * @param name name of the channel to open + */ + override async selectChannel(name: string): Promise { + await super.selectChannel(name); + } +} + +/** + * Debug Console view on the bottom panel + * Most functionality will only be available when a debug session is running + */ +export class DebugConsoleView extends ElementWithContexMenu { + constructor(panel: BottomBarPanel = new BottomBarPanel()) { + super(DebugConsoleView.locators.DebugConsoleView.constructor, panel); + } + + /** + * Clear the console of all text + */ + async clearText(): Promise { + const menu = await this.openContextMenu(); + await menu.select('Clear Console'); + } + + /** + * Type an expression into the debug console text area + * @param expression expression in form of a string + */ + async setExpression(expression: string): Promise { + const textarea = await this.findElement(DebugConsoleView.locators.BottomBarViews.textArea); + await textarea.clear(); + await textarea.sendKeys(expression); + } + + /** + * Evaluate an expression: + * - if no argument is supplied, evaluate the current expression present in debug console text area + * - if a string argument is supplied, replace the current expression with the `expression` argument and evaluate + * + * @param expression expression to evaluate. To use existing contents of the debug console text area instead, don't define this argument + */ + async evaluateExpression(expression?: string): Promise { + const textarea = await this.findElement(DebugConsoleView.locators.BottomBarViews.textArea); + if (expression) { + await this.setExpression(expression); + } + await textarea.sendKeys(Key.ENTER); + } + + /** + * Create a content assist page object + * @returns promise resolving to ContentAssist object + */ + async getContentAssist(): Promise { + await this.getDriver().wait(until.elementLocated(ContentAssist.locators.ContentAssist.constructor), 1000); + return new ContentAssist(this).wait(); + } +} + +/** + * Terminal view on the bottom panel + */ +export class TerminalView extends ChannelView { + constructor(panel: BottomBarPanel = new BottomBarPanel()) { + super(TerminalView.locators.TerminalView.constructor, panel); + this.actionsLabel = TerminalView.locators.TerminalView.actionsLabel; + } + + /** + * Execute command in the internal terminal and wait for results + * @param command text of the command + * @param timeout optional maximum time to wait for completion in milliseconds, 0 for unlimited + * @returns Promise resolving when the command is finished + */ + async executeCommand(command: string, timeout: number = 0): Promise { + const input = await this.findElement(TerminalView.locators.TerminalView.textArea); + + try { + await input.clear(); + } catch (err) { + // try clearing, ignore if not available + } + await input.sendKeys(command, Key.ENTER); + + let timer = 0; + let style = await input.getCssValue('left'); + do { + if (timeout > 0 && timer > timeout) { + throw new Error(`Timeout of ${timeout}ms exceeded`); + } + await new Promise(res => setTimeout(res, 500)); + timer += 500; + style = await input.getCssValue('left'); + } while (style === '0px') + } + + /** + * Get all text from the internal terminal + * Beware, no formatting. + * + * @returns Promise resolving to all terminal text + */ + override async getText(): Promise { + const clipboard = (await import('clipboardy')).default; + let originalClipboard = ''; + try { + originalClipboard = clipboard.readSync(); + } catch (error) { + // workaround issue https://github.com/redhat-developer/vscode-extension-tester/issues/835 + // do not fail if clipboard is empty + } + const workbench = new Workbench(); + await workbench.executeCommand('terminal select all'); + await workbench.getDriver().sleep(500); + const text = clipboard.readSync(); + if (originalClipboard.length > 0) { + clipboard.writeSync(originalClipboard); + } + return text; + } + + /** + * Destroy the currently open terminal + * @returns Promise resolving when Kill Terminal button is pressed + */ + async killTerminal(): Promise { + await new Workbench().executeCommand('terminal: kill the active terminal instance'); + } + + /** + * Initiate new terminal creation + * @returns Promise resolving when New Terminal button is pressed + */ + async newTerminal(): Promise { + await new Workbench().executeCommand(TerminalView.locators.TerminalView.newCommand); + const combo = await this.enclosingItem.findElements(ChannelView.locators.BottomBarViews.channelCombo); + if (combo.length < 1) { + await this.getDriver().wait(async () => { + const list = await this.findElements(TerminalView.locators.TerminalView.tabList); + return list.length > 0; + }, 5000); + } + } + + override async getCurrentChannel(): Promise { + const combo = await this.enclosingItem.findElements(ChannelView.locators.BottomBarViews.channelCombo); + if (combo.length > 0) { + return await super.getCurrentChannel(); + } + const singleTerm = await this.enclosingItem.findElements(TerminalView.locators.TerminalView.singleTab); + if (singleTerm.length > 0) { + return await singleTerm[0].getText(); + } + const list = await this.findElement(TerminalView.locators.TerminalView.tabList); + const row = await list.findElement(TerminalView.locators.TerminalView.selectedRow); + await this.getDriver().sleep(1000); + const label = (await row.getAttribute('aria-label')).split(' '); + + return `${label[1]}: ${label[2]}` + } + + override async selectChannel(name: string): Promise { + const combo = await this.enclosingItem.findElements(ChannelView.locators.BottomBarViews.channelCombo); + if (combo.length > 0) { + return await super.selectChannel(name); + } + const singleTerm = await this.enclosingItem.findElements(TerminalView.locators.TerminalView.singleTab); + if (singleTerm.length > 0) { + return; + } + + const matches = name.match(/.*(\d+).?\s.*/); + if (matches === null || !matches[1]) { + throw new Error(`Channel ${name} not found`); + } + const channelNumber = matches[1]; + + const list = await this.findElement(TerminalView.locators.TerminalView.tabList); + const rows = await list.findElements(TerminalView.locators.TerminalView.row); + + for (const row of rows) { + const label = await row.getAttribute('aria-label'); + if (label.includes(channelNumber)) { + await row.click(); + return; + } + } + throw new Error(`Channel ${name} not found`); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/bottomBar/WebviewView.ts b/vscode-extension-tester/page-objects/src/components/bottomBar/WebviewView.ts new file mode 100644 index 00000000..df156cea --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/bottomBar/WebviewView.ts @@ -0,0 +1,21 @@ +import { WebElement } from "selenium-webdriver"; +import { AbstractElement } from "../AbstractElement"; +import WebviewMixin from "../WebviewMixin"; + +/** + * Page object representing a user-contributed panel implemented using a Webview. + */ +class WebviewViewBase extends AbstractElement { + + constructor() { + super(WebviewViewBase.locators.Workbench.constructor); + } + + async getViewToSwitchTo(handle: string): Promise { + return await this.getDriver().findElement(WebviewViewBase.locators.WebviewView.iframe); + } + +} + +export const WebviewView = WebviewMixin(WebviewViewBase); +export type WebviewView = InstanceType; diff --git a/vscode-extension-tester/page-objects/src/components/dialog/ModalDialog.ts b/vscode-extension-tester/page-objects/src/components/dialog/ModalDialog.ts new file mode 100644 index 00000000..dc9997ac --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/dialog/ModalDialog.ts @@ -0,0 +1,59 @@ +import { WebElement } from "selenium-webdriver"; +import { AbstractElement } from "../AbstractElement"; + +/** + * Page Object for Custom Style Modal Dialogs (non-native) + */ +export class ModalDialog extends AbstractElement { + + constructor() { + super(ModalDialog.locators.Dialog.constructor); + } + + /** + * Get the dialog's message in a Promise + */ + async getMessage(): Promise { + const message = await this.findElement(ModalDialog.locators.Dialog.message); + return await message.getText(); + } + + /** + * Get the details message in a Promise + */ + async getDetails(): Promise { + const details = await this.findElement(ModalDialog.locators.Dialog.details); + return await details.getText(); + } + + /** + * Get the list of buttons as WebElements + * + * @returns Promise resolving to Array of WebElement items representing the buttons + */ + async getButtons(): Promise { + return await this.findElement(ModalDialog.locators.Dialog.buttonContainer).findElements(ModalDialog.locators.Dialog.button); + } + + /** + * Push a button with given title if it exists + * + * @param title title/text of the button + */ + async pushButton(title: string): Promise { + const buttons = await this.getButtons(); + const titles = await Promise.all(buttons.map(async btn => ModalDialog.locators.Dialog.buttonLabel.value(btn))); + const index = titles.findIndex(value => value === title); + if (index > -1) { + await buttons[index].click(); + } + } + + /** + * Close the dialog using the 'cross' button + */ + async close(): Promise { + const btn = await this.findElement(ModalDialog.locators.Dialog.closeButton); + return await btn.click(); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/editor/Breakpoint.ts b/vscode-extension-tester/page-objects/src/components/editor/Breakpoint.ts new file mode 100644 index 00000000..c7fbbbc3 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/editor/Breakpoint.ts @@ -0,0 +1,36 @@ +import { until, WebElement } from 'selenium-webdriver'; +import { AbstractElement } from '../AbstractElement'; + +export class Breakpoint extends AbstractElement { + constructor(breakpoint: WebElement, private lineElement: WebElement) { + super(breakpoint, lineElement); + } + + override async isEnabled(): Promise { + return await Breakpoint.locators.TextEditor.breakpoint.properties.enabled(this); + } + + async isPaused(): Promise { + return await Breakpoint.locators.TextEditor.breakpoint.properties.paused(this); + } + + /** + * Return line number of the breakpoint. + * @returns number indicating line where breakpoint is set + */ + async getLineNumber(): Promise { + const breakpointLocators = Breakpoint.locators.TextEditor.breakpoint; + const line = await this.lineElement.findElement(breakpointLocators.properties.line.selector); + const lineNumber = await breakpointLocators.properties.line.number(line); + return lineNumber; + } + + /** + * Remove breakpoint. + * @param timeout time in ms when operation is considered to be unsuccessful + */ + async remove(timeout: number = 5000): Promise { + await this.click(); + await this.getDriver().wait(until.stalenessOf(this), timeout); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/editor/ContentAssist.ts b/vscode-extension-tester/page-objects/src/components/editor/ContentAssist.ts new file mode 100644 index 00000000..a47765d1 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/editor/ContentAssist.ts @@ -0,0 +1,100 @@ +import { TextEditor, Menu, MenuItem, DebugConsoleView } from "../.."; +import { error, Key, WebElement } from 'selenium-webdriver'; + +/** + * Page object representing the content assistant + */ +export class ContentAssist extends Menu { + constructor(parent: TextEditor | DebugConsoleView) { + super(ContentAssist.locators.ContentAssist.constructor, parent); + } + + /** + * Get content assist item by name/text, scroll through the list + * until the item is found, or the end is reached + * + * @param name name/text to search by + * @returns Promise resolving to ContentAssistItem object if found, undefined otherwise + */ + override async getItem(name: string): Promise { + let lastItem = false; + const scrollable = await this.findElement(ContentAssist.locators.ContentAssist.itemList); + + let firstItem = await this.findElements(ContentAssist.locators.ContentAssist.firstItem); + while(firstItem.length < 1) { + await scrollable.sendKeys(Key.PAGE_UP, Key.NULL); + firstItem = await this.findElements(ContentAssist.locators.ContentAssist.firstItem); + } + + while(!lastItem) { + const items = await this.getItems(); + + for (const item of items) { + if (await item.getLabel() === name) { + return item; + } + lastItem = lastItem ? lastItem : (await item.getAttribute('data-last-element')) === 'true'; + } + if (!lastItem) { + await scrollable.sendKeys(Key.PAGE_DOWN); + await new Promise(res => setTimeout(res, 100)); + } + } + + return Promise.resolve(undefined); + } + + /** + * Get all visible content assist items + * @returns Promise resolving to array of ContentAssistItem objects + */ + async getItems(): Promise { + await this.getDriver().wait(async () => { return await this.isLoaded(); }); + + const elements = await this.findElement(ContentAssist.locators.ContentAssist.itemRows) + .findElements(ContentAssist.locators.ContentAssist.itemRow); + const items: ContentAssistItem[] = []; + + for (const item of elements) { + try { + items.push(await new ContentAssistItem(item, this).wait()); + } catch (err) { + if (!(err instanceof error.StaleElementReferenceError)) { + throw err; + } + } + } + return items; + } + + /** + * Find if the content assist is still loading the suggestions + * @returns promise that resolves to true when suggestions are done loading, + * to false otherwise + */ + async isLoaded(): Promise { + const message = await this.findElement(ContentAssist.locators.ContentAssist.message); + if (await message.isDisplayed()) { + if ((await message.getText()).startsWith('No suggestions')) { + return true; + } + return false; + } + return true; + } +} + +/** + * Page object for a content assist item + */ +export class ContentAssistItem extends MenuItem { + constructor(item: WebElement, contentAssist: ContentAssist) { + super(item, contentAssist); + this.parent = contentAssist; + } + + override async getLabel(): Promise { + const labelDiv = await this.findElement(ContentAssist.locators.ContentAssist.itemLabel); + return await labelDiv.getText(); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/editor/CustomEditor.ts b/vscode-extension-tester/page-objects/src/components/editor/CustomEditor.ts new file mode 100644 index 00000000..a2a4805a --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/editor/CustomEditor.ts @@ -0,0 +1,45 @@ +import { Key } from "selenium-webdriver"; +import { Editor, InputBox, WebView } from "../.."; + +/** + * Page object for custom editors + */ +export class CustomEditor extends Editor { + + /** + * Get the WebView object contained in the editor + * @returns WebView page object + */ + getWebView(): WebView { + return new WebView(); + } + + /** + * Check if the editor has unsaved changes + * @returns Promise resolving to true if there are unsaved changes, false otherwise + */ + async isDirty(): Promise { + const tab = await this.getTab(); + const klass = await tab.getAttribute('class'); + return klass.includes('dirty'); + } + + /** + * Save the editor + */ + async save(): Promise { + const tab = await this.getTab(); + await tab.sendKeys(Key.chord(CustomEditor.ctlKey, 's')); + } + + /** + * Open the Save as prompt + * + * @returns InputBox serving as a simple file dialog + */ + async saveAs(): Promise { + const tab = await this.getTab(); + await tab.sendKeys(Key.chord(CustomEditor.ctlKey, Key.SHIFT, 's')); + return await InputBox.create(); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/editor/DiffEditor.ts b/vscode-extension-tester/page-objects/src/components/editor/DiffEditor.ts new file mode 100644 index 00000000..a8081e14 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/editor/DiffEditor.ts @@ -0,0 +1,29 @@ +import { Editor } from './Editor'; +import { TextEditor } from './TextEditor'; +import { EditorView } from './EditorView'; + +/** + * Page object representing a diff editor + */ +export class DiffEditor extends Editor { + + /** + * Gets the text editor corresponding to the originalside. + * (The left side of the diff editor) + * @returns Promise resolving to TextEditor object + */ + async getOriginalEditor(): Promise { + const element = await this.getEnclosingElement().findElement(DiffEditor.locators.DiffEditor.originalEditor); + return new TextEditor(new EditorView(), element); + } + + /** + * Gets the text editor corresponding to the modified side. + * (The right side of the diff editor) + * @returns Promise resolving to TextEditor object + */ + async getModifiedEditor(): Promise { + const element = await this.getEnclosingElement().findElement(DiffEditor.locators.DiffEditor.modifiedEditor); + return new TextEditor(new EditorView(), element); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/editor/Editor.ts b/vscode-extension-tester/page-objects/src/components/editor/Editor.ts new file mode 100644 index 00000000..81b13608 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/editor/Editor.ts @@ -0,0 +1,29 @@ +import { ElementWithContexMenu } from "../ElementWithContextMenu"; +import { EditorTab, EditorView, EditorGroup } from "../.."; +import { WebElement, Locator } from 'selenium-webdriver'; + +/** + * Abstract representation of an editor tab + */ +export abstract class Editor extends ElementWithContexMenu { + + constructor(view: EditorView | EditorGroup = new EditorView(), base: Locator | WebElement = Editor.locators.Editor.constructor) { + super(base, view); + } + + /** + * Get title/name of the open editor + */ + async getTitle(): Promise { + const tab = await this.getTab(); + return await tab.getTitle(); + } + + /** + * Get the corresponding editor tab + */ + async getTab(): Promise { + const element = this.enclosingItem as EditorView | EditorGroup; + return await element.getActiveTab() as EditorTab; + } +} diff --git a/vscode-extension-tester/page-objects/src/components/editor/EditorAction.ts b/vscode-extension-tester/page-objects/src/components/editor/EditorAction.ts new file mode 100644 index 00000000..519d9f9c --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/editor/EditorAction.ts @@ -0,0 +1,16 @@ +import { EditorGroup } from "./EditorView"; +import { AbstractElement } from "../AbstractElement"; +import { WebElement } from "../.."; + +export class EditorAction extends AbstractElement { + constructor(element: WebElement, parent: EditorGroup) { + super(element, parent); + } + + /** + * Get text description of the action. + */ + async getTitle(): Promise { + return await this.getAttribute(EditorAction.locators.EditorView.attribute); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/editor/EditorView.ts b/vscode-extension-tester/page-objects/src/components/editor/EditorView.ts new file mode 100644 index 00000000..623fa874 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/editor/EditorView.ts @@ -0,0 +1,374 @@ +import { error, WebElement } from "selenium-webdriver"; +import { TextEditor } from "../.."; +import { AbstractElement } from "../AbstractElement"; +import { ElementWithContexMenu } from "../ElementWithContextMenu"; +import { DiffEditor } from './DiffEditor'; +import { Editor } from "./Editor"; +import { EditorAction } from "./EditorAction"; +import { SettingsEditor } from "./SettingsEditor"; +import { WebView } from "./WebView"; + +export class EditorTabNotFound extends Error { + constructor(title: string, group: number) { + super(`No editor with title '${title}' in group '${group}' available`); + } +} + +/** + * View handling the open editors + */ +export class EditorView extends AbstractElement { + constructor() { + super(EditorView.locators.EditorView.constructor, EditorView.locators.Workbench.constructor); + } + + /** + * Switch to an editor tab with the given title + * @param title title of the tab + * @param groupIndex zero based index for the editor group (0 for the left most group) + * @returns Promise resolving to Editor object + */ + async openEditor(title: string, groupIndex: number = 0): Promise { + const group = await this.getEditorGroup(groupIndex); + return group.openEditor(title); + } + + /** + * Close an editor tab with the given title + * @param title title of the tab + * @param groupIndex zero based index for the editor group (0 for the left most group) + * @returns Promise resolving when the tab's close button is pressed + */ + async closeEditor(title: string, groupIndex: number = 0): Promise { + const group = await this.getEditorGroup(groupIndex); + return group.closeEditor(title); + } + + /** + * Close all open editor tabs + * @param groupIndex optional index to specify an editor group + * @returns Promise resolving once all tabs have had their close button pressed + */ + async closeAllEditors(groupIndex?: number): Promise { + let groups = await this.getEditorGroups(); + if (groupIndex !== undefined) { + return groups[groupIndex].closeAllEditors(); + } + + while (groups.length > 0 && (await groups[0].getOpenEditorTitles()).length > 0) { + await groups[0].closeAllEditors(); + await new Promise(res => setTimeout(res, 1000)); + groups = await this.getEditorGroups(); + } + } + + /** + * Retrieve all open editor tab titles in an array + * @param groupIndex optional index to specify an editor group, if left empty will search all groups + * @returns Promise resolving to array of editor titles + */ + async getOpenEditorTitles(groupIndex?: number): Promise { + const groups = await this.getEditorGroups(); + if (groupIndex !== undefined) { + return groups[groupIndex].getOpenEditorTitles(); + } + const titles: string[] = []; + for (const group of groups) { + titles.push(...(await group.getOpenEditorTitles())); + } + return titles; + } + + /** + * Retrieve an editor tab from a given group by title + * @param title title of the tab + * @param groupIndex zero based index of the editor group, default 0 (leftmost one) + * @returns promise resolving to EditorTab object + */ + async getTabByTitle(title: string, groupIndex: number = 0): Promise { + const group = await this.getEditorGroup(groupIndex); + return group.getTabByTitle(title); + } + + /** + * Retrieve all open editor tabs + * @param groupIndex index of group to search for tabs, if left undefined, all groups are searched + * @returns promise resolving to EditorTab list + */ + async getOpenTabs(groupIndex?: number): Promise { + const groups = await this.getEditorGroups(); + if (groupIndex !== undefined) { + return groups[groupIndex].getOpenTabs(); + } + const tabs: EditorTab[] = []; + for (const group of groups) { + tabs.push(...(await group.getOpenTabs())); + } + return tabs; + } + + /** + * Retrieve the active editor tab + * @returns promise resolving to EditorTab object, undefined if no tab is active + */ + async getActiveTab(): Promise { + const tabs = await this.getOpenTabs(); + + for (const tab of tabs) { + if (await tab.isSelected()) { + return tab; + } + } + + return undefined; + } + + /** + * Retrieve all editor groups in a list, sorted left to right + * @returns promise resolving to an array of EditorGroup objects + */ + async getEditorGroups(): Promise { + const elements = await this.findElements(EditorGroup.locators.EditorView.editorGroup); + const groups = await Promise.all(elements.map(async (element, index) => new EditorGroup(element, this, index).wait())); + + // sort the groups by x coordinates, so the leftmost is always at index 0 + for (let i = 0; i < groups.length - 1; i++) { + for (let j = 0; j < groups.length - i - 1; j++) { + if ((await groups[j].getRect()).x > (await groups[j + 1].getRect()).x) { + let temp = groups[j]; + groups[j] = groups[j + 1]; + groups[j + 1] = temp; + } + } + } + return groups; + } + + /** + * Retrieve an editor group with a given index (counting from left to right) + * @param index zero based index of the editor group (leftmost group has index 0) + * @returns promise resolving to an EditorGroup object + */ + async getEditorGroup(index: number): Promise { + return (await this.getEditorGroups())[index]; + } + + /** + * Get editor actions of a select editor group + * @param groupIndex zero based index of the editor group (leftmost group has index 0), default 0 + * @returns promise resolving to list of EditorAction objects + */ + async getActions(groupIndex: number = 0): Promise { + const group = await this.getEditorGroup(groupIndex); + return group.getActions(); + } + + /** + * Get editor action of a select editor group, search by title or predicate + * @param predicateOrTitle title or predicate to be used in search process + * @param groupIndex zero based index of the editor group (leftmost group has index 0), default 0 + * @returns promise resolving to EditorAction object if found, undefined otherwise + */ + async getAction(predicateOrTitle: string | ((action: EditorAction) => boolean | PromiseLike), groupIndex: number = 0): Promise { + const group = await this.getEditorGroup(groupIndex); + return group.getAction(predicateOrTitle); + } +} + +/** + * Page object representing an editor group + */ +export class EditorGroup extends AbstractElement { + constructor(element: WebElement, view: EditorView = new EditorView(), private index: number = 0) { + super(element, view); + } + + /** + * Switch to an editor tab with the given title + * @param title title of the tab + * @returns Promise resolving to Editor object + */ + async openEditor(title: string): Promise { + const tab = await this.getTabByTitle(title); + await tab.select(); + + try { + await this.findElement(EditorView.locators.EditorView.settingsEditor); + return new SettingsEditor(this).wait(); + } catch (err) { + try { + await this.findElement(EditorView.locators.EditorView.webView); + return new WebView(this).wait(); + } catch (err) { + try { + await this.findElement(EditorView.locators.EditorView.diffEditor); + return new DiffEditor(this).wait(); + } catch (err) { + return new TextEditor(this).wait(); + } + } + } + } + + /** + * Close an editor tab with the given title + * @param title title of the tab + * @returns Promise resolving when the tab's close button is pressed + */ + async closeEditor(title: string): Promise { + const tab = await this.getTabByTitle(title); + const closeButton = await tab.findElement(EditorView.locators.EditorView.closeTab); + await closeButton.click(); + } + + /** + * Close all open editor tabs + * @returns Promise resolving once all tabs have had their close button pressed + */ + async closeAllEditors(): Promise { + let titles = await this.getOpenEditorTitles(); + while (titles.length > 0) { + await this.closeEditor(titles[0]); + try { + // check if the group still exists + await this.getTagName(); + } catch (err) { + break; + } + titles = await this.getOpenEditorTitles(); + } + } + + /** + * Retrieve all open editor tab titles in an array + * @returns Promise resolving to array of editor titles + */ + async getOpenEditorTitles(): Promise { + const tabs = await this.findElements(EditorView.locators.EditorView.tab); + const titles = []; + for (const tab of tabs) { + try { + const title = await new EditorTab(tab, this.enclosingItem as EditorView).getTitle(); + titles.push(title); + } + catch (e) { + if (e instanceof error.StaleElementReferenceError) { + continue; + } + throw e; + } + } + return titles; + } + + /** + * Retrieve an editor tab by title + * @param title title of the tab + * @returns promise resolving to EditorTab object + */ + async getTabByTitle(title: string): Promise { + const tabs = await this.findElements(EditorView.locators.EditorView.tab); + for (const element of tabs) { + try { + const tab = new EditorTab(element, this.enclosingItem as EditorView); + const label = await tab.getTitle(); + if (label === title) { + return tab; + } + } + catch (e) { + if (e instanceof error.StaleElementReferenceError) { + continue; + } + throw e; + } + } + throw new EditorTabNotFound(title, this.index); + } + + /** + * Retrieve all open editor tabs + * @returns promise resolving to EditorTab list + */ + async getOpenTabs(): Promise { + const tabs = await this.findElements(EditorView.locators.EditorView.tab); + return Promise.all(tabs.map(async tab => new EditorTab(tab, this.enclosingItem as EditorView).wait())); + } + + /** + * Retrieve the active editor tab + * @returns promise resolving to EditorTab object, undefined if no tab is active + */ + async getActiveTab(): Promise { + const tabs = await this.getOpenTabs(); + + for (const tab of tabs) { + if (await tab.isSelected()) { + return tab; + } + } + + return undefined; + } + + /** + * Retrieve the editor action buttons as EditorActions + * @returns promise resolving to list of EditorAction objects + */ + async getActions(): Promise { + const actions = await + this.findElement(EditorGroup.locators.EditorView.actionContainer) + .findElements(EditorGroup.locators.EditorView.actionItem); + return actions.map((action) => new EditorAction(action, this)); + } + + /** + * Find an editor action button by predicate or title + * @param predicateOrTitle predicate/title to be used + * @returns promise resolving to EditorAction representing the button if found, undefined otherwise + */ + async getAction(predicateOrTitle: string | ((action: EditorAction) => boolean | PromiseLike)): Promise { + const predicate = (typeof predicateOrTitle === 'string') ? + (async (action: EditorAction) => await action.getTitle() === predicateOrTitle) : (predicateOrTitle); + + const actions = await this.getActions(); + + for (const action of actions) { + if (await predicate(action)) { + return action; + } + } + return undefined; + } +} + +/** + * Page object for editor view tab + */ +export class EditorTab extends ElementWithContexMenu { + constructor(element: WebElement, view: EditorView) { + super(element, view); + } + + /** + * Get the tab title as string + */ + async getTitle(): Promise { + const label = await this.findElement(EditorTab.locators.Editor.title); + return await label.getText(); + } + + /** + * Select (click) the tab + */ + async select(): Promise { + const tabCoords = await this.getRect(); + await this.getDriver().actions().move({ x: Math.ceil(tabCoords.x + 10), y: Math.ceil(tabCoords.y + 10) }).click().perform(); + } + + override async isSelected(): Promise { + const klass = await this.getAttribute('class'); + const segments = klass?.split(/\s+/g) ?? []; + return await super.isSelected() || segments.includes('active'); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/editor/SettingsEditor.ts b/vscode-extension-tester/page-objects/src/components/editor/SettingsEditor.ts new file mode 100644 index 00000000..5cc74659 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/editor/SettingsEditor.ts @@ -0,0 +1,312 @@ +import { Editor } from "./Editor"; +import { ContextMenu } from "../menu/ContextMenu"; +import { WebElement, Key, By } from "selenium-webdriver"; +import { AbstractElement } from "../AbstractElement"; +import { EditorView, EditorGroup } from "../.."; + +/** + * Page object representing the internal VSCode settings editor + */ +export class SettingsEditor extends Editor { + + private divider = SettingsEditor.versionInfo.version >= '1.83.0' ? '›' : ' › '; + + constructor(view: EditorView | EditorGroup = new EditorView()) { + super(view); + } + + /** + * Search for a setting with a particular title and category. + * Returns an appropriate Setting object if the label is found, + * undefined otherwise. + * + * If your setting has nested categories (i.e `example.general.test`), + * pass in each category as a separate string. + * + * @param title title of the setting + * @param categories category of the setting + * @returns Promise resolving to a Setting object if found, undefined otherwise + */ + async findSetting(title: string, ...categories: string[]): Promise { + const category = categories.join(this.divider); + const searchBox = await this.findElement(SettingsEditor.locators.Editor.inputArea); + await searchBox.sendKeys(Key.chord(SettingsEditor.ctlKey, 'a')); + await searchBox.sendKeys(`${category}${this.divider}${title}`); + + return await this._getSettingItem(title, category); + } + + /** + * Search for a setting with a precise ID. + * Returns an appropriate Setting object if it exists, + * undefined otherwise. + * + * @param id of the setting + * @returns Promise resolving to a Setting object if found, undefined otherwise + */ + async findSettingByID(id: string): Promise { + const searchBox = await this.findElement(SettingsEditor.locators.Editor.inputArea); + await searchBox.sendKeys(Key.chord(SettingsEditor.ctlKey, 'a')); + await searchBox.sendKeys(id); + + const title = id.split('.').pop(); + return await this._getSettingItem(title); + } + + private async _getSettingItem(title: string = '', category: string = ''): Promise { + const count = await this.findElement(SettingsEditor.locators.SettingsEditor.itemCount); + let textCount = await count.getText(); + await this.getDriver().wait(async function() { + await new Promise(res => setTimeout(res, 1500)); + const text = await count.getText(); + if (text !== textCount) { + textCount = text; + return false; + } + return true; + }); + + let setting!: Setting; + const items = await this.findElements(SettingsEditor.locators.SettingsEditor.itemRow); + for (const item of items) { + try { + const _category = (await (await item.findElement(SettingsEditor.locators.SettingsEditor.settingCategory)).getText()).replace(':', ''); + const _title = await (await item.findElement(SettingsEditor.locators.SettingsEditor.settingLabel)).getText(); + if(category !== '') { + if(category.toLowerCase().replace(this.divider, '').replace(/\s/g, '').trim() !== _category.toLowerCase().replace(this.divider, '').replace(/\s/g, '').trim()) { + continue; + } + } + if(title !== '') { + if(title.toLowerCase().replace(/\s/g, '').trim() !== _title.toLowerCase().replace(/\s/g, '').trim()) { + continue; + } + } + return await (await this.createSetting(item, _title, _category)).wait(); + } catch (err) { + } + } + return setting; + } + + /** + * Switch between settings perspectives + * Works only if your vscode instance has both user and workspace settings available + * + * @param perspective User or Workspace + * @returns Promise that resolves when the appropriate button is clicked + */ + async switchToPerspective(perspective: 'User' | 'Workspace'): Promise { + const actions = await this.findElement(SettingsEditor.locators.SettingsEditor.header) + .findElement(SettingsEditor.locators.SettingsEditor.tabs) + .findElement(SettingsEditor.locators.SettingsEditor.actions); + await actions.findElement(SettingsEditor.locators.SettingsEditor.action(perspective)).click(); + } + + /** + * Context menu is disabled in this editor, throw an error + */ + override async openContextMenu(): Promise { + throw new Error('Operation not supported'); + } + + private async createSetting(element: WebElement, title: string, category: string): Promise { + await element.findElement(SettingsEditor.locators.SettingsEditor.settingConstructor(title, category)); + try { + // try a combo setting + await element.findElement(SettingsEditor.locators.SettingsEditor.comboSetting); + return new ComboSetting(SettingsEditor.locators.SettingsEditor.settingConstructor(title, category), this); + } catch (err) { + try { + // try text setting + await element.findElement(SettingsEditor.locators.SettingsEditor.textSetting); + return new TextSetting(SettingsEditor.locators.SettingsEditor.settingConstructor(title, category), this); + } catch (err) { + try { + // try checkbox setting + await element.findElement(SettingsEditor.locators.SettingsEditor.checkboxSetting); + return new CheckboxSetting(SettingsEditor.locators.SettingsEditor.settingConstructor(title, category), this); + } catch (err) { + // try link setting + try { + await element.findElement(SettingsEditor.locators.SettingsEditor.linkButton); + return new LinkSetting(SettingsEditor.locators.SettingsEditor.settingConstructor(title, category), this); + } catch (err) { + throw new Error('Setting type not supported'); + } + } + } + } + } +} + +/** + * Abstract item representing a Setting with title, description and + * an input element (combo/textbox/checkbox/link) + */ +export abstract class Setting extends AbstractElement { + + constructor(settingsConstructor: By, settings: SettingsEditor) { + super(settingsConstructor, settings); + } + + /** + * Get the value of the setting based on its input type + * + * @returns promise that resolves to the current value of the setting + */ + abstract getValue(): Promise + + /** + * Set the value of the setting based on its input type + * + * @param value boolean for checkboxes, string otherwise + */ + abstract setValue(value: string | boolean): Promise + + /** + * Get the category of the setting + * All settings are labeled as Category: Title + */ + async getCategory(): Promise { + return await (await this.findElement(SettingsEditor.locators.SettingsEditor.settingCategory)).getText(); + } + + /** + * Get description of the setting + * @returns Promise resolving to setting description + */ + async getDescription(): Promise { + return await (await this.findElement(SettingsEditor.locators.SettingsEditor.settingDesctiption)).getText(); + } + + /** + * Get title of the setting + */ + async getTitle(): Promise { + return await (await this.findElement(SettingsEditor.locators.SettingsEditor.settingLabel)).getText(); + } +} + +/** + * Setting with a combo box + */ +export class ComboSetting extends Setting { + + async getValue(): Promise { + const combo = await this.findElement(SettingsEditor.locators.SettingsEditor.comboSetting); + return await combo.getAttribute(SettingsEditor.locators.SettingsEditor.comboValue); + } + + async setValue(value: string): Promise { + const rows = await this.getOptions(); + for (let i = 0; i < rows.length; i++) { + if ((await rows[i].getAttribute('class')).indexOf('disabled') < 0) { + const text = await (await rows[i].findElement(SettingsEditor.locators.SettingsEditor.comboOption)).getText(); + if (value === text) { + return await rows[i].click(); + } + } + } + } + + /** + * Get the labels of all options from the combo + * @returns Promise resolving to array of string values + */ + async getValues(): Promise { + const values = []; + const rows = await this.getOptions(); + + for (const row of rows) { + values.push(await (await row.findElement(SettingsEditor.locators.SettingsEditor.comboOption)).getText()) + } + return values; + } + + private async getOptions(): Promise { + const menu = await this.openCombo(); + return await menu.findElements(SettingsEditor.locators.SettingsEditor.itemRow); + } + + private async openCombo(): Promise { + const combo = await this.findElement(SettingsEditor.locators.SettingsEditor.comboSetting); + const workbench = await this.getDriver().findElement(SettingsEditor.locators.Workbench.constructor); + const menus = await workbench.findElements(SettingsEditor.locators.ContextMenu.contextView); + let menu!: WebElement; + + if (menus.length < 1) { + await combo.click(); + menu = await workbench.findElement(SettingsEditor.locators.ContextMenu.contextView); + return menu; + } else if (await menus[0].isDisplayed()) { + await combo.click(); + await this.getDriver().sleep(200); + } + await combo.click(); + menu = await workbench.findElement(SettingsEditor.locators.ContextMenu.contextView); + return menu; + } +} + +/** + * Setting with a text box input + */ +export class TextSetting extends Setting { + + async getValue(): Promise { + const input = await this.findElement(SettingsEditor.locators.SettingsEditor.textSetting); + return await input.getAttribute('value'); + } + + async setValue(value: string): Promise { + const input = await this.findElement(SettingsEditor.locators.SettingsEditor.textSetting); + await input.clear(); + await input.sendKeys(value); + } +} + +/** + * Setting with a checkbox + */ +export class CheckboxSetting extends Setting { + + async getValue(): Promise { + const checkbox = await this.findElement(SettingsEditor.locators.SettingsEditor.checkboxSetting); + const checked = await checkbox.getAttribute(SettingsEditor.locators.SettingsEditor.checkboxChecked); + if (checked === 'true') { + return true; + } + return false; + } + + async setValue(value: boolean): Promise { + const checkbox = await this.findElement(SettingsEditor.locators.SettingsEditor.checkboxSetting); + if (await this.getValue() !== value) { + await checkbox.click(); + } + } +} + +/** + * Setting with no value, with a link to settings.json instead + */ +export class LinkSetting extends Setting { + + async getValue(): Promise { + throw new Error('Method getValue is not available for LinkSetting'); + } + + async setValue(value: string | boolean): Promise { + throw new Error('Method setValue is not available for LinkSetting'); + } + + /** + * Open the link that leads to the value in settings.json + * @returns Promise resolving when the link has been clicked + */ + async openLink(): Promise { + const link = await this.findElement(SettingsEditor.locators.SettingsEditor.linkButton); + await link.click(); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/editor/TextEditor.ts b/vscode-extension-tester/page-objects/src/components/editor/TextEditor.ts new file mode 100644 index 00000000..94fe65a2 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/editor/TextEditor.ts @@ -0,0 +1,761 @@ +import { ContentAssist, ContextMenu, InputBox, Workbench } from "../.."; +import { By, ChromiumWebDriver, Key, until, WebElement } from "selenium-webdriver"; +import { fileURLToPath } from "url"; +import { StatusBar } from "../statusBar/StatusBar"; +import { Editor } from "./Editor"; +import { ElementWithContexMenu } from "../ElementWithContextMenu"; +import { AbstractElement } from "../AbstractElement"; +import { Breakpoint } from "./Breakpoint"; + +export class BreakpointError extends Error { } + +/** + * Page object representing the active text editor + */ +export class TextEditor extends Editor { + breakPoints: number[] = []; + + /** + * Find whether the active editor has unsaved changes + * @returns Promise resolving to true/false + */ + async isDirty(): Promise { + const tab = await this.enclosingItem.findElement(TextEditor.locators.TextEditor.activeTab); + const klass = await tab.getAttribute('class'); + return klass.indexOf('dirty') >= 0; + } + + /** + * Saves the active editor + * @returns Promise resolving when ctrl+s is invoked + */ + async save(): Promise { + const inputarea = await this.findElement(TextEditor.locators.Editor.inputArea); + await inputarea.sendKeys(Key.chord(TextEditor.ctlKey, 's')); + } + + /** + * Open the Save as prompt + * + * @returns InputBox serving as a simple file dialog + */ + async saveAs(): Promise { + const tab = await this.getTab(); + await tab.sendKeys(Key.chord(TextEditor.ctlKey, Key.SHIFT, 's')); + return await InputBox.create(); + } + + /** + * Retrieve the Uri of the file opened in the active editor + * @returns Promise resolving to editor's underlying Uri + */ + async getFileUri(): Promise { + const ed = await this.findElement(TextEditor.locators.TextEditor.editorContainer); + return await ed.getAttribute(TextEditor.locators.TextEditor.dataUri); + } + + /** + * Retrieve the path to the file opened in the active editor + * @returns Promise resolving to editor's underlying file path + */ + async getFilePath(): Promise { + return fileURLToPath(await this.getFileUri()); + } + + /** + * Open/Close the content assistant at the current position in the editor by sending the default + * keyboard shortcut signal + * @param open true to open, false to close + * @returns Promise resolving to ContentAssist object when opening, void otherwise + */ + async toggleContentAssist(open: boolean): Promise { + let isHidden = true; + try { + const assist = await this.findElement(TextEditor.locators.ContentAssist.constructor) + const klass = await assist.getAttribute('class'); + const visibility = await assist.getCssValue('visibility'); + isHidden = klass.indexOf('visible') < 0 || visibility === 'hidden'; + } catch (err) { + isHidden = true; + } + const inputarea = await this.findElement(TextEditor.locators.Editor.inputArea); + + if (open) { + if (isHidden) { + await inputarea.sendKeys(Key.chord(Key.CONTROL, Key.SPACE)); + await this.getDriver().wait(until.elementLocated(TextEditor.locators.ContentAssist.constructor), 2000); + } + const assist = await new ContentAssist(this).wait(); + await this.getDriver().wait(() => { return assist.isLoaded(); }, 10000); + return assist; + } else { + if (!isHidden) { + await inputarea.sendKeys(Key.ESCAPE); + } + } + } + + /** + * Get all text from the editor + * @returns Promise resolving to editor text + */ + override async getText(): Promise { + const clipboard = (await import('clipboardy')).default; + let originalClipboard = ''; + try { + originalClipboard = clipboard.readSync(); + } catch (error) { + // workaround issue https://github.com/redhat-developer/vscode-extension-tester/issues/835 + // do not fail if clipboard is empty + } + const inputarea = await this.findElement(TextEditor.locators.Editor.inputArea); + await inputarea.sendKeys(Key.chord(TextEditor.ctlKey, 'a'), Key.chord(TextEditor.ctlKey, 'c')); + await new Promise(res => setTimeout(res, 500)); + const text = clipboard.readSync(); + await inputarea.sendKeys(Key.UP); + if (originalClipboard.length > 0) { + clipboard.writeSync(originalClipboard); + } + return text; + } + + /** + * Replace the contents of the editor with a given text + * @param text text to type into the editor + * @param formatText format the new text, default false + * @returns Promise resolving once the new text is copied over + */ + async setText(text: string, formatText: boolean = false): Promise { + const clipboard = (await import('clipboardy')).default; + let originalClipboard = ''; + try { + originalClipboard = clipboard.readSync(); + } catch (error) { + // workaround issue https://github.com/redhat-developer/vscode-extension-tester/issues/835 + // do not fail if clipboard is empty + } + const inputarea = await this.findElement(TextEditor.locators.Editor.inputArea); + clipboard.writeSync(text); + await inputarea.sendKeys(Key.chord(TextEditor.ctlKey, 'a'), Key.chord(TextEditor.ctlKey, 'v')); + if (originalClipboard.length > 0) { + clipboard.writeSync(originalClipboard); + } + if (formatText) { + await this.formatDocument(); + } + } + + /** + * Deletes all text within the editor + * @returns Promise resolving once the text is deleted + */ + async clearText(): Promise { + const inputarea = await this.findElement(TextEditor.locators.Editor.inputArea); + await inputarea.sendKeys(Key.chord(TextEditor.ctlKey, 'a')); + await inputarea.sendKeys(Key.BACK_SPACE); + } + + /** + * Get text from a given line + * @param line number of the line to retrieve + * @returns Promise resolving to text at the given line number + */ + async getTextAtLine(line: number): Promise { + const text = await this.getText(); + const lines = text.split('\n'); + if (line < 1 || line > lines.length) { + throw new Error(`Line number ${line} does not exist`); + } + return lines[line - 1]; + } + + /** + * Replace the contents of a line with a given text + * @param line number of the line to edit + * @param text text to set at the line + * @returns Promise resolving when the text is typed in + */ + async setTextAtLine(line: number, text: string): Promise { + if (line < 1 || line > await this.getNumberOfLines()) { + throw new Error(`Line number ${line} does not exist`); + } + const lines = (await this.getText()).split('\n'); + lines[line - 1] = text; + await this.setText(lines.join('\n')); + } + + /** + * Get line number that contains the given text. Not suitable for multi line inputs. + * + * @param text text to search for + * @param occurrence select which occurrence of the search text to look for in case there are multiple in the document, defaults to 1 (the first instance) + * + * @returns Number of the line that contains the start of the given text. -1 if no such text is found. + * If occurrence number is specified, searches until it finds as many instances of the given text. + * Returns the line number that holds the last occurrence found this way. + */ + async getLineOfText(text: string, occurrence = 1): Promise { + let lineNum = -1; + let found = 0; + const lines = (await this.getText()).split('\n'); + + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes(text)) { + found++; + lineNum = i + 1; + if (found >= occurrence) { + break; + } + } + } + return lineNum; + } + + /** + * Find and select a given text. Not usable for multi line selection. + * + * @param text text to select + * @param occurrence specify which onccurrence of text to select if multiple are present in the document + */ + async selectText(text: string, occurrence = 1): Promise { + const lineNum = await this.getLineOfText(text, occurrence); + if (lineNum < 1) { + throw new Error(`Text '${text}' not found`); + } + + const line = await this.getTextAtLine(lineNum); + const column = line.indexOf(text) + 1; + + await this.moveCursor(lineNum, column); + + let actions = this.getDriver().actions(); + await actions.clear(); + actions.keyDown(Key.SHIFT); + for (let i = 0; i < text.length; i++) { + actions = actions.sendKeys(Key.RIGHT); + } + actions = actions.keyUp(Key.SHIFT); + await actions.perform(); + await new Promise(res => setTimeout(res, 500)); + } + + /** + * Get the text that is currently selected as string + */ + async getSelectedText(): Promise { + const clipboard = (await import('clipboardy')).default; + let originalClipboard = ''; + try { + originalClipboard = clipboard.readSync(); + } catch (error) { + // workaround issue https://github.com/redhat-developer/vscode-extension-tester/issues/835 + // do not fail if clipboard is empty + } + if (process.platform !== 'darwin') { + const selection = await this.getSelection(); + if (!selection) { + return ''; + } + const menu = await selection.openContextMenu(); + await menu.select('Copy'); + } else { + const inputarea = await this.findElement(TextEditor.locators.Editor.inputArea); + await inputarea.sendKeys(Key.chord(TextEditor.ctlKey, 'c')); + await new Promise(res => setTimeout(res, 500)); + await inputarea.sendKeys(Key.UP); + } + await new Promise(res => setTimeout(res, 500)); + const text = clipboard.readSync(); + if (originalClipboard.length > 0) { + clipboard.writeSync(originalClipboard); + } + return text; + } + + /** + * Get the selection block as a page object + * @returns Selection page object + */ + async getSelection(): Promise { + const selection = await this.findElements(TextEditor.locators.TextEditor.selection); + if (selection.length < 1) { + return undefined; + } + return new Selection(selection[0], this); + } + + async openFindWidget(): Promise { + let actions = this.getDriver().actions(); + await actions.clear(); + await actions.keyDown(TextEditor.ctlKey).sendKeys('f').keyUp(TextEditor.ctlKey).perform(); + const widget = await this.getDriver().wait(until.elementLocated(TextEditor.locators.TextEditor.findWidget), 2000); + await this.getDriver().wait(until.elementIsVisible(widget), 2000); + + return new FindWidget(widget, this); + } + + /** + * Add the given text to the given coordinates + * @param line number of the line to type into + * @param column number of the column to start typing at + * @param text text to add + * @returns Promise resolving when the text is typed in + */ + async typeTextAt(line: number, column: number, text: string): Promise { + await this.moveCursor(line, column); + await this.typeText(text); + } + + /** + * Type given text at the current coordinates + * @param text text to type + * @returns promise resolving when the text is typed in + */ + async typeText(text: string): Promise { + const inputarea = await this.findElement(TextEditor.locators.Editor.inputArea); + await inputarea.sendKeys(text); + } + + /** + * Move the cursor to the given coordinates + * @param line line number to move to + * @param column column number to move to + * @returns Promise resolving when the cursor has reached the given coordinates + */ + async moveCursor(line: number, column: number): Promise { + if (line < 1 || line > await this.getNumberOfLines()) { + throw new Error(`Line number ${line} does not exist`); + } + if (column < 1) { + throw new Error(`Column number ${column} does not exist`); + } + if (process.platform === 'darwin') { + const input = await new Workbench().openCommandPrompt(); + await input.setText(`:${line},${column}`); + await input.confirm(); + } else { + const inputarea = await this.findElement(TextEditor.locators.Editor.inputArea); + let coordinates = await this.getCoordinates(); + const lineGap = coordinates[0] - line; + const lineKey = lineGap >= 0 ? Key.UP : Key.DOWN; + for (let i = 0; i < Math.abs(lineGap); i++) { + await inputarea.sendKeys(lineKey); + } + + coordinates = await this.getCoordinates(); + const columnGap = coordinates[1] - column; + const columnKey = columnGap >= 0 ? Key.LEFT : Key.RIGHT; + for (let i = 0; i < Math.abs(columnGap); i++) { + await inputarea.sendKeys(columnKey); + let actualCoordinates = (await this.getCoordinates())[0]; + if (actualCoordinates != coordinates[0]) { + throw new Error(`Column number ${column} is not accessible on line ${line}`); + } + } + } + await this.getDriver().wait(async () => { + const coor = await this.getCoordinates(); + return coor[0] === line && coor[1] === column; + }, 10000, `Unable to set cursor at position ${column}:${line}`); + } + + /** + * Get number of lines in the editor + * @returns Promise resolving to number of lines + */ + async getNumberOfLines(): Promise { + const lines = (await this.getText()).split('\n'); + return lines.length; + } + + /** + * Use the built-in 'Format Document' option to format the text + * @returns Promise resolving when the Format Document command is invoked + */ + async formatDocument(): Promise { + const menu = await this.openContextMenu(); + try { + await menu.select('Format Document'); + } catch (err) { + console.log('Warn: Format Document not available for selected language'); + if (await menu.isDisplayed()) { + await menu.close(); + } + } + } + + override async openContextMenu(): Promise { + await this.getDriver().actions().contextClick(this).perform(); + const shadowRootHost = await this.enclosingItem.findElements(By.className('shadow-root-host')); + + if (shadowRootHost.length > 0) { + let shadowRoot; + const webdriverCapabilities = await (this.getDriver() as ChromiumWebDriver).getCapabilities(); + const chromiumVersion = webdriverCapabilities.getBrowserVersion(); + if (chromiumVersion && parseInt(chromiumVersion.split('.')[0]) >= 96) { + shadowRoot = await shadowRootHost[0].getShadowRoot(); + return new ContextMenu(await shadowRoot.findElement(By.className('monaco-menu-container'))).wait(); + } else { + shadowRoot = await this.getDriver().executeScript('return arguments[0].shadowRoot', shadowRootHost[0]) as WebElement; + return new ContextMenu(shadowRoot).wait(); + } + + } + return await super.openContextMenu(); + } + + /** + * Get the cursor's coordinates as an array of two numbers: `[line, column]` + * + * **Caution** line & column coordinates do not start at `0` but at `1`! + */ + async getCoordinates(): Promise<[number, number]> { + const coords: number[] = []; + const statusBar = new StatusBar(); + const coordinates = (await statusBar.getCurrentPosition()).match(/\d+/g); + for (const c of coordinates) { + coords.push(+c); + } + return [coords[0], coords[1]]; + } + + /** + * Toggle breakpoint on a given line + * + * @param line target line number + * @returns promise resolving to True when a breakpoint was added, False when removed + */ + async toggleBreakpoint(line: number): Promise { + const margin = await this.findElement(TextEditor.locators.TextEditor.marginArea); + const lineNum = await margin.findElement(TextEditor.locators.TextEditor.lineNumber(line)); + await this.getDriver().actions().move({ origin: lineNum }).perform(); + + const lineOverlay = await margin.findElement(TextEditor.locators.TextEditor.lineOverlay(line)); + const breakpointContainer = TextEditor.versionInfo.version >= '1.80.0' ? await this.findElement(By.className('glyph-margin-widgets')) : lineOverlay; + const breakPoint = await breakpointContainer.findElements(TextEditor.locators.TextEditor.breakpoint.generalSelector); + if (breakPoint.length > 0) { + if (this.breakPoints.indexOf(line) != -1) { + await breakPoint[this.breakPoints.indexOf(line)].click(); + await new Promise(res => setTimeout(res, 200)); + this.breakPoints.splice(this.breakPoints.indexOf(line), 1); + return false; + } + } + const noBreak = await breakpointContainer.findElements(TextEditor.locators.TextEditor.debugHint); + if (noBreak.length > 0) { + await noBreak[0].click(); + await new Promise(res => setTimeout(res, 200)); + this.breakPoints.push(line); + return true; + } + return false; + } + + /** + * Get paused breakpoint if available. Otherwise, return undefined. + * @returns promise which resolves to either Breakpoint page object or undefined + */ + async getPausedBreakpoint(): Promise { + const breakpointLocators = Breakpoint.locators.TextEditor.breakpoint; + const breakpointContainer = TextEditor.versionInfo.version >= '1.80.0' ? await this.findElement(By.className('glyph-margin-widgets')) : this; + const breakpoints = await breakpointContainer.findElements(breakpointLocators.pauseSelector); + + if (breakpoints.length === 0) { + return undefined; + } + + if (breakpoints.length > 1) { + throw new BreakpointError(`unexpected number of paused breakpoints: ${breakpoints.length}; expected 1 at most`); + } + + // get parent + let lineElement: WebElement; + if (TextEditor.versionInfo.version >= '1.80.0') { + const styleTopAttr = await breakpoints[0].getCssValue('top'); + lineElement = await this.findElement(TextEditor.locators.TextEditor.marginArea).findElement(By.xpath(`.//div[contains(@style, "${styleTopAttr}")]`)) + } else { + lineElement = await breakpoints[0].findElement(By.xpath('./..')); + } + return new Breakpoint(breakpoints[0], lineElement); + } + + /** + * Get all code lenses within the editor + * @returns list of CodeLens page objects + */ + async getCodeLenses(): Promise { + const lenses: CodeLens[] = []; + const widgets = await this.findElement(By.className('contentWidgets')); + const items = await widgets.findElements(By.xpath(`.//span[contains(@widgetid, 'codelens.widget')]/a[@id]`)); + for (const item of items) { + lenses.push(await new CodeLens(item, this).wait()); + } + return lenses; + } + + /** + * Get a code lens based on title, or zero based index + * + * @param indexOrTitle zero based index (counting from the top of the editor), or partial title of the code lens + * @returns CodeLens object if such a code lens exists, undefined otherwise + */ + async getCodeLens(indexOrTitle: number | string): Promise { + const lenses = await this.getCodeLenses(); + + if (typeof (indexOrTitle) === 'string') { + for (const lens of lenses) { + const title = await lens.getText(); + const match = title.match(indexOrTitle); + if (match && match.length > 0) { + return lens; + } + } + } else if (lenses[indexOrTitle]) { + return lenses[indexOrTitle]; + } + return undefined; + } +} + +/** + * Text selection block + */ +class Selection extends ElementWithContexMenu { + + constructor(el: WebElement, editor: TextEditor) { + super(el, editor); + } + + override async openContextMenu(): Promise { + const ed = this.getEnclosingElement() as TextEditor; + await this.getDriver().actions().contextClick(this).perform(); + const shadowRootHost = await ed.getEnclosingElement().findElements(By.className('shadow-root-host')); + + if (shadowRootHost.length > 0) { + let shadowRoot; + const webdriverCapabilities = await (this.getDriver() as ChromiumWebDriver).getCapabilities(); + const chromiumVersion = webdriverCapabilities.getBrowserVersion(); + if (chromiumVersion && parseInt(chromiumVersion.split('.')[0]) >= 96) { + shadowRoot = await shadowRootHost[0].getShadowRoot(); + return new ContextMenu(await shadowRoot.findElement(By.className('monaco-menu-container'))).wait(); + } else { + shadowRoot = await this.getDriver().executeScript('return arguments[0].shadowRoot', shadowRootHost[0]) as WebElement; + return new ContextMenu(shadowRoot).wait(); + } + } + return await super.openContextMenu(); + } +} + +/** + * Page object for Code Lens inside a text editor + */ +export class CodeLens extends AbstractElement { + + /** + * Get tooltip of the code lens + * @returns tooltip as string + */ + async getTooltip(): Promise { + return await this.getAttribute('title'); + } +} + +/** + * Text Editor's Find Widget + */ +export class FindWidget extends AbstractElement { + + constructor(element: WebElement, editor: TextEditor) { + super(element, editor); + } + + /** + * Toggle between find and replace mode + * @param replace true for replace, false for find + */ + async toggleReplace(replace: boolean): Promise { + const btn = await this.findElement(FindWidget.locators.FindWidget.toggleReplace); + const klass = await btn.getAttribute('class'); + + if (replace && klass.includes('collapsed') || !replace && !klass.includes('collapsed')) { + await btn.sendKeys(Key.SPACE); + const repl = await this.getDriver().wait(until.elementLocated(FindWidget.locators.FindWidget.replacePart), 2000); + if (replace) { + await this.getDriver().wait(until.elementIsVisible(repl), 2000); + } else { + await this.getDriver().wait(until.elementIsNotVisible(repl), 2000); + } + } + } + + /** + * Set text in the search box + * @param text text to fill in + */ + async setSearchText(text: string): Promise { + const findPart = await this.findElement(FindWidget.locators.FindWidget.findPart); + await this.setText(text, findPart); + } + + /** + * Get text from Find input box + * @returns value of find input as string + */ + async getSearchText(): Promise { + const findPart = await this.findElement(FindWidget.locators.FindWidget.findPart); + return await this.getInputText(findPart); + } + + /** + * Set text in the replace box. Will toggle replace mode on if called in find mode. + * @param text text to fill in + */ + async setReplaceText(text: string): Promise { + await this.toggleReplace(true); + const replacePart = await this.findElement(FindWidget.locators.FindWidget.replacePart); + await this.setText(text, replacePart); + } + + + /** + * Get text from Replace input box + * @returns value of replace input as string + */ + async getReplaceText(): Promise { + const replacePart = await this.findElement(FindWidget.locators.FindWidget.replacePart); + return await this.getInputText(replacePart); + } + + /** + * Click 'Next match' + */ + async nextMatch(): Promise { + const name = TextEditor.versionInfo.version < '1.59.0' ? 'Next match' : 'Next Match'; + await this.clickButton(name, 'find'); + } + + /** + * Click 'Previous match' + */ + async previousMatch(): Promise { + const name = TextEditor.versionInfo.version < '1.59.0' ? 'Previous match' : 'Previous Match'; + await this.clickButton(name, 'find'); + } + + /** + * Click 'Replace'. Only works in replace mode. + */ + async replace(): Promise { + await this.clickButton('Replace', 'replace'); + } + + + /** + * Click 'Replace All'. Only works in replace mode. + */ + async replaceAll(): Promise { + await this.clickButton('Replace All', 'replace'); + } + + /** + * Close the widget. + */ + async close(): Promise { + const part = TextEditor.versionInfo.version >= '1,80.0' ? 'close' : 'find'; + await this.clickButton('Close', part); + } + + /** + * Get the number of results as an ordered pair of numbers + * @returns pair in form of [current result index, total number of results] + */ + async getResultCount(): Promise<[number, number]> { + const count = await this.findElement(FindWidget.locators.FindWidget.matchCount); + const text = await count.getText(); + + if (text.includes('No results')) { + return [0, 0]; + } + const numbers = text.split(' of '); + return [+numbers[0], +numbers[1]]; + } + + /** + * Toggle the search to match case + * @param toggle true to turn on, false to turn off + */ + async toggleMatchCase(toggle: boolean) { + await this.toggleControl('Match Case', 'find', toggle); + } + + /** + * Toggle the search to match whole words + * @param toggle true to turn on, false to turn off + */ + async toggleMatchWholeWord(toggle: boolean) { + await this.toggleControl('Match Whole Word', 'find', toggle); + } + + /** + * Toggle the search to use regular expressions + * @param toggle true to turn on, false to turn off + */ + async toggleUseRegularExpression(toggle: boolean) { + await this.toggleControl('Use Regular Expression', 'find', toggle); + } + + /** + * Toggle the replace to preserve case + * @param toggle true to turn on, false to turn off + */ + async togglePreserveCase(toggle: boolean) { + await this.toggleControl('Preserve Case', 'replace', toggle); + } + + private async toggleControl(title: string, part: 'find' | 'replace', toggle: boolean) { + let element!: WebElement; + if (part === 'find') { + element = await this.findElement(FindWidget.locators.FindWidget.findPart); + } + if (part === 'replace') { + element = await this.findElement(FindWidget.locators.FindWidget.replacePart); + await this.toggleReplace(true); + } + + const control = await element.findElement(FindWidget.locators.FindWidget.checkbox(title)); + const checked = await control.getAttribute('aria-checked'); + if ((toggle && checked !== 'true') || (!toggle && checked === 'true')) { + await control.click(); + } + } + + private async clickButton(title: string, part: 'find' | 'replace' | 'close') { + let element!: WebElement; + if (part === 'find') { + element = await this.findElement(FindWidget.locators.FindWidget.findPart); + } + if (part === 'replace') { + element = await this.findElement(FindWidget.locators.FindWidget.replacePart); + await this.toggleReplace(true); + } + if (part === 'close') { + element = this; + } + + const btn = await element.findElement(FindWidget.locators.FindWidget.button(title)); + await btn.click(); + await this.getDriver().sleep(100); + } + + private async setText(text: string, composite: WebElement) { + const input = await composite.findElement(FindWidget.locators.FindWidget.input); + await input.clear(); + await input.sendKeys(text); + } + + private async getInputText(composite: WebElement) { + const input = await composite.findElement(FindWidget.locators.FindWidget.content); + return await input.getAttribute('innerHTML'); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/editor/WebView.ts b/vscode-extension-tester/page-objects/src/components/editor/WebView.ts new file mode 100644 index 00000000..67ef67e8 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/editor/WebView.ts @@ -0,0 +1,39 @@ +import { until, WebElement } from "selenium-webdriver"; +import WebviewMixin from "../WebviewMixin"; +import { Editor } from "./Editor"; + +/** + * Page object representing an open editor containing a web view + */ +class WebViewBase extends Editor { + + async getViewToSwitchTo(handle: string): Promise { + const handles = await this.getDriver().getAllWindowHandles(); + for (const handle of handles) { + await this.getDriver().switchTo().window(handle); + + if ((await this.getDriver().getTitle()).includes('Virtual Document')) { + await this.getDriver().switchTo().frame(0); + return; + } + } + await this.getDriver().switchTo().window(handle); + + const reference = await this.findElement(WebViewBase.locators.EditorView.webView); + const containers = await this.getDriver().wait(until.elementsLocated(WebViewBase.locators.WebView.container(await reference.getAttribute(WebViewBase.locators.WebView.attribute))), 5000); + + return await containers[0].getDriver().wait(async () => { + for (let index = 0; index < containers.length; index++) { + const tries = await containers[index].findElements(WebViewBase.locators.WebView.iframe); + if (tries.length > 0) { + return tries[0]; + } + } + return undefined; + }, 5000) as WebElement; + } + +} + +export const WebView = WebviewMixin(WebViewBase); +export type WebView = InstanceType; diff --git a/vscode-extension-tester/page-objects/src/components/menu/ContextMenu.ts b/vscode-extension-tester/page-objects/src/components/menu/ContextMenu.ts new file mode 100644 index 00000000..fa9164a7 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/menu/ContextMenu.ts @@ -0,0 +1,123 @@ +import { Menu, MenuItem } from "../.."; +import { WebElement, Key, until, error } from "selenium-webdriver"; + +/** + * Object representing a context menu + */ +export class ContextMenu extends Menu { + constructor(containingElement: WebElement) { + super(ContextMenu.locators.ContextMenu.constructor, containingElement); + } + + /** + * Get context menu item by name + * @param name name of the item to search by + * @returns Promise resolving to ContextMenuItem object + */ + override async getItem(name: string): Promise { + try { + const items = await this.getItems(); + for (const item of items) { + if (await item.getLabel() === name) { + return item; + } + } + } catch (err) { + return undefined; + } + return Promise.resolve(undefined); + } + + /** + * Get all context menu items + * @returns Promise resolving to array of ContextMenuItem objects + */ + async getItems(): Promise { + const items: ContextMenuItem[] = []; + const elements = await this.findElements(ContextMenu.locators.ContextMenu.itemElement); + + for (const element of elements) { + const klass = await element.getAttribute('class'); + if (klass.indexOf('disabled') < 0) { + items.push(await new ContextMenuItem(element, this).wait()); + } + } + return items; + } + + /** + * Close the context menu + * @returns Promise resolving when the menu is closed + */ + async close(): Promise { + let actions = this.getDriver().actions(); + await actions.clear(); + await this.getDriver().actions().sendKeys(Key.ESCAPE).perform(); + try { + await this.getDriver().wait(until.elementIsNotVisible(this)); + } catch (err) { + if (!(err instanceof error.StaleElementReferenceError)) { + throw err; + } + } + } + + /** + * Wait for the menu to appear and load all its items + */ + override async wait(timeout: number = 5000): Promise { + await this.getDriver().wait(until.elementIsVisible(this), timeout); + let items = (await this.getItems()).length; + try { + await this.getDriver().wait(async () => { + const temp = (await this.getItems()).length; + if (temp === items) { + return true; + } else { + items = temp; + return false; + } + }, 1000); + } catch (err) { + if (err instanceof error.TimeoutError) { + // ignore timeout + } else { + throw err; + } + } + return this; + } +} + +/** + * Object representing an item of a context menu + */ +export class ContextMenuItem extends MenuItem { + + constructor(item: WebElement, parent: Menu) { + super(item, parent); + this.parent = parent; + } + + override async select(): Promise { + await this.click(); + await new Promise(res => setTimeout(res, 500)); + if (await this.isNesting()) { + return await new ContextMenu(this).wait(); + } + return undefined; + } + + override async getLabel(): Promise { + const labelItem = await this.findElement(ContextMenu.locators.ContextMenu.itemLabel); + return await labelItem.getAttribute(ContextMenu.locators.ContextMenu.itemText); + } + + private async isNesting(): Promise { + try { + return await this.findElement(ContextMenu.locators.ContextMenu.itemNesting).isDisplayed(); + } catch (err) { + return false; + } + } +} diff --git a/vscode-extension-tester/page-objects/src/components/menu/MacTitleBar.ts b/vscode-extension-tester/page-objects/src/components/menu/MacTitleBar.ts new file mode 100644 index 00000000..48d16d19 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/menu/MacTitleBar.ts @@ -0,0 +1,38 @@ +import { execSync } from "child_process"; + +/** + * Handler object for macOS based title bar + */ +export class MacTitleBar { + + /** + * Select an item from the mac menu bar by its path, + * does not actually visibly open the menus. + * + * @param items varargs path to the given menu item + * each argument serves as a part of the path in order, + * + * e.g. ('File', 'Save') will select the 'Save' item from + * the 'File' submenu + */ + static select(...items: string[]): void { + let menuCounter = 0; + const commands = [ + `tell application "System Events"`, + `tell process "Code"`, + `tell menu bar item "${items[0]}" of menu bar 1` + ]; + for (let i = 1; i < items.length - 1; i++) { + commands.push(`tell menu item "${items[i]}" of menu 1`); + ++menuCounter; + } + if (items.length > 1) { + commands.push(`click menu item "${items[items.length - 1]}" of menu 1`); + } + for (let i = 0; i < menuCounter + 3; i++) { + commands.push(`end tell`); + } + const command = `osascript -e '${commands.join('\n')}'`; + execSync(command); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/menu/Menu.ts b/vscode-extension-tester/page-objects/src/components/menu/Menu.ts new file mode 100644 index 00000000..083a2f01 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/menu/Menu.ts @@ -0,0 +1,62 @@ +import { AbstractElement } from "../AbstractElement"; +import { MenuItem } from "./MenuItem"; + +/** + * Abstract element representing a menu + */ +export abstract class Menu extends AbstractElement { + + /** + * Find whether the menu has an item of a given name + * @param name name of the item to search for + * @returns true if menu has an item with the given name, false otherwise + */ + async hasItem(name: string): Promise { + const item = await this.getItem(name); + return !!item && (item).isDisplayed(); + } + + /** + * Return a menu item of a given name, undefined if not found + * @param name name of the item to search for + */ + abstract getItem(name: string): Promise; + + /** + * Get all items of a menu + * @returns array of MenuItem object representing the menu items + */ + abstract getItems(): Promise; + + /** + * Recursively select an item with a given path. + * + * E.g. calling select('File', 'Preferences', 'Settings') will + * open the 'File' -> 'Preferences' submenus and then click on 'Settings'. + * + * Selection happens in order of the arguments, if one of the items in the middle + * of the path has no children, the consequent path arguments will be ignored. + * + * + * @param path path to the item to select, represented by a sequence of strings + * @returns void if the last clicked item is a leaf, Menu item representing + * its submenu otherwise + */ + async select(...path: string[]): Promise { + let parent: Menu = this; + for (const label of path) { + const item = await parent.getItem(label); + if (!item) return parent; + await Menu.driver.wait(async function () { + return await item.isDisplayed() && await item.isEnabled(); + }); + const submenu = await item.select(); + if (submenu) { + parent = submenu; + } else { + return; + } + } + return parent; + } +} diff --git a/vscode-extension-tester/page-objects/src/components/menu/MenuItem.ts b/vscode-extension-tester/page-objects/src/components/menu/MenuItem.ts new file mode 100644 index 00000000..0ea21131 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/menu/MenuItem.ts @@ -0,0 +1,37 @@ +import { AbstractElement } from "../AbstractElement"; +import { Menu } from "./Menu"; + +/** + * Abstract element representing a menu item + */ +export abstract class MenuItem extends AbstractElement { + + protected parent!: Menu; + protected label!: string; + + /** + * Use the given menu item: Opens the submenu if the item has children, + * otherwise simply click the item. + * + * @returns Menu object representing the submenu if the item has children, void otherwise. + */ + async select(): Promise { + await this.click(); + await new Promise(res => setTimeout(res, 500)); + return undefined; + } + + /** + * Return the Menu object representing the menu this item belongs to + */ + getParent(): Menu { + return this.parent; + } + + /** + * Returns the label of the menu item + */ + getLabel(): string | Promise { + return this.label; + } +} diff --git a/vscode-extension-tester/page-objects/src/components/menu/TitleBar.ts b/vscode-extension-tester/page-objects/src/components/menu/TitleBar.ts new file mode 100644 index 00000000..b5daa977 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/menu/TitleBar.ts @@ -0,0 +1,81 @@ +import { Key } from "selenium-webdriver"; +import { WindowControls, ContextMenu } from "../.."; +import { Menu } from "./Menu"; +import { MenuItem } from "./MenuItem"; + +/** + * Page object representing the custom VSCode title bar + */ +export class TitleBar extends Menu { + + constructor() { + super(TitleBar.locators.TitleBar.constructor, TitleBar.locators.Workbench.constructor); + } + + /** + * Get title bar item by name + * @param name name of the item to search by + * @returns Promise resolving to TitleBarItem object + */ + async getItem(name: string): Promise { + try { + await this.findElement(TitleBar.locators.TitleBar.itemConstructor(name)); + return await new TitleBarItem(name, this).wait(); + } catch (err) { + return undefined; + } + } + + /** + * Get all title bar items + * @returns Promise resolving to array of TitleBarItem objects + */ + async getItems(): Promise { + const items: TitleBarItem[] = []; + const elements = await this.findElements(TitleBar.locators.TitleBar.itemElement); + + for (const element of elements) { + if (await element.isDisplayed()) { + items.push(await new TitleBarItem(await element.getAttribute(TitleBar.locators.TitleBar.itemLabel), this).wait()); + } + } + return items; + } + + /** + * Get the window title + * @returns Promise resolving to the window title + */ + async getTitle(): Promise { + return await this.findElement(TitleBar.locators.TitleBar.title).getText(); + } + + /** + * Get a reference to the WindowControls + */ + getWindowControls(): WindowControls { + return new WindowControls(this); + } +} + +/** + * Page object representing an item of the custom VSCode title bar + */ +export class TitleBarItem extends MenuItem { + + constructor(label: string, parent: Menu) { + super(TitleBar.locators.TitleBar.itemConstructor(label), parent); + this.parent = parent; + this.label = label; + } + + override async select(): Promise { + const openMenus = await this.getDriver().findElements(TitleBar.locators.ContextMenu.constructor); + if (openMenus.length > 0 && await openMenus[0].isDisplayed()) { + await this.getDriver().actions().sendKeys(Key.ESCAPE).perform(); + } + await this.click(); + await new Promise(res => setTimeout(res, 500)); + return new ContextMenu(this).wait(); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/menu/WindowControls.ts b/vscode-extension-tester/page-objects/src/components/menu/WindowControls.ts new file mode 100644 index 00000000..e2cf3ef1 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/menu/WindowControls.ts @@ -0,0 +1,58 @@ + import { AbstractElement } from "../AbstractElement"; +import { TitleBar } from "../.."; +import { WebElement } from "selenium-webdriver"; + +/** + * Page object for the windows controls part of the title bar + */ +export class WindowControls extends AbstractElement { + constructor(bar: TitleBar = new TitleBar()) { + super(WindowControls.locators.WindowControls.constructor, bar); + } + + /** + * Use the minimize window button + * @returns Promise resolving when minimize button is pressed + */ + async minimize(): Promise { + const minButton = await this.findElement(WindowControls.locators.WindowControls.minimize); + await minButton.click(); + } + + /** + * Use the maximize window button if the window is not maximized + * @returns Promise resolving when maximize button is pressed + */ + async maximize(): Promise { + let maxButton: WebElement; + try { + maxButton = await this.findElement(WindowControls.locators.WindowControls.maximize); + await maxButton.click(); + } catch (err) { + console.log('Window is already maximized'); + } + } + + /** + * Use the restore window button if the window is maximized + * @returns Promise resolving when restore button is pressed + */ + async restore(): Promise { + let maxButton: WebElement; + try { + maxButton = await this.findElement(WindowControls.locators.WindowControls.restore); + await maxButton.click(); + } catch (err) { + console.log('Window is not maximized'); + } + } + + /** + * Use the window close button. Use at your own risk. + * @returns Promise resolving when close button is pressed + */ + async close(): Promise { + const closeButton = await this.findElement(WindowControls.locators.WindowControls.close); + await closeButton.click(); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/sidebar/SideBarView.ts b/vscode-extension-tester/page-objects/src/components/sidebar/SideBarView.ts new file mode 100644 index 00000000..6c576421 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/sidebar/SideBarView.ts @@ -0,0 +1,27 @@ +import { AbstractElement } from "../AbstractElement"; +import { ViewTitlePart, ViewContent } from "../.."; + +/** + * Page object for the side bar view + */ +export class SideBarView extends AbstractElement { + constructor() { + super(SideBarView.locators.SideBarView.constructor, SideBarView.locators.Workbench.constructor); + } + + /** + * Get the top part of the open view (contains title and possibly some buttons) + * @returns ViewTitlePart object + */ + getTitlePart(): ViewTitlePart { + return new ViewTitlePart(this); + } + + /** + * Get the content part of the open view + * @returns ViewContent object + */ + getContent(): ViewContent { + return new ViewContent(this); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/sidebar/ViewContent.ts b/vscode-extension-tester/page-objects/src/components/sidebar/ViewContent.ts new file mode 100644 index 00000000..e1328909 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/sidebar/ViewContent.ts @@ -0,0 +1,117 @@ +import { CustomTreeSection, DefaultTreeSection, ExtensionsViewSection, SideBarView, ViewSection, ViewSectionConstructor } from "../.."; +import { WebElement, error } from "selenium-webdriver"; +import { AbstractElement } from "../AbstractElement"; + +/** + * Page object representing the view container of a side bar view + */ +export class ViewContent extends AbstractElement { + constructor(view: SideBarView = new SideBarView()) { + super(ViewContent.locators.ViewContent.constructor, view); + } + + /** + * Finds whether a progress bar is active at the top of the view + * @returns Promise resolving to true/false + */ + async hasProgress(): Promise { + const progress = await this.findElement(ViewContent.locators.ViewContent.progress); + const hidden = await progress.getAttribute('aria-hidden'); + if (hidden === 'true') { + return false; + } + return true; + } + + /** + * Retrieves a collapsible view content section by its title. + * Generic parameter allows caller to cast returned section to + * desired type, however it is caller's responsibility to check + * whether type is compatible with desired type. + * @param title Title of the section + * @returns Promise resolving to ViewSection object + */ + getSection(title: string): Promise; + /** + * Retrieves a collapsible view content section by its title. + * Type parameter allows caller to use custom section implementation, + * however it is caller's responsibility to check + * whether type is compatible with desired type. + * @param title Title of the section + * @param type ViewSection constructor to be used + * @returns Promise resolving to object specified by type + */ + getSection(title: string, type: ViewSectionConstructor): Promise; + /** + * Retrieves a collapsible view content section by predicate. + * Generic parameter allows caller to cast returned section to + * desired type, however it is caller's responsibility to check + * whether type is compatible with desired type. + * @param predicate Predicate to be used when searching + * @returns Promise resolving to ViewSection object + */ + getSection(predicate: ((section: ViewSection) => boolean | PromiseLike)): Promise; + /** + * Retrieves a collapsible view content section by predicate. + * Type parameter allows caller to use custom section implementation, + * however it is caller's responsibility to check + * whether type is compatible with desired type. + * @param predicate Predicate to be used when searching + * @param type ViewSection constructor to be used + * @returns Promise resolving to object specified by type + */ + getSection(predicate: ((section: ViewSection) => boolean | PromiseLike), type: ViewSectionConstructor): Promise; + async getSection(titleOrPredicate: string | ((section: ViewSection) => boolean | PromiseLike), type?: ViewSectionConstructor): Promise { + const sections = await this.getSections(); + const predicate = typeof titleOrPredicate === 'string' ? + (async (section: ViewSection) => await section.getTitle() === titleOrPredicate) : titleOrPredicate; + + for (const section of sections) { + if (await predicate(section)) { + if (type !== undefined && !(section instanceof type)) { + return new type(section, this); + } + return section as T; + } + } + if (typeof titleOrPredicate === 'string') { + throw new error.NoSuchElementError(`No section with title '${titleOrPredicate}' found`); + } + else { + throw new error.NoSuchElementError(`No section satisfying predicate found`); + } + } + + /** + * Retrieves all the collapsible view content sections + * @returns Promise resolving to array of ViewSection objects + */ + async getSections(): Promise { + const sections: ViewSection[] = []; + const elements = await this.findElements(ViewContent.locators.ViewContent.section); + for (const element of elements) { + sections.push(await this.createSection(element)); + } + return sections; + } + + private async createSection(panel: WebElement, type?: ViewSectionConstructor): Promise { + if (type !== undefined) { + return new type(panel, this); + } + + let section: ViewSection = new DefaultTreeSection(panel, this); + const types = ViewContent.locators.DefaultTreeSection.type; + const locators = ViewContent.locators; + + if (await types.default(section, locators)) { + return section; + } + else if (await types.marketplace.extension(section, locators)) { + return new ExtensionsViewSection(panel, this); + } + else { + return new CustomTreeSection(panel, this); + } + } +} diff --git a/vscode-extension-tester/page-objects/src/components/sidebar/ViewItem.ts b/vscode-extension-tester/page-objects/src/components/sidebar/ViewItem.ts new file mode 100644 index 00000000..5e934f3d --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/sidebar/ViewItem.ts @@ -0,0 +1,234 @@ +import { ElementWithContexMenu } from "../ElementWithContextMenu"; +import { AbstractElement } from "../AbstractElement"; +import { WebElement, By, error } from "selenium-webdriver"; +import { NullAttributeError } from "../../errors/NullAttributeError"; + +/** + * Arbitrary item in the side bar view + */ +export abstract class ViewItem extends ElementWithContexMenu { + /** + * Select the item in the view. + * Note that selecting the item will toggle its expand state when applicable. + * @returns Promise resolving when the item has been clicked + */ + async select(): Promise { + await this.click(); + } +} + + +/** + * Abstract representation of a row in the tree inside a view content section + */ +export abstract class TreeItem extends ViewItem { + /** + * Retrieves the label of this view item + */ + abstract getLabel(): Promise; + + /** + * Retrieves the tooltip of this TreeItem. + * @returns A promise resolving to the tooltip or undefined if the TreeItem has no tooltip. + */ + async getTooltip(): Promise { + return undefined; + } + + /** + * Retrieves the description of this TreeItem. + * @returns A promise resolving to the tooltip or undefined if the TreeItem has no description. + */ + async getDescription(): Promise { + return undefined; + } + + /** + * Finds if the item has children by actually counting the child items + * Note that this will expand the item if it was collapsed + * @returns Promise resolving to true/false + */ + async hasChildren(): Promise { + const children = await this.getChildren(); + return children && children.length > 0; + } + + /** + * Finds whether the item is expanded. Always returns false if item has no children. + * @returns Promise resolving to true/false + */ + abstract isExpanded(): Promise + + /** + * Find children of an item, will try to expand the item in the process + * @returns Promise resolving to array of TreeItem objects, empty array if item has no children + */ + abstract getChildren(): Promise + + /** + * Finds if the item is expandable/collapsible + * @returns Promise resolving to true/false + */ + abstract isExpandable(): Promise; + + /** + * Expands the current item, if it can be expanded and is collapsed. + */ + async expand(): Promise { + if (await this.isExpandable() && !await this.isExpanded()) { + await (await this.findTwistie()).click(); + } + } + + /** + * Find a child item with the given name + * @returns Promise resolving to TreeItem object if the child item exists, undefined otherwise + */ + async findChildItem(name: string): Promise { + const children = await this.getChildren(); + for (const item of children) { + if (await item.getLabel() === name) { + return item; + } + } + return Promise.resolve(undefined); + } + + /** + * Collapse the item if expanded + */ + async collapse(): Promise { + if (await this.isExpandable() && await this.isExpanded()) { + await (await this.findTwistie()).click(); + } + } + + /** + * Find all action buttons bound to the view item + * + * @returns array of ViewItemAction objects, empty array if item has no + * actions associated + */ + async getActionButtons(): Promise { + await this.getDriver().actions().move({ origin: this }).perform(); + + let container: WebElement; + try { + container = await this.findElement(TreeItem.locators.TreeItem.actions); + } catch (e) { + if (e instanceof error.NoSuchElementError) { + return []; + } + throw e; + } + + const actions: ViewItemAction[] = []; + const items = await container.findElements(TreeItem.locators.TreeItem.actionLabel); + + for (const item of items) { + const label = await item.getAttribute(TreeItem.locators.TreeItem.actionTitle); + + if (label === '' || label === null) { + // unknown, skip the item + continue; + } + + try { + actions.push(new ViewItemAction(ViewItemAction.locators.ViewSection.actionConstructor(label), this)); + } + catch (e) { + // the item was destroyed in meantime + if (e instanceof error.NoSuchElementError) { + continue; + } + + if (e instanceof error.StaleElementReferenceError) { + console.warn('ViewItem has become stale'); + } + + throw e; + } + } + + return actions; + } + + /** + * Find action button for view item by label + * @param label label of the button to search by + * + * @returns ViewItemAction object if such button exists, undefined otherwise + */ + async getActionButton(label: string): Promise { + const actions = await this.getActionButtons(); + + for (const action of actions) { + try { + if ((await action.getLabel()).includes(label)) { + return action; + } + } + catch (e) { + if (e instanceof NullAttributeError || e instanceof error.StaleElementReferenceError) { + continue; + } + throw e; + } + } + + return undefined; + } + + /** + * Find all child elements of a tree item + * @param locator locator of a given type of tree item + */ + protected async getChildItems(locator: By): Promise { + const items: WebElement[] = []; + await this.expand(); + + const rows = await this.enclosingItem.findElements(locator); + const baseIndex = +await this.getAttribute(TreeItem.locators.ViewSection.index); + const baseLevel = +await this.getAttribute(TreeItem.locators.ViewSection.level); + + for (const row of rows) { + const level = +await row.getAttribute(TreeItem.locators.ViewSection.level); + const index = +await row.getAttribute(TreeItem.locators.ViewSection.index); + + if (index <= baseIndex) { continue; } + if (level > baseLevel + 1) { continue; } + if (level <= baseLevel) { break; } + + items.push(row); + } + + return items; + } + + protected async findTwistie(): Promise { + return await this.findElement(TreeItem.locators.TreeItem.twistie); + } +} + +/** + * Action button bound to a view item + */ +export class ViewItemAction extends AbstractElement { + + constructor(actionConstructor: By, viewItem: TreeItem) { + super(actionConstructor, viewItem); + } + + /** + * Get label of the action button + */ + async getLabel(): Promise { + const value = await this.getAttribute(ViewItemAction.locators.ViewSection.buttonLabel); + + if (value === null) { + throw new NullAttributeError(`${this.constructor.name}.getLabel returned null`); + } + + return value; + } +} diff --git a/vscode-extension-tester/page-objects/src/components/sidebar/ViewSection.ts b/vscode-extension-tester/page-objects/src/components/sidebar/ViewSection.ts new file mode 100644 index 00000000..64a4c14a --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/sidebar/ViewSection.ts @@ -0,0 +1,209 @@ +import { By, ChromiumWebDriver, until, WebElement } from "selenium-webdriver"; +import { ContextMenu, ViewContent, ViewItem, waitForAttributeValue, WelcomeContentSection } from "../.."; +import { AbstractElement } from "../AbstractElement"; +import { ElementWithContexMenu } from "../ElementWithContextMenu"; + +export type ViewSectionConstructor = { new(rootElement: WebElement, tree: ViewContent): T ;}; + +/** + * Page object representing a collapsible content section of the side bar view + */ +export abstract class ViewSection extends AbstractElement { + + constructor(panel: WebElement, content: ViewContent) { + super(panel, content); + } + + /** + * Get the title of the section as string + * @returns Promise resolving to section title + */ + async getTitle(): Promise { + const title = await this.findElement(ViewSection.locators.ViewSection.title); + return await title.getAttribute(ViewSection.locators.ViewSection.titleText); + } + + /** + * Expand the section if collapsed + * @returns Promise resolving when the section is expanded + */ + async expand(): Promise { + if (await this.isHeaderHidden()) { + return; + } + if (!await this.isExpanded()) { + const panel = await this.findElement(ViewSection.locators.ViewSection.header); + await panel.click(); + await this.getDriver().wait(waitForAttributeValue(panel, ViewSection.locators.ViewSection.headerExpanded, 'true'), 1000); + } + } + + /** + * Collapse the section if expanded + * @returns Promise resolving when the section is collapsed + */ + async collapse(): Promise { + if (await this.isHeaderHidden()) { + return; + } + if (await this.isExpanded()) { + const panel = await this.findElement(ViewSection.locators.ViewSection.header); + await panel.click(); + await this.getDriver().wait(waitForAttributeValue(panel, ViewSection.locators.ViewSection.headerExpanded, 'false'), 1000); + } + } + + /** + * Finds whether the section is expanded + * @returns Promise resolving to true/false + */ + async isExpanded(): Promise { + const header = await this.findElement(ViewSection.locators.ViewSection.header); + const expanded = await header.getAttribute(ViewSection.locators.ViewSection.headerExpanded); + return expanded === 'true'; + } + + /** + * Finds [Welcome Content](https://code.visualstudio.com/api/extension-guides/tree-view#welcome-content) + * present in this ViewSection and returns it. If none is found, then `undefined` is returned + * + */ + public async findWelcomeContent(): Promise { + try { + const res = await this.findElement(ViewSection.locators.ViewSection.welcomeContent); + if (!await res.isDisplayed()) { + return undefined; + } + return new WelcomeContentSection(res, this); + } catch (_err) { + return undefined; + } + } + + /** + * Retrieve all items currently visible in the view section. + * Note that any item currently beyond the visible list, i.e. not scrolled to, will not be retrieved. + * @returns Promise resolving to array of ViewItem objects + */ + abstract getVisibleItems(): Promise + + /** + * Find an item in this view section by label. Does not perform recursive search through the whole tree. + * Does however scroll through all the expanded content. Will find items beyond the current scroll range. + * @param label Label of the item to search for. + * @param maxLevel Limit how deep the algorithm should look into any expanded items, default unlimited (0) + * @returns Promise resolving to ViewItem object is such item exists, undefined otherwise + */ + abstract findItem(label: string, maxLevel?: number): Promise + + /** + * Open an item with a given path represented by a sequence of labels + * + * e.g to open 'file' inside 'folder', call + * openItem('folder', 'file') + * + * The first item is only searched for directly within the root element (depth 1). + * The label sequence is handled in order. If a leaf item (a file for example) is found in the middle + * of the sequence, the rest is ignored. + * + * If the item structure is flat, use the item's title to search by. + * + * @param path Sequence of labels that make up the path to a given item. + * @returns Promise resolving to array of ViewItem objects representing the last item's children. + * If the last item is a leaf, empty array is returned. + */ + abstract openItem(...path: string[]): Promise + + /** + * Retrieve the action buttons on the section's header + * @returns Promise resolving to array of ViewPanelAction objects + */ + async getActions(): Promise { + const actions: ViewPanelAction[] = []; + + if (!await this.isHeaderHidden()) { + const header = await this.findElement(ViewSection.locators.ViewSection.header); + const act = await header.findElement(ViewSection.locators.ViewSection.actions); + const elements = await act.findElements(ViewSection.locators.ViewSection.button); + + for (const element of elements) { + actions.push(await new ViewPanelAction(element, this).wait()); + } + } + return actions; + } + + /** + * Retrieve an action button on the sections's header by its label + * @param label label/title of the button + * @returns ViewPanelAction object if found, undefined otherwise + */ + async getAction(label: string): Promise { + const actions = await this.getActions(); + for (const action of actions) { + if (await action.getLabel() === label) { + return action; + } + } + return Promise.resolve(undefined); + } + + /** + * Click on the More Actions... item if it exists + * + * @returns ContextMenu page object if the action succeeds, undefined otherwise + */ + async moreActions(): Promise { + let more = await this.getAction('More Actions...'); + if (!more) { + return undefined; + } + const section = this; + const btn = new class extends ElementWithContexMenu { + override async openContextMenu() { + await this.click(); + const shadowRootHost = await section.findElements(By.className('shadow-root-host')); + if (shadowRootHost.length > 0) { + let shadowRoot; + const webdriverCapabilities = await (this.getDriver() as ChromiumWebDriver).getCapabilities(); + const chromiumVersion = webdriverCapabilities.getBrowserVersion(); + if (chromiumVersion && parseInt(chromiumVersion.split('.')[0]) >= 96) { + shadowRoot = await shadowRootHost[0].getShadowRoot(); + return new ContextMenu(await shadowRoot.findElement(By.className('monaco-menu-container'))).wait(); + } else { + shadowRoot = await this.getDriver().executeScript('return arguments[0].shadowRoot', shadowRootHost[0]) as WebElement; + return new ContextMenu(shadowRoot).wait(); + } + } + return await super.openContextMenu(); + } + }(more, this); + return await btn.openContextMenu(); + } + + private async isHeaderHidden(): Promise { + const header = await this.findElement(ViewSection.locators.ViewSection.header); + return (await header.getAttribute('class')).indexOf('hidden') > -1; + } +} + +/** + * Action button on the header of a view section + */ +export class ViewPanelAction extends AbstractElement { + constructor(element: WebElement, viewPart: ViewSection) { + super(element, viewPart); + } + + /** + * Get label of the action button + */ + async getLabel(): Promise { + return await this.getAttribute(ViewSection.locators.ViewSection.buttonLabel); + } + + override async wait(timeout: number = 1000): Promise { + await this.getDriver().wait(until.elementIsEnabled(this), timeout); + return this; + } +} diff --git a/vscode-extension-tester/page-objects/src/components/sidebar/ViewTitlePart.ts b/vscode-extension-tester/page-objects/src/components/sidebar/ViewTitlePart.ts new file mode 100644 index 00000000..e5ff3c4f --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/sidebar/ViewTitlePart.ts @@ -0,0 +1,60 @@ +import { ElementWithContexMenu } from "../ElementWithContextMenu"; +import { AbstractElement } from "../AbstractElement"; +import { By, SideBarView } from "../.."; + +/** + * Page object representing the top (title) part of a side bar view + */ +export class ViewTitlePart extends ElementWithContexMenu { + constructor(view: SideBarView = new SideBarView()) { + super(ViewTitlePart.locators.ViewTitlePart.constructor, view); + } + + /** + * Returns the displayed title of the view + * @returns Promise resolving to displayed title + */ + async getTitle(): Promise { + return await this.findElement(ViewTitlePart.locators.ViewTitlePart.title).getText(); + } + + /** + * Finds action buttons inside the view title part + * @returns Promise resolving to array of TitleActionButton objects + */ + async getActions(): Promise { + const actions: TitleActionButton[] = []; + const elements = await this.findElements(ViewTitlePart.locators.ViewTitlePart.action); + for (const element of elements) { + const title = await element.getAttribute(ViewTitlePart.locators.ViewTitlePart.actionLabel); + actions.push(await new TitleActionButton(TitleActionButton.locators.ViewTitlePart.actionConstructor(title), this).wait()); + } + return actions; + } + + /** + * Finds an action button by title + * @param title title of the button to search for + * @returns Promise resolving to TitleActionButton object + */ + async getAction(title: string): Promise { + return new TitleActionButton(TitleActionButton.locators.ViewTitlePart.actionConstructor(title), this); + } +} + +/** + * Page object representing a button inside the view title part + */ +export class TitleActionButton extends AbstractElement { + + constructor(actionConstructor: By, viewTitle: ViewTitlePart) { + super(actionConstructor, viewTitle); + } + + /** + * Get title of the button + */ + async getTitle(): Promise { + return await this.getAttribute(TitleActionButton.locators.ViewTitlePart.actionLabel); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/sidebar/WelcomeContent.ts b/vscode-extension-tester/page-objects/src/components/sidebar/WelcomeContent.ts new file mode 100644 index 00000000..e85d2258 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/sidebar/WelcomeContent.ts @@ -0,0 +1,77 @@ +import { WebElement } from "selenium-webdriver"; +import { AbstractElement } from "../AbstractElement"; +import { ViewSection } from "../.."; + +/** + * A button that appears in the welcome content and can be clicked to execute a command. + * + * To execute the command bound to this button simply run: `await button.click();`. + */ +export class WelcomeContentButton extends AbstractElement { + /** + * @param panel The panel containing the button in the welcome section + * @param welcomeSection The enclosing welcome section + */ + constructor(panel: WebElement, welcomeSection: WelcomeContentSection) { + super(panel, welcomeSection); + } + + /** Return the title displayed on this button */ + public async getTitle(): Promise { + return await this.getText(); + } +} + +/** + * A section in an empty custom view, see: + * https://code.visualstudio.com/api/extension-guides/tree-view#welcome-content + * + * The welcome section contains two types of elements: text entries and buttons that can be bound to commands. + * The text sections can be accessed via [[getTextSections]], the buttons on the + * other hand via [[getButtons]]. + * This however looses the information of the order of the buttons and lines + * with respect to each other. This can be remedied by using [[getContents]], + * which returns both in the order that they are found (at the expense, that you + * now must use typechecks to find out what you got). + */ +export class WelcomeContentSection extends AbstractElement { + /** + * @param panel The panel containing the welcome content. + * @param parent The webelement in which the welcome content is embedded. + */ + constructor(panel: WebElement, parent: ViewSection) { + super(panel, parent); + } + + /** + * Combination of [[getButtons]] and [[getTextSections]]: returns all entries in the welcome view in the order that they appear. + */ + public async getContents(): Promise<(WelcomeContentButton|string)[]> { + const elements = await this.findElements(WelcomeContentSection.locators.WelcomeContent.buttonOrText); + return Promise.all(elements.map(async (e) => { + const tagName = await e.getTagName(); + if (tagName === "p") { + return await e.getText(); + } else { + return new WelcomeContentButton(e, this); + } + })); + } + + /** Finds all buttons in the welcome content */ + public async getButtons(): Promise { + return ( + await this.findElements(WelcomeContentSection.locators.WelcomeContent.button) + ).map((elem) => new WelcomeContentButton(elem, this)); + } + + /** + * Finds all text entries in the welcome content and returns each line as an + * element in an array. + */ + public async getTextSections(): Promise { + return await Promise.all( + (await this.findElements(WelcomeContentSection.locators.WelcomeContent.text)).map(async(elem) => await elem.getText()) + ); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/sidebar/extensions/ExtensionsViewItem.ts b/vscode-extension-tester/page-objects/src/components/sidebar/extensions/ExtensionsViewItem.ts new file mode 100644 index 00000000..a5ef5c4b --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/sidebar/extensions/ExtensionsViewItem.ts @@ -0,0 +1,101 @@ +import { ViewItem } from "../ViewItem"; +import { until, WebElement } from "selenium-webdriver"; +import { ContextMenu } from "../../menu/ContextMenu"; +import { ExtensionsViewSection } from "./ExtensionsViewSection"; + +/** + * Page object representing an extension in the extensions view + */ +export class ExtensionsViewItem extends ViewItem { + + constructor(extensionElement: WebElement, section: ExtensionsViewSection) { + super(extensionElement, section); + } + + /** + * Get title of the extension + */ + async getTitle(): Promise { + const title = await this.findElement(ExtensionsViewItem.locators.ExtensionsViewSection.itemTitle); + return await title.getText(); + } + + /** + * Get version of the extension + * @returns Promise resolving to version string + */ + async getVersion(): Promise { + const version = await this.findElements(ExtensionsViewItem.locators.ExtensionsViewItem.version); + if (version.length > 0) { + return await version[0].getText(); + } + const label = await this.getAttribute('aria-label'); + const ver = label.split(',')[1].trim(); + + return ver; + } + + /** + * Get the author of the extension + * @returns Promise resolving to displayed author + */ + async getAuthor(): Promise { + const author = await this.findElement(ExtensionsViewItem.locators.ExtensionsViewItem.author); + return await author.getText(); + } + + /** + * Get the description of the extension + * @returns Promise resolving to description + */ + async getDescription(): Promise { + const description = await this.findElement(ExtensionsViewItem.locators.ExtensionsViewItem.description); + return await description.getText(); + } + + /** + * Find if the extension is installed + * @returns Promise resolving to true/false + */ + async isInstalled(): Promise { + const button = await this.findElement(ExtensionsViewItem.locators.ExtensionsViewItem.install); + if ((await button.getAttribute('class')).indexOf('disabled') > -1) { + return true; + } + return false; + } + + /** + * Open the management context menu if the extension is installed + * @returns Promise resolving to ContextMenu object + */ + async manage(): Promise { + await this.getDriver().wait(until.elementLocated(ExtensionsViewItem.locators.ExtensionsViewItem.manage), 1000); + const button = await this.enclosingItem.findElement(ExtensionsViewItem.locators.ExtensionsViewItem.manage); + if ((await button.getAttribute('class')).indexOf('disabled') > -1) { + throw new Error(`Extension '${await this.getTitle()}' is not installed`); + } + return await this.openContextMenu(); + } + + /** + * Install the extension if not installed already. + * + * Will wait for the extension to finish installing. To skip the wait, set timeout to 0. + * + * @param timeout timeout to wait for the installation in milliseconds, default unlimited, set to 0 to skip waiting + * @returns Promise resolving when the installation finishes or is skipped + */ + async install(timeout: number = 300000): Promise { + if (await this.isInstalled()) { + return; + } + const button = await this.findElement(ExtensionsViewItem.locators.ExtensionsViewItem.install); + const manage = await this.findElement(ExtensionsViewItem.locators.ExtensionsViewItem.manage); + await button.click(); + + if (timeout > 0) { + await this.getDriver().wait(until.elementIsVisible(manage), timeout); + } + } +} diff --git a/vscode-extension-tester/page-objects/src/components/sidebar/extensions/ExtensionsViewSection.ts b/vscode-extension-tester/page-objects/src/components/sidebar/extensions/ExtensionsViewSection.ts new file mode 100644 index 00000000..35d76f69 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/sidebar/extensions/ExtensionsViewSection.ts @@ -0,0 +1,125 @@ +import { ViewSection } from "../ViewSection"; +import { ExtensionsViewItem } from "./ExtensionsViewItem"; +import { until, Key } from "selenium-webdriver"; +import { ViewContent } from "../ViewContent"; + +/** + * Categories of extensions to search for + */ +enum ExtensionCategory { + Installed = '@installed', + Enabled = '@enabled', + Disabled = '@disabled', + Outdated = '@outdated', + Recommended = '@recommended' +} + +/** + * View section containing extensions + */ +export class ExtensionsViewSection extends ViewSection { + + async getVisibleItems(): Promise { + const extensionTable = await this.findElement(ExtensionsViewSection.locators.ExtensionsViewSection.items); + const extensionRows = await extensionTable.findElements(ExtensionsViewSection.locators.ExtensionsViewSection.itemRow); + + return await Promise.all(extensionRows.map(async row => new ExtensionsViewItem(row, this).wait())); + } + + /** + * Search for an extension by title. This utilizes the search bar + * in the Extensions view, which switches the perspective to the + * section representing the chosen category and temporarily hides all other sections. + * If you wish to continue working with the initial view section + * (i.e. Enabled), use the clearSearch method to reset it back to default + * + * @param title title to search for in '@category name' format, + * e.g '@installed extension'. If no @category is present, marketplace will be searched + * + * @returns Promise resolving to ExtensionsViewItem if such item exists, undefined otherwise + */ + async findItem(title: string): Promise { + await this.clearSearch(); + const progress = await this.enclosingItem.findElement(ExtensionsViewSection.locators.ViewContent.progress); + const searchField = await this.enclosingItem.findElement(ExtensionsViewSection.locators.ExtensionsViewSection.searchBox); + await searchField.sendKeys(title); + try { + await this.getDriver().wait(until.elementIsVisible(progress), 1000); + } catch (err) { + if ((err as Error).name !== "TimeoutError"){ + throw err; + } + } + await this.getDriver().wait(until.elementIsNotVisible(progress)); + + const parent = this.enclosingItem as ViewContent; + let sectionTitle = this.getSectionForCategory(title); + + const section = await parent.getSection(sectionTitle) as ExtensionsViewSection; + + const titleParts = title.split(' '); + if (titleParts[0].startsWith('@')) { + title = titleParts.slice(1).join(' '); + } + + const extensions = await section.getVisibleItems(); + + for (const extension of extensions) { + if (await extension.getTitle() === title) { + return extension; + } + } + + return undefined; + } + + /** + * Clears the search bar on top of the view + * @returns Promise resolving when the search box is cleared + */ + async clearSearch(): Promise { + const progress = await this.enclosingItem.findElement(ExtensionsViewSection.locators.ViewContent.progress); + const searchField = await this.enclosingItem.findElement(ExtensionsViewSection.locators.ExtensionsViewSection.searchBox); + const textField = await this.enclosingItem.findElement(ExtensionsViewSection.locators.ExtensionsViewSection.textContainer); + + try { + await textField.findElement(ExtensionsViewSection.locators.ExtensionsViewSection.textField); + await searchField.sendKeys(Key.chord(ExtensionsViewItem.ctlKey, 'a'), Key.BACK_SPACE); + await this.getDriver().wait(until.elementIsVisible(progress)); + await this.getDriver().wait(until.elementIsNotVisible(progress)); + } catch (err) { + // do nothing, the text field is empty + } + } + + /** + * Find and open an extension item + * @param title title of the extension + * @returns Promise resolving when the item is clicked + */ + async openItem(title: string): Promise { + const item = await this.findItem(title); + if (item) { + await item.click(); + } + return []; + } + + private getSectionForCategory(title: string): string { + const category = title.split(' ')[0].toLowerCase(); + switch(category) { + case ExtensionCategory.Disabled: + return 'Disabled'; + case ExtensionCategory.Enabled: + return 'Enabled'; + case ExtensionCategory.Installed: + return 'Installed'; + case ExtensionCategory.Outdated: + return 'Outdated'; + case ExtensionCategory.Recommended: + return 'Other Recommendations'; + default: + return 'Marketplace'; + } + } +} diff --git a/vscode-extension-tester/page-objects/src/components/sidebar/scm/NewScmView.ts b/vscode-extension-tester/page-objects/src/components/sidebar/scm/NewScmView.ts new file mode 100644 index 00000000..ffd616e9 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/sidebar/scm/NewScmView.ts @@ -0,0 +1,174 @@ +import { ScmView, ScmProvider, MoreAction, ScmChange } from "./ScmView"; +import { WebElement, Key } from "selenium-webdriver"; +import { ContextMenu } from "../../menu/ContextMenu"; +import { ElementWithContexMenu } from "../../ElementWithContextMenu"; +import { TitleActionButton } from "../ViewTitlePart"; + +/** + * New SCM view for code 1.47 onwards + */ +export class NewScmView extends ScmView { + + override async getProviders(): Promise { + const inputs = await this.findElements(NewScmView.locators.ScmView.inputField); + if (inputs.length < 1) { + return []; + } + + const providers = await this.findElements(NewScmView.locators.ScmView.multiScmProvider); + if (inputs.length === 1 && providers.length < 1) { + const element = await this.findElement(NewScmView.locators.ScmView.singleScmProvider); + return [await new SingleScmProvider(element, this).wait()]; + } + + const elements = await this.findElements(NewScmView.locators.ScmView.multiProviderItem); + return await Promise.all(elements.map(async element => new MultiScmProvider(element, this).wait())); + } +} + +/** + * Implementation for a single SCM provider + */ +export class SingleScmProvider extends ScmProvider { + + /** + * There is no title available for a single provider + */ + override async getTitle(): Promise { + return ''; + } + + /** + * No title available for single provider + */ + override async getType(): Promise { + return ''; + } + + override async takeAction(title: string): Promise { + const view = this.enclosingItem as NewScmView; + const titlePart = view.getTitlePart(); + const elements = await titlePart.findElements(ScmView.locators.ScmView.action); + const buttons: TitleActionButton[] = []; + for (const element of elements) { + const title = await element.getAttribute(ScmView.locators.ScmView.actionLabel); + buttons.push(await new TitleActionButton(ScmView.locators.ScmView.actionConstructor(title), titlePart).wait()); + } + const names = await Promise.all(buttons.map(async button => button.getTitle())); + const index = names.findIndex(name => name === title) + if (index > -1) { + await buttons[index].click(); + return true; + } + return false; + } + + override async openMoreActions(): Promise { + const view = this.enclosingItem as NewScmView; + return await new MoreAction(view).openContextMenu(); + } + + override async getChanges(staged: boolean = false): Promise { + const count = await this.getChangeCount(staged); + const elements: WebElement[] = []; + + if (count > 0) { + const locator = staged ? ScmProvider.locators.ScmView.stagedChanges : ScmProvider.locators.ScmView.changes; + const header = await this.findElement(locator); + const startIndex = +await header.getAttribute('data-index'); + const depth = +await header.getAttribute('aria-level') + 1; + + const items = await this.findElements(NewScmView.locators.ScmView.itemLevel(depth)); + for (const item of items) { + const index = +await item.getAttribute('data-index'); + if (index > startIndex && index <= startIndex + count) { + elements.push(item); + } + } + } + return Promise.all(elements.map(async element => new ScmChange(element, this).wait())); + } +} + +/** + * Implementation of an SCM provider when multiple providers are available + */ +export class MultiScmProvider extends ScmProvider { + + override async takeAction(title: string): Promise { + const actions = await this.findElements(ScmProvider.locators.ScmView.action); + const names = await Promise.all(actions.map(async action => await action.getAttribute('title'))); + const index = names.findIndex(item => item === title); + + if (index > -1) { + await actions[index].click(); + return true; + } + return false; + } + + override async openMoreActions(): Promise { + return await new MultiMoreAction(this).openContextMenu(); + } + + override async commitChanges(message: string): Promise { + const index = +await this.getAttribute('data-index') + 1; + const input = await this.enclosingItem.findElement(NewScmView.locators.ScmView.itemIndex(index)); + await input.clear(); + await input.sendKeys(message); + await input.sendKeys(Key.chord(ScmProvider.ctlKey, Key.ENTER)); + } + + override async getChanges(staged: boolean = false): Promise { + const count = await this.getChangeCount(staged); + const elements: WebElement[] = []; + + if (count > 0) { + const index = +await this.getAttribute('data-index'); + const locator = staged ? ScmProvider.locators.ScmView.stagedChanges : ScmProvider.locators.ScmView.changes; + const headers = await this.enclosingItem.findElements(locator); + let header!: WebElement; + + for (const item of headers) { + if (+await item.getAttribute('data-index') > index) { + header = item; + } + } + if (!header) { + return [] + } + + const startIndex = +await header.getAttribute('data-index'); + const depth = +await header.getAttribute('aria-level') + 1; + + const items = await this.enclosingItem.findElements(NewScmView.locators.ScmView.itemLevel(depth)); + for (const item of items) { + const index = +await item.getAttribute('data-index'); + if (index > startIndex && index <= startIndex + count) { + elements.push(item); + } + } + } + return await Promise.all(elements.map(async element => new ScmChange(element, this).wait())); + } + + override async getChangeCount(staged: boolean = false): Promise { + const locator = staged ? ScmProvider.locators.ScmView.stagedChanges : ScmProvider.locators.ScmView.changes; + const rows = await this.enclosingItem.findElements(locator); + const index = +await this.getAttribute('data-index'); + + for (const row of rows) { + if (+await row.getAttribute('data-index') > index) { + const count = await rows[0].findElement(ScmChange.locators.ScmView.changeCount); + return +await count.getText(); + } + } + return 0; + } +} + +class MultiMoreAction extends ElementWithContexMenu { + constructor(scm: ScmProvider) { + super(MoreAction.locators.ScmView.multiMore, scm); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/sidebar/scm/ScmView.ts b/vscode-extension-tester/page-objects/src/components/sidebar/scm/ScmView.ts new file mode 100644 index 00000000..5dd518a8 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/sidebar/scm/ScmView.ts @@ -0,0 +1,291 @@ +import { SideBarView } from "../SideBarView"; +import { WebElement, Key, By, ChromiumWebDriver } from "selenium-webdriver"; +import { AbstractElement } from "../../AbstractElement"; +import { ContextMenu } from "../../.."; +import { ElementWithContexMenu } from "../../ElementWithContextMenu"; + +/** + * Page object representing the Source Control view + */ +export class ScmView extends SideBarView { + + /** + * Get SCM provider (repository) by title + * @param title name of the repository + * @returns promise resolving to ScmProvider object + */ + async getProvider(title?: string): Promise { + const providers = await this.getProviders(); + if (!title || providers.length === 1) { + return providers[0]; + } + const names = await Promise.all(providers.map(async item => await item.getTitle())); + const index = names.findIndex(name => name === title) + + return index > -1 ? providers[index] : undefined; + } + + /** + * Get all SCM providers + * @returns promise resolving to ScmProvider array + */ + async getProviders(): Promise { + const headers = await this.findElements(ScmView.locators.ScmView.providerHeader); + const sections = await Promise.all(headers.map(async header => await header.findElement(ScmView.locators.ScmView.providerRelative))); + return await Promise.all(sections.map(async section => new ScmProvider(section, this))); + } + + /** + * Initialize repository in the current folder if no SCM provider is found + * @returns true if the action was completed succesfully, false if a provider already exists + */ + async initializeRepository(): Promise { + const buttons = await this.findElements(ScmView.locators.ScmView.initButton); + if (buttons.length > 0) { + await buttons[0].click(); + return true; + } + return false; + } +} + +/** + * Page object representing a repository in the source control view + * Maps roughly to a view section of the source control view + */ +export class ScmProvider extends AbstractElement { + constructor(element: WebElement, view: ScmView) { + super(element, view); + } + + /** + * Get title of the scm provider + */ + async getTitle(): Promise { + return await this.findElement(ScmProvider.locators.ScmView.providerTitle).getAttribute('innerHTML'); + } + + /** + * Get type of the scm provider (e.g. Git) + */ + async getType(): Promise { + return await this.findElement(ScmProvider.locators.ScmView.providerType).getAttribute('innerHTML'); + } + + /** + * Find an action button for the SCM provider by title and click it. (e.g 'Commit') + * @param title Title of the action button to click + * @returns true if the given action could be performed, false if the button doesn't exist + */ + async takeAction(title: string): Promise { + const header = await this.findElement(ScmProvider.locators.ScmView.providerHeader); + let actions: WebElement[] = []; + if ((await header.getAttribute('class')).indexOf('hidden') > -1) { + const view = this.enclosingItem as ScmView; + actions = await view.getTitlePart().getActions(); + } else { + await this.getDriver().actions().move({origin: this}).perform(); + actions = await header.findElements(ScmProvider.locators.ScmView.action); + } + const names = await Promise.all(actions.map(async action => await action.getAttribute('title'))); + const index = names.findIndex(item => item === title); + + if (index > -1) { + await actions[index].click(); + return true; + } + return false; + } + + /** + * Open a context menu using the 'More Actions...' button + * @returns Promise resolving to a ContextMenu object + */ + async openMoreActions(): Promise { + const header = await this.findElement(ScmProvider.locators.ScmView.providerHeader); + if ((await header.getAttribute('class')).indexOf('hidden') > -1) { + return await new MoreAction(this.enclosingItem as ScmView).openContextMenu(); + } else { + await this.getDriver().actions().move({origin: this}).perform(); + return await new MoreAction(this).openContextMenu(); + } + } + + /** + * Fill in the message field and send ctrl/cmd + enter to commit the changes + * @param message the commit message to use + * @returns promise resolving once the keypresses are sent + */ + async commitChanges(message: string): Promise { + const input = await this.findElement(ScmProvider.locators.ScmView.inputField); + await input.clear(); + await input.sendKeys(message); + await input.sendKeys(Key.chord(ScmProvider.ctlKey, Key.ENTER)); + } + + /** + * Get page objects for all tree items representing individual changes + * @param staged when true, finds staged changes; otherwise finds unstaged changes + * @returns promise resolving to ScmChange object array + */ + async getChanges(staged: boolean = false): Promise { + const changes = await this.getChangeCount(staged); + const label = staged ? 'STAGED CHANGES' : 'CHANGES'; + + let elements: WebElement[] = []; + if (changes > 0) { + let i = -1; + elements = await this.findElements(ScmProvider.locators.ScmView.changeItem); + for (const [index, item] of elements.entries()) { + const name = await item.findElement(ScmProvider.locators.ScmView.changeName); + if (await name.getText() === label) { + i = index + 1; + break; + } + } + if (i < 0) { + return []; + } + elements = elements.slice(i, i + changes); + } + return await Promise.all(elements.map(async element => new ScmChange(element, this).wait())); + } + + /** + * Get the number of changes for a given section + * @param staged when true, counts the staged changes, unstaged otherwise + * @returns promise resolving to number of changes in the given subsection + */ + async getChangeCount(staged: boolean = false): Promise { + const locator = staged ? ScmProvider.locators.ScmView.stagedChanges : ScmProvider.locators.ScmView.changes; + const rows = await this.findElements(locator); + + if (rows.length < 1) { + return 0; + } + const count = await rows[0].findElement(ScmChange.locators.ScmView.changeCount); + return +await count.getText(); + } +} + +/** + * Page object representing a SCM change tree item + */ +export class ScmChange extends ElementWithContexMenu { + + constructor(row: WebElement, provider: ScmProvider) { + super(row, provider); + } + + /** + * Get label as a string + */ + async getLabel(): Promise { + const label = await this.findElement(ScmChange.locators.ScmView.changeLabel); + return await label.getText(); + } + + /** + * Get description as a string + */ + async getDescription(): Promise { + const desc = await this.findElements(ScmChange.locators.ScmView.changeDesc); + if (desc.length < 1) { + return ''; + } + return await desc[0].getText(); + } + + /** + * Get the status string (e.g. 'Modified') + */ + async getStatus(): Promise { + const res = await this.findElement(ScmChange.locators.ScmView.resource); + const status = await res.getAttribute('data-tooltip'); + + if (status && status.length > 0) { + return status; + } + return 'folder'; + } + + /** + * Find if the item is expanded + * @returns promise resolving to true if change is expanded, to false otherwise + */ + async isExpanded(): Promise { + const twisties = await this.findElements(ScmChange.locators.ScmView.expand); + if (twisties.length < 1) { + return true; + } + return (await twisties[0].getAttribute('class')).indexOf('collapsed') < 0; + } + + /** + * Expand or collapse a change item if possible, only works for folders in hierarchical view mode + * @param expand true to expand the item, false to collapse + * @returns promise resolving to true if the item changed state, to false otherwise + */ + async toggleExpand(expand: boolean): Promise { + if (await this.isExpanded() !== expand) { + await this.click(); + return true; + } + return false; + } + + /** + * Find and click an action button available to a given change tree item + * @param title title of the action button (e.g 'Stage Changes') + * @returns promise resolving to true if the action was performed successfully, + * false if the given button does not exist + */ + async takeAction(title: string): Promise { + await this.getDriver().actions().move({origin: this}).perform(); + const actions = await this.findElements(ScmChange.locators.ScmView.action); + const names = await Promise.all(actions.map(async action => await action.getAttribute(ScmChange.locators.ScmView.actionLabel))); + const index = names.findIndex(item => item === title); + + if (index > -1) { + await actions[index].click(); + return true; + } + return false; + } +} + +export class MoreAction extends ElementWithContexMenu { + constructor(scm: ScmProvider | ScmView) { + super(MoreAction.locators.ScmView.more,scm); + } + + override async openContextMenu(): Promise { + await this.click(); + const shadowRootHost = await this.enclosingItem.findElements(By.className('shadow-root-host')); + let actions = this.getDriver().actions(); + await actions.clear(); + await actions.sendKeys(Key.ESCAPE).perform(); + const webdriverCapabilities = await (this.getDriver() as ChromiumWebDriver).getCapabilities(); + const chromiumVersion = webdriverCapabilities.getBrowserVersion(); + if (shadowRootHost.length > 0) { + if (await this.getAttribute('aria-expanded') !== 'true') { + await this.click(); + } + let shadowRoot; + const webdriverCapabilities = await (this.getDriver() as ChromiumWebDriver).getCapabilities(); + const chromiumVersion = webdriverCapabilities.getBrowserVersion(); + if (chromiumVersion && parseInt(chromiumVersion.split('.')[0]) >= 96) { + shadowRoot = await shadowRootHost[0].getShadowRoot(); + return new ContextMenu(await shadowRoot.findElement(By.className('monaco-menu-container'))).wait(); + } else { + shadowRoot = await this.getDriver().executeScript('return arguments[0].shadowRoot', shadowRootHost[0]) as WebElement; + return new ContextMenu(shadowRoot).wait(); + } + } else if (chromiumVersion && parseInt(chromiumVersion.split('.')[0]) >= 100) { + await this.click(); + const workbench = await this.getDriver().findElement(ElementWithContexMenu.locators.Workbench.constructor); + return new ContextMenu(workbench).wait(); + } + return await super.openContextMenu(); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/sidebar/tree/TreeSection.ts b/vscode-extension-tester/page-objects/src/components/sidebar/tree/TreeSection.ts new file mode 100644 index 00000000..f42bc98d --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/sidebar/tree/TreeSection.ts @@ -0,0 +1,58 @@ +import { ViewSection } from "../ViewSection"; +import { TreeItem } from "../ViewItem"; +import { error } from "selenium-webdriver"; + +export class TreeItemNotFoundError extends error.NoSuchElementError { + constructor(msg?: string) { + super(msg); + this.name = 'TreeItemNotFoundError'; + } +} + +/** + * Abstract representation of a view section containing a tree + */ +export abstract class TreeSection extends ViewSection { + async openItem(...path: string[]): Promise { + let items: TreeItem[] = []; + + for (let i = 0; i < path.length; i++) { + const item = await this.findItem(path[i], i + 1); + if (await item?.hasChildren() && !await item?.isExpanded()) { + await item?.expand(); + } + } + + let currentItem = await this.findItem(path[0], 1); + for (let i = 0; i < path.length; i++) { + if (!currentItem) { + if (i === 0) { + items = await this.getVisibleItems(); + } + let names = await Promise.all(items.map(item => item.getLabel())); + names = names.sort((a, b) => a > b ? 1 : (a < b ? -1 : 0)); + const message = names.length < 1 ? `Current directory is empty.` : `Available items in current directory: [${names.toString()}]`; + + throw new TreeItemNotFoundError(`Item '${path[i]}' not found. ${message}`); + } + items = await currentItem.getChildren(); + if (items.length < 1) { + await currentItem.select(); + return items; + } + if (i + 1 < path.length) { + currentItem = undefined; + for (const item of items) { + if (await item.getLabel() === path[i + 1]) { + currentItem = item; + break; + } + } + } + } + return items; + } + + abstract override findItem(label: string, maxLevel?: number): Promise + abstract override getVisibleItems(): Promise +} diff --git a/vscode-extension-tester/page-objects/src/components/sidebar/tree/custom/CustomTreeItem.ts b/vscode-extension-tester/page-objects/src/components/sidebar/tree/custom/CustomTreeItem.ts new file mode 100644 index 00000000..f5590aa0 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/sidebar/tree/custom/CustomTreeItem.ts @@ -0,0 +1,40 @@ +import { TreeItem } from "../../ViewItem"; +import { TreeSection } from "../TreeSection"; +import { WebElement } from "selenium-webdriver"; + +/** + * View item in a custom-made content section (e.g. an extension tree view) + */ +export class CustomTreeItem extends TreeItem { + constructor(element: WebElement, viewPart: TreeSection) { + super(element, viewPart); + } + + async getLabel(): Promise { + return await this.findElement(CustomTreeItem.locators.CustomTreeSection.itemLabel).getText(); + } + + override async getTooltip(): Promise { + return await this.getAttribute(CustomTreeItem.locators.CustomTreeItem.tooltipAttribute); + } + + override async getDescription(): Promise { + return await this.findElement(CustomTreeItem.locators.CustomTreeItem.description).getText(); + } + + async isExpanded(): Promise { + const attr = await this.getAttribute(CustomTreeItem.locators.CustomTreeItem.expandedAttr); + return attr === CustomTreeItem.locators.CustomTreeItem.expandedValue; + } + + async getChildren(): Promise { + const rows = await this.getChildItems(CustomTreeItem.locators.DefaultTreeSection.itemRow); + const items = await Promise.all(rows.map(async row => new CustomTreeItem(row, this.enclosingItem as TreeSection).wait())); + return items; + } + + async isExpandable(): Promise { + const attr = await this.getAttribute(CustomTreeItem.locators.CustomTreeItem.expandedAttr); + return attr !== null; + } +} diff --git a/vscode-extension-tester/page-objects/src/components/sidebar/tree/custom/CustomTreeSection.ts b/vscode-extension-tester/page-objects/src/components/sidebar/tree/custom/CustomTreeSection.ts new file mode 100644 index 00000000..3c632df4 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/sidebar/tree/custom/CustomTreeSection.ts @@ -0,0 +1,69 @@ +import { TreeSection } from "../TreeSection"; +import { TreeItem } from "../../ViewItem"; +import { Key, until, WebElement } from "selenium-webdriver"; +import { CustomTreeItem, ViewContent } from "../../../.."; + +export type GenericCustomTreeItemConstructor = { new(rootElement: WebElement, tree: TreeSection): T ;}; + +/** + * Generic custom tree view, e.g. contributed by an extension + */ +export class GenericCustomTreeSection extends TreeSection { + private _itemConstructor: GenericCustomTreeItemConstructor; + + constructor(panel: WebElement, private _viewContent: ViewContent, itemConstructor: GenericCustomTreeItemConstructor) { + super(panel, _viewContent); + this._itemConstructor = itemConstructor; + } + + private get viewContent() : ViewContent { + return this._viewContent; + } + + private get itemConstructor() : GenericCustomTreeItemConstructor { + return this._itemConstructor; + } + + async getVisibleItems(): Promise { + const items: T[] = []; + const container = await this.getContainer(); + const elements = await container.findElements(CustomTreeSection.locators.CustomTreeSection.itemRow); + for (const element of elements) { + if (await element.isDisplayed()) { + items.push(new this.itemConstructor(element, this)) + } + } + return items; + } + + async findItem(labelOrPredicate: string | ((el: T) => (PromiseLike | boolean)), maxLevel: number = 0): Promise { + const predicate = typeof labelOrPredicate === 'string' ? (async (el: T) => await el.getLabel() === labelOrPredicate) : (labelOrPredicate); + const elements = await this.getVisibleItems(); + for (const element of elements) { + if (await predicate(element)) { + const level = +await element.getAttribute(CustomTreeSection.locators.ViewSection.level); + if (maxLevel < 1 || level <= maxLevel) { + return element; + } + } + } + return undefined; + } + + private async getContainer(): Promise> { + await this.expand(); + await this.getDriver().wait(until.elementLocated(CustomTreeSection.locators.CustomTreeSection.rowContainer), 5000); + const container = await this.findElement(CustomTreeSection.locators.CustomTreeSection.rowContainer); + await container.sendKeys(Key.HOME); + return new GenericCustomTreeSection(container, this.viewContent, this.itemConstructor); + } +} + +/** + * Custom tree view, e.g. contributed by an extension + */ +export class CustomTreeSection extends GenericCustomTreeSection { + constructor(panel: WebElement, viewContent: ViewContent) { + super(panel, viewContent, CustomTreeItem); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/sidebar/tree/default/DefaultTreeItem.ts b/vscode-extension-tester/page-objects/src/components/sidebar/tree/default/DefaultTreeItem.ts new file mode 100644 index 00000000..907d73fb --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/sidebar/tree/default/DefaultTreeItem.ts @@ -0,0 +1,42 @@ +import { TreeItem } from "../../ViewItem"; +import { TreeSection } from "../TreeSection"; +import { WebElement } from "selenium-webdriver"; +import { NullAttributeError } from "../../../../errors/NullAttributeError"; + +/** + * Default tree item base on the items in explorer view + */ +export class DefaultTreeItem extends TreeItem { + constructor(element: WebElement, viewPart: TreeSection) { + super(element, viewPart); + } + + async getLabel(): Promise { + const value = await this.getAttribute(DefaultTreeItem.locators.DefaultTreeSection.itemLabel); + if (value === null) { + throw new NullAttributeError(`${this.constructor.name}.getLabel returned null`); + } + return value; + } + + override async getTooltip(): Promise { + const tooltip = await this.findElement(DefaultTreeItem.locators.DefaultTreeItem.tooltip); + return await tooltip.getAttribute(DefaultTreeItem.locators.DefaultTreeItem.labelAttribute); + } + + async isExpanded(): Promise { + const twistieClass = await this.findElement(DefaultTreeItem.locators.DefaultTreeItem.twistie).getAttribute('class'); + return twistieClass.indexOf('collapsed') < 0; + } + + async getChildren(): Promise { + const rows = await this.getChildItems(DefaultTreeItem.locators.DefaultTreeSection.itemRow); + const items = await Promise.all(rows.map(async row => new DefaultTreeItem(row, this.enclosingItem as TreeSection).wait())); + return items; + } + + async isExpandable(): Promise { + const twistieClass = await this.findElement(DefaultTreeItem.locators.DefaultTreeItem.twistie).getAttribute('class'); + return twistieClass.indexOf('collapsible') > -1; + } +} diff --git a/vscode-extension-tester/page-objects/src/components/sidebar/tree/default/DefaultTreeSection.ts b/vscode-extension-tester/page-objects/src/components/sidebar/tree/default/DefaultTreeSection.ts new file mode 100644 index 00000000..821dde81 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/sidebar/tree/default/DefaultTreeSection.ts @@ -0,0 +1,43 @@ +import { TreeSection } from "../TreeSection"; +import { TreeItem } from "../../../.."; +import { Key } from 'selenium-webdriver'; +import { DefaultTreeItem } from "./DefaultTreeItem"; + +/** + * Default view section + */ +export class DefaultTreeSection extends TreeSection { + async getVisibleItems(): Promise { + const items: TreeItem[] = []; + const elements = await this.findElements(DefaultTreeSection.locators.DefaultTreeSection.itemRow); + for (const element of elements) { + items.push(await new DefaultTreeItem(element, this).wait()); + } + return items; + } + + async findItem(label: string, maxLevel: number = 0): Promise { + await this.expand(); + const container = await this.findElement(DefaultTreeSection.locators.DefaultTreeSection.rowContainer); + await container.sendKeys(Key.HOME); + let item: TreeItem | undefined = undefined; + do { + const temp = await container.findElements(DefaultTreeSection.locators.DefaultTreeItem.ctor(label)); + if (temp.length > 0) { + const level = +await temp[0].getAttribute(DefaultTreeSection.locators.ViewSection.level); + if (maxLevel < 1 || level <= maxLevel) { + item = await new DefaultTreeItem(temp[0], this).wait(); + } + } + if (!item) { + const lastrow = await container.findElements(DefaultTreeSection.locators.DefaultTreeSection.lastRow); + if (lastrow.length > 0) { + break; + } + await container.sendKeys(Key.PAGE_DOWN); + } + } while (!item) + + return item; + } +} diff --git a/vscode-extension-tester/page-objects/src/components/statusBar/StatusBar.ts b/vscode-extension-tester/page-objects/src/components/statusBar/StatusBar.ts new file mode 100644 index 00000000..7f12c033 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/statusBar/StatusBar.ts @@ -0,0 +1,169 @@ +import { By, Locator, WebElement, error } from "selenium-webdriver"; +import { AbstractElement } from "../AbstractElement"; +import { NotificationsCenter } from "../workbench/NotificationsCenter"; + +/** + * Page object for the status bar at the bottom + */ +export class StatusBar extends AbstractElement { + constructor() { + super(StatusBar.locators.StatusBar.constructor, StatusBar.locators.Workbench.constructor); + } + + /** + * Retrieve all status bar items currently displayed + * @returns Promise resolving to an array of WebElement + */ + async getItems(): Promise { + return await this.findElements(StatusBar.locators.StatusBar.item); + } + + /** + * Find status bar item by title/visible label + * @param title title of the item + * @returns Promise resolving to a WebElement if item is found, to undefined otherwise + */ + async getItem(title: string): Promise { + const items = await this.getItems(); + for (const item of items) { + try { + if (await item.getAttribute(StatusBar.locators.StatusBar.itemTitle) === title) { + return item; + } + } catch (err) { + if (!(err instanceof error.StaleElementReferenceError)) { + throw err; + } + } + } + return undefined; + } + + /** + * Open the notifications center + * @returns Promise resolving to NotificationsCenter object + */ + async openNotificationsCenter(): Promise { + await this.toggleNotificationsCentre(true); + return new NotificationsCenter(); + } + + /** + * Close the notifications center + * @returns Promise resolving when the notifications center is closed + */ + async closeNotificationsCenter(): Promise { + await this.toggleNotificationsCentre(false); + } + + /** + * Open the language selection quick pick + * Only works with an open editor + * @returns Promise resolving when the language selection is opened + */ + async openLanguageSelection(): Promise { + await this.findElement(StatusBar.locators.StatusBar.language).click(); + } + + /** + * Get the current language label text + * Only works with an open editor + * @returns Promise resolving to string representation of current language + */ + async getCurrentLanguage(): Promise { + return await this.getPartText(StatusBar.locators.StatusBar.language); + } + + /** + * Open the quick pick for line endings selection + * Only works with an open editor + * @returns Promise resolving when the line ending selection is opened + */ + async openLineEndingSelection(): Promise { + await this.findElement(StatusBar.locators.StatusBar.lines).click(); + } + + /** + * Get the currently selected line ending as text + * Only works with an open editor + * @returns Promise resolving to string representation of current line ending + */ + async getCurrentLineEnding(): Promise { + return await this.getPartText(StatusBar.locators.StatusBar.lines); + } + + /** + * Open the encoding selection quick pick + * Only works with an open editor + * @returns Promise resolving when the encoding selection is opened + */ + async openEncodingSelection(): Promise { + await this.findElement(StatusBar.locators.StatusBar.encoding).click(); + } + + /** + * Get the name of the current encoding as text + * Only works with an open editor + * @returns Promise resolving to string representation of current encoding + */ + async getCurrentEncoding(): Promise { + return await this.getPartText(StatusBar.locators.StatusBar.encoding); + } + + /** + * Open the indentation selection quick pick + * Only works with an open editor + * @returns Promise resolving when the indentation selection is opened + */ + async openIndentationSelection(): Promise { + await this.findElement(StatusBar.locators.StatusBar.indent).click(); + } + + /** + * Get the current indentation option label as text + * Only works with an open editor + * @returns Promise resolving to string representation of current indentation + */ + async getCurrentIndentation(): Promise { + return await this.getPartText(StatusBar.locators.StatusBar.indent); + } + + /** + * Open the line selection input box + * Only works with an open editor + * @returns Promise resolving when the line selection is opened + */ + async openLineSelection(): Promise { + await this.findElement(StatusBar.locators.StatusBar.selection).click(); + } + + /** + * Get the current editor coordinates as text + * Only works with an open editor + * @returns Promise resolving to string representation of current position in the editor + */ + async getCurrentPosition(): Promise { + return await this.getPartText(StatusBar.locators.StatusBar.selection); + } + + /** + * Open/Close notification centre + * @param open true to open, false to close + */ + private async toggleNotificationsCentre(open: boolean): Promise { + let visible = false; + try { + const klass = await this.enclosingItem.findElement(StatusBar.locators.StatusBar.notifications).getAttribute('class'); + visible = klass.indexOf('visible') > -1; + } catch (err) { + // element doesn't exist until the button is first clicked + } + if (visible !== open) { + await this.findElement(StatusBar.locators.StatusBar.bell).click(); + } + } + + private async getPartText(locator: Locator): Promise { + return await this.findElement(locator).findElement(By.css('a')).getAttribute('innerHTML'); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/workbench/DebugToolbar.ts b/vscode-extension-tester/page-objects/src/components/workbench/DebugToolbar.ts new file mode 100644 index 00000000..237437da --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/workbench/DebugToolbar.ts @@ -0,0 +1,100 @@ +import { until, WebElement } from "selenium-webdriver"; +import { AbstractElement } from "../AbstractElement"; +import { Workbench } from "./Workbench"; + +/** + * Page object for the Debugger Toolbar + */ +export class DebugToolbar extends AbstractElement { + constructor() { + super(DebugToolbar.locators.DebugToolbar.ctor, new Workbench()); + } + + /** + * Wait for the debug toolbar to appear and instantiate it. + * Assumes that debug session is already starting and it is just + * a matter of waiting for the toolbar to appear. + * + * @param timeout max time to wait in milliseconds, default 5000 + */ + static async create(timeout = 5000): Promise { + await DebugToolbar.driver.wait(until.elementLocated(DebugToolbar.locators.DebugToolbar.ctor), timeout); + return new DebugToolbar().wait(timeout); + } + + /** + * Wait for the execution to pause at the next breakpoint + */ + async waitForBreakPoint(timeout = undefined): Promise { + let btn = await this.getDriver().wait(until.elementLocated(DebugToolbar.locators.DebugToolbar.button('continue'))); + await this.getDriver().wait(async () => { + try { + const enabled = await btn.isEnabled(); + return enabled; + } catch(err) { + btn = await this.findElement(DebugToolbar.locators.DebugToolbar.button('continue')); + } + return false; + }, timeout); + } + + /** + * Click Continue + */ + async continue(): Promise { + await (await this.getButton('continue')).click(); + } + + /** + * Click Disconnect + */ + async disconnect(): Promise { + await (await this.getButton('disconnect')).click(); + } + + /** + * Click Pause + */ + async pause(): Promise { + await (await this.getButton('pause')).click(); + } + + /** + * Click Step Over + */ + async stepOver(): Promise { + await (await this.getButton('step-over')).click(); + } + + /** + * Click Step Into + */ + async stepInto(): Promise { + await (await this.getButton('step-into')).click(); + } + + /** + * Click Step Out + */ + async stepOut(): Promise { + await (await this.getButton('step-out')).click(); + } + + /** + * Click Restart + */ + async restart(): Promise { + await (await this.getButton('restart')).click(); + } + + /** + * Click Stop + */ + async stop(): Promise { + await (await this.getButton('stop')).click(); + } + + private async getButton(name: string): Promise { + return await this.findElement(DebugToolbar.locators.DebugToolbar.button(name)); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/workbench/Notification.ts b/vscode-extension-tester/page-objects/src/components/workbench/Notification.ts new file mode 100644 index 00000000..c91b2216 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/workbench/Notification.ts @@ -0,0 +1,140 @@ +import { ElementWithContexMenu } from "../ElementWithContextMenu"; +import { AbstractElement } from "../AbstractElement"; +import { By, until, WebElement } from "selenium-webdriver"; + +/** + * Available types of notifications + */ +export enum NotificationType { + Info = 'info', + Warning = 'warning', + Error = 'error', + Any = 'any' +} + +/** + * Abstract element representing a notification + */ +export abstract class Notification extends ElementWithContexMenu { + + /** + * Get the message of the notification + * @returns Promise resolving to notification message + */ + async getMessage(): Promise { + return await (await this.findElement(Notification.locators.Notification.message)).getText(); + } + + /** + * Get the type of the notification + * @returns Promise resolving to NotificationType + */ + async getType(): Promise { + const iconType = await (await this.findElement(Notification.locators.Notification.icon)).getAttribute('class'); + if (iconType.indexOf('icon-info') > -1) { + return NotificationType.Info; + } else if (iconType.indexOf('icon-warning') > -1) { + return NotificationType.Warning; + } else { + return NotificationType.Error; + } + } + + /** + * Get the source of the notification as text + * @returns Promise resolving to notification source + */ + async getSource(): Promise { + await this.expand(); + return await (await this.findElement(Notification.locators.Notification.source)).getAttribute('title'); + } + + /** + * Find whether the notification has an active progress bar + * @returns Promise resolving to true/false + */ + async hasProgress(): Promise { + const klass = await (await this.findElement(Notification.locators.Notification.progress)).getAttribute('class'); + return klass.indexOf('done') < 0; + } + + /** + * Dismiss the notification + * @returns Promise resolving when notification is dismissed + */ + async dismiss(): Promise { + await this.getDriver().actions().move({origin: this}).perform(); + const btn = await this.findElement(Notification.locators.Notification.dismiss); + await this.getDriver().wait(until.elementIsVisible(btn), 2000); + await btn.click(); + } + + /** + * Get the action buttons of the notification as an array + * of NotificationButton objects + * @returns Promise resolving to array of NotificationButton objects + */ + async getActions(): Promise { + const buttons: NotificationButton[] = []; + const elements = await this.findElement(Notification.locators.Notification.actions) + .findElements(Notification.locators.Notification.action); + + for (const button of elements) { + const title = await Notification.locators.Notification.actionLabel.value(button); + buttons.push(await new NotificationButton(Notification.locators.Notification.buttonConstructor(title), this).wait()); + } + return buttons; + } + + /** + * Click on an action button with the given title + * @param title title of the action/button + * @returns Promise resolving when the select button is pressed + */ + async takeAction(title: string): Promise { + await new NotificationButton(Notification.locators.Notification.buttonConstructor(title), this).click(); + } + + /** + * Expand the notification if possible + */ + async expand(): Promise { + await this.getDriver().actions().move({origin: this}).perform(); + const exp = await this.findElements(Notification.locators.Notification.expand); + if (exp[0]) { + await exp[0].click(); + } + } +} + +/** + * Notification displayed on its own in the notifications-toasts container + */ +export class StandaloneNotification extends Notification { + constructor(notification: WebElement) { + super(notification, StandaloneNotification.locators.Notification.standaloneContainer); + } +} + +/** + * Notification displayed within the notifications center + */ +export class CenterNotification extends Notification { + constructor(notification: WebElement) { + super(notification, CenterNotification.locators.NotificationsCenter.constructor); + } +} + +/** + * Notification button + */ +class NotificationButton extends AbstractElement { + + constructor(buttonConstructor: By, notification: Notification) { + super(buttonConstructor, notification); + } + + async getTitle(): Promise { + return await Notification.locators.Notification.actionLabel.value(this); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/workbench/NotificationsCenter.ts b/vscode-extension-tester/page-objects/src/components/workbench/NotificationsCenter.ts new file mode 100644 index 00000000..e34fa05e --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/workbench/NotificationsCenter.ts @@ -0,0 +1,56 @@ +import { Key } from "selenium-webdriver"; +import { AbstractElement } from "../AbstractElement"; +import { Notification, CenterNotification, NotificationType } from "./Notification"; + +/** + * Notifications center page object + */ +export class NotificationsCenter extends AbstractElement { + constructor() { + super(NotificationsCenter.locators.NotificationsCenter.constructor, NotificationsCenter.locators.Workbench.constructor); + } + + /** + * Close the notifications center + * @returns Promise resolving when the center is closed + */ + async close(): Promise { + if (await this.isDisplayed()) { + try { + await this.findElement(NotificationsCenter.locators.NotificationsCenter.close).click(); + } catch (error) { + await this.click(); + this.getDriver().actions().sendKeys(Key.ESCAPE).perform(); + } + } + } + + /** + * Clear all notifications in the notifications center + * Note that this will also hide the notifications center + * @returns Promise resolving when the clear all button is pressed + */ + async clearAllNotifications(): Promise { + await this.findElement(NotificationsCenter.locators.NotificationsCenter.clear).click(); + } + + /** + * Get all notifications of a given type + * @param type type of the notifications to look for, + * NotificationType.Any will retrieve all notifications + * + * @returns Promise resolving to array of Notification objects + */ + async getNotifications(type: NotificationType): Promise { + const notifications: Notification[] = []; + const elements = await this.findElements(NotificationsCenter.locators.NotificationsCenter.row); + + for (const element of elements) { + const not = new CenterNotification(element); + if (type === NotificationType.Any || await not.getType() === type) { + notifications.push(await not.wait()); + } + } + return notifications; + } +} diff --git a/vscode-extension-tester/page-objects/src/components/workbench/Workbench.ts b/vscode-extension-tester/page-objects/src/components/workbench/Workbench.ts new file mode 100644 index 00000000..33e01eef --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/workbench/Workbench.ts @@ -0,0 +1,153 @@ +import { AbstractElement } from "../AbstractElement"; +import { Key, until, WebElement } from "selenium-webdriver"; +import { TitleBar } from "../menu/TitleBar"; +import { SideBarView } from "../sidebar/SideBarView"; +import { ActivityBar } from "../activityBar/ActivityBar"; +import { StatusBar } from "../statusBar/StatusBar"; +import { EditorView } from "../editor/EditorView"; +import { BottomBarPanel } from "../bottomBar/BottomBarPanel"; +import { Notification, StandaloneNotification } from "./Notification"; +import { NotificationsCenter } from "./NotificationsCenter"; +import { QuickOpenBox } from "./input/QuickOpenBox"; +import { SettingsEditor } from "../editor/SettingsEditor"; +import { InputBox } from "./input/InputBox"; + +/** + * Handler for general workbench related actions + */ +export class Workbench extends AbstractElement { + constructor() { + super(Workbench.locators.Workbench.constructor); + } + + /** + * Get a title bar handle + */ + getTitleBar(): TitleBar { + return new TitleBar(); + } + + /** + * Get a side bar handle + */ + getSideBar(): SideBarView { + return new SideBarView(); + } + + /** + * Get an activity bar handle + */ + getActivityBar(): ActivityBar { + return new ActivityBar(); + } + + /** + * Get a status bar handle + */ + getStatusBar(): StatusBar { + return new StatusBar(); + } + + /** + * Get a bottom bar handle + */ + getBottomBar(): BottomBarPanel { + return new BottomBarPanel(); + } + + /** + * Get a handle for the editor view + */ + getEditorView(): EditorView { + return new EditorView(); + } + + /** + * Get all standalone notifications (notifications outside the notifications center) + * @returns Promise resolving to array of Notification objects + */ + async getNotifications(): Promise { + const notifications: Notification[] = []; + let container: WebElement; + + try { + container = await this.findElement(Workbench.locators.Workbench.notificationContainer); + } catch (err) { + return []; + } + + const elements = await container.findElements(Workbench.locators.Workbench.notificationItem); + + for (const element of elements) { + notifications.push(await new StandaloneNotification(element).wait()); + } + + return notifications; + } + + /** + * Opens the notifications center + * @returns Promise resolving to NotificationsCenter object + */ + async openNotificationsCenter(): Promise { + return await new StatusBar().openNotificationsCenter(); + } + + /** + * Opens the settings editor + * + * @returns promise that resolves to a SettingsEditor instance + */ + async openSettings(): Promise { + await this.executeCommand("open user settings"); + await new EditorView().openEditor("Settings"); + await Workbench.driver.wait(until.elementLocated(Workbench.locators.Editor.constructor)); + await new Promise(res => setTimeout(res, 500)); + + return new SettingsEditor(); + } + + /** + * Open the VS Code command line prompt + * @returns Promise resolving to InputBox (vscode 1.44+) or QuickOpenBox (vscode up to 1.43) object + */ + async openCommandPrompt(): Promise { + const webview = await new EditorView().findElements(EditorView.locators.EditorView.webView); + + if (webview.length > 0) { + const tab = await new EditorView().getActiveTab(); + + if (tab) { + await tab.sendKeys(Key.F1); + + return await InputBox.create(); + } + } + + const driver = this.getDriver(); + await driver.actions().keyDown(Workbench.ctlKey).keyDown(Key.SHIFT).sendKeys("p").perform(); + + if (Workbench.versionInfo.version >= "1.44.0") { + return await InputBox.create(); + } + + return await QuickOpenBox.create(); + } + + /** + * Open the command prompt, type in a command and execute + * @param command text of the command to be executed + * @returns Promise resolving when the command prompt is confirmed + */ + async executeCommand(command: string): Promise { + const prompt = await this.openCommandPrompt(); + await prompt.setText(`>${command}`); + const quickPicks = await Promise.all((await prompt.getQuickPicks()).map(item => item.getLabel())); + + if (quickPicks.includes(command)) { + await prompt.selectQuickPick(command); + } else { + await prompt.confirm(); + } + } +} diff --git a/vscode-extension-tester/page-objects/src/components/workbench/input/Input.ts b/vscode-extension-tester/page-objects/src/components/workbench/input/Input.ts new file mode 100644 index 00000000..94fd9c31 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/workbench/input/Input.ts @@ -0,0 +1,275 @@ +import { AbstractElement } from "../../AbstractElement"; +import { Key } from "selenium-webdriver"; +import { QuickOpenBox } from "../../.."; + +/** + * Abstract page object for input fields + */ +export abstract class Input extends AbstractElement { + + /** + * Get current text of the input field + * @returns Promise resolving to text of the input field + */ + override async getText(): Promise { + const input = await this.findElement(Input.locators.Input.inputBox) + .findElement(Input.locators.Input.input); + return await input.getAttribute('value'); + } + + /** + * Set (by selecting all and typing) text in the input field + * @param text text to set into the input field + * @returns Promise resolving when the text is typed in + */ + async setText(text: string): Promise { + const clipboard = (await import('clipboardy')).default; + let originalClipboard = ''; + try { + originalClipboard = clipboard.readSync(); + } catch (error) { + // workaround issue https://github.com/redhat-developer/vscode-extension-tester/issues/835 + // do not fail if clipboard is empty + } + const input = await this.findElement(Input.locators.Input.inputBox) + .findElement(Input.locators.Input.input); + await this.clear(); + await new Promise(res => setTimeout(res, 200)); + if ((await this.getText()).length > 0) { + await input.sendKeys(Key.END, Key.chord(Key.SHIFT, Key.HOME)); + } + await input.sendKeys(text); + + // fallback to clipboard if the text gets malformed + if ((await this.getText()) !== text) { + await clipboard.write(text); + await input.sendKeys(Key.END, Key.chord(Key.SHIFT, Key.HOME)); + await input.sendKeys(Key.chord(Input.ctlKey, 'v')); + if(originalClipboard.length > 0) { + clipboard.writeSync(originalClipboard); + } + } + } + + /** + * Get the placeholder text for the input field + * @returns Promise resolving to input placeholder + */ + async getPlaceHolder(): Promise { + return await this.findElement(Input.locators.Input.inputBox) + .findElement(Input.locators.Input.input).getAttribute('placeholder'); + } + + /** + * Confirm the input field by pressing Enter + * @returns Promise resolving when the input is confirmed + */ + async confirm(): Promise { + const input = await this.findElement(Input.locators.Input.inputBox) + .findElement(Input.locators.Input.input); + await input.click(); + await input.sendKeys(Key.ENTER); + } + + /** + * Cancel the input field by pressing Escape + * @returns Promise resolving when the input is cancelled + */ + async cancel(): Promise { + const input = await this.findElement(Input.locators.Input.inputBox) + .findElement(Input.locators.Input.input); + await input.sendKeys(Key.ESCAPE); + } + + /** + * Clear the inpur field + * @returns Promise resolving when the field is cleared + */ + override async clear(): Promise { + const input = await this.findElement(Input.locators.Input.inputBox) + .findElement(Input.locators.Input.input); + // VS Code 1.40 breaks the default clear method, use select all + back space instead + await input.sendKeys(Key.END, Key.chord(Key.SHIFT, Key.HOME), Key.BACK_SPACE); + if ((await input.getAttribute('value')).length > 0) { + await input.sendKeys(Key.END, Key.chord(Key.SHIFT, Key.HOME), Key.BACK_SPACE); + } + } + + /** + * Select (click) a quick pick option. Will scroll through the quick picks to find the item. + * Search for the item can be done by its text, or index in the quick pick menu. + * Note that scrolling does not affect the item's index, but it will + * replace some items in the DOM (thus they become unreachable) + * + * @param indexOrText index (number) or text (string) of the item to search by + * @returns Promise resolving when the given quick pick is selected + */ + async selectQuickPick(indexOrText: string | number): Promise { + const pick = await this.findQuickPick(indexOrText); + if (pick) { + await pick.select(); + } else { + await this.resetPosition(); + } + } + + /** + * Select/Deselect all quick picks using the 'select all' checkbox + * If multiple selection is disabled on the input box, no action is performed + * + * @param state true to select all, false to deselect all + * @returns Promise resolving when all quick picks have been toggled to desired state + */ + async toggleAllQuickPicks(state: boolean): Promise { + const checkboxes = await this.findElements(Input.locators.Input.quickPickSelectAll); + if (checkboxes.length < 0) { + return; + } + if (!await checkboxes[0].isSelected()) { + await checkboxes[0].click(); + } + if (state === false) { + await checkboxes[0].click(); + } + } + + /** + * Scroll through the quick picks to find an item by the name or index + * @param indexOrText index (number) or text (string) of the item to search by + * @returns Promise resolvnig to QuickPickItem if found, to undefined otherwise + */ + async findQuickPick(indexOrText: string | number): Promise { + const input = await this.findElement(Input.locators.Input.inputBox) + .findElement(Input.locators.Input.input); + const first = await this.findElements(Input.locators.Input.quickPickPosition(1)); + if (first.length < 1) { + await this.resetPosition(); + } + let endReached = false; + + while(!endReached) { + const picks = await this.getQuickPicks(); + for (const pick of picks) { + const lastRow = await this.findElements(Input.locators.DefaultTreeSection.lastRow); + if (lastRow.length > 0) { + endReached = true; + } else if (await pick.getAttribute('aria-posinset') === await pick.getAttribute('aria-setsize')) { + endReached = true; + } + if (typeof indexOrText === 'string') { + const text = await pick.getLabel(); + if (text.indexOf(indexOrText) > -1) { + return pick; + } + } else if (indexOrText === pick.getIndex()){ + return pick; + } + } + if (!endReached) { + await input.sendKeys(Key.PAGE_DOWN); + } + } + return undefined; + } + + /** + * Retrieve the title of an input box if it has one + * @returns Promise resolving to title if it exists, to undefined otherwise + */ + async getTitle(): Promise { + const titleBar = await this.findElements(Input.locators.Input.titleBar); + if (titleBar.length > 0 && await titleBar[0].isDisplayed()) { + return (await titleBar[0].findElement(Input.locators.Input.title)).getText(); + } + return Promise.resolve(undefined); + } + + /** + * Click on the back button if it exists + * @returns Promise resolving to true if a button was clicked, to false otherwise + */ + async back(): Promise { + const titleBar = await this.findElements(Input.locators.Input.titleBar); + if (titleBar.length > 0 && await titleBar[0].isDisplayed()) { + const backBtn = await titleBar[0].findElements(Input.locators.Input.backButton); + if (backBtn.length > 0 && await backBtn[0].isEnabled()) { + await backBtn[0].click(); + return true; + } + } + return false; + } + + /** + * Find whether the input box has an active progress bar + * @returns Promise resolving to true/false + */ + abstract hasProgress(): Promise + + /** + * Retrieve the quick pick items currently available in the DOM + * (visible in the quick pick menu) + * @returns Promise resolving to array of QuickPickItem objects + */ + abstract getQuickPicks(): Promise + + private async resetPosition(): Promise { + const text = await this.getText(); + await this.clear(); + await this.setText(text); + } +} + +/** + * Page object representing a quick pick option in the input box + */ +export class QuickPickItem extends AbstractElement { + private index: number; + + constructor(index: number, input: Input, isMultiSelect = false) { + let locator = Input.locators.Input.quickPickIndex(index); + if (input instanceof QuickOpenBox) { + locator = Input.locators.Input.quickPickPosition(index); + } + + if (isMultiSelect) { + locator = Input.locators.Input.multiSelectIndex(index); + } + + super(locator, input); + this.index = index; + } + + /** + * Get the label of the quick pick item + */ + async getLabel(): Promise { + return await this.findElement(Input.locators.Input.quickPickLabel).getText(); + } + + /** + * Get the description of the quick pick item + */ + async getDescription(): Promise { + try { + return await this.findElement(Input.locators.Input.quickPickDescription).getText(); + } catch (err) { + return undefined; + } + } + + /** + * Get the index of the quick pick item + */ + getIndex(): number { + return this.index; + } + + /** + * Select (click) the quick pick item + * @returns Promise resolving when the item has been clicked + */ + async select(): Promise { + await this.click(); + } +} diff --git a/vscode-extension-tester/page-objects/src/components/workbench/input/InputBox.ts b/vscode-extension-tester/page-objects/src/components/workbench/input/InputBox.ts new file mode 100644 index 00000000..e8f99e84 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/workbench/input/InputBox.ts @@ -0,0 +1,81 @@ +import { Input, QuickPickItem } from "../../.."; +import { until } from "selenium-webdriver"; + +/** + * Plain input box variation of the input page object + */ +export class InputBox extends Input { + constructor() { + super(InputBox.locators.InputBox.constructor, InputBox.locators.Workbench.constructor); + } + + /** + * Construct a new InputBox instance after waiting for its underlying element to exist + * Use when an input box is scheduled to appear. + * @param timeout max time to wait in milliseconds, default 5000 + */ + static async create(timeout: number = 5000): Promise { + await InputBox.driver.wait(until.elementLocated(InputBox.locators.InputBox.constructor), timeout); + return new InputBox().wait(); + } + + /** + * Get the message below the input field + */ + async getMessage(): Promise { + return await this.findElement(InputBox.locators.InputBox.message).getText(); + } + + async hasProgress(): Promise { + const klass = await this.findElement(InputBox.locators.InputBox.progress) + .getAttribute('class'); + return klass.indexOf('done') < 0; + } + + async getQuickPicks(): Promise { + const picks: QuickPickItem[] = []; + const elements = await this.findElement(InputBox.locators.InputBox.quickList) + .findElement(InputBox.locators.InputBox.rows) + .findElements(InputBox.locators.InputBox.row); + + for (const element of elements) { + if (await element.isDisplayed()) { + picks.push(await new QuickPickItem(+await element.getAttribute('data-index'), this).wait()); + } + } + return picks; + } + + async getCheckboxes(): Promise { + const picks: QuickPickItem[] = []; + const elements = await this.findElement(InputBox.locators.InputBox.quickList) + .findElement(InputBox.locators.InputBox.rows) + .findElements(InputBox.locators.InputBox.row); + + for (const element of elements) { + if (await element.isDisplayed()) { + picks.push(await new QuickPickItem(+await element.getAttribute('data-index'), this, true).wait()); + } + } + + return picks; + } + + /** + * Find whether the input is showing an error + * @returns Promise resolving to notification message + */ + async hasError(): Promise { + const klass = await this.findElement(InputBox.locators.Input.inputBox).getAttribute('class'); + return klass.indexOf('error') > -1; + } + + /** + * Check if the input field is masked (input type password) + * @returns Promise resolving to notification message + */ + async isPassword(): Promise { + const input = await this.findElement(InputBox.locators.Input.input); + return await input.getAttribute('type') === 'password'; + } +} diff --git a/vscode-extension-tester/page-objects/src/components/workbench/input/QuickOpenBox.ts b/vscode-extension-tester/page-objects/src/components/workbench/input/QuickOpenBox.ts new file mode 100644 index 00000000..7f11ad34 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/components/workbench/input/QuickOpenBox.ts @@ -0,0 +1,40 @@ +import { Input, QuickPickItem } from "../../.."; +import { until } from "selenium-webdriver"; + +/** + * @deprecated as of VS Code 1.44.0, quick open box has been replaced with input box + * The quick open box variation of the input + */ +export class QuickOpenBox extends Input { + constructor() { + super(QuickOpenBox.locators.QuickOpenBox.constructor, QuickOpenBox.locators.Workbench.constructor); + } + + /** + * Construct a new QuickOpenBox instance after waiting for its underlying element to exist + * Use when a quick open box is scheduled to appear. + */ + static async create(): Promise { + await QuickOpenBox.driver.wait(until.elementLocated(QuickOpenBox.locators.QuickOpenBox.constructor)); + return new QuickOpenBox().wait(); + } + + async hasProgress(): Promise { + const klass = await this.findElement(QuickOpenBox.locators.QuickOpenBox.progress) + .getAttribute('class'); + return klass.indexOf('done') < 0; + } + + async getQuickPicks(): Promise { + const picks: QuickPickItem[] = []; + const tree = await this.getDriver().wait(until.elementLocated(QuickOpenBox.locators.QuickOpenBox.quickList), 1000); + const elements = await tree.findElements(QuickOpenBox.locators.QuickOpenBox.row); + for (const element of elements) { + const index = +await element.getAttribute('aria-posinset'); + if (await element.isDisplayed()) { + picks.push(await new QuickPickItem(index, this).wait()); + } + } + return picks; + } +} diff --git a/vscode-extension-tester/page-objects/src/conditions/WaitForAttribute.ts b/vscode-extension-tester/page-objects/src/conditions/WaitForAttribute.ts new file mode 100644 index 00000000..26af7e43 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/conditions/WaitForAttribute.ts @@ -0,0 +1,14 @@ +import { WebElement } from "selenium-webdriver"; + +/** + * Condition to wait until an element's attribute has a specified value + * @param element webelement to check + * @param attribute attribute to check + * @param value value to wait for the attribute to have + */ +export function waitForAttributeValue(element: WebElement, attribute: string, value: string) { + return async () => { + const result = await element.getAttribute(attribute); + return result === value; + } +} diff --git a/vscode-extension-tester/page-objects/src/errors/NullAttributeError.ts b/vscode-extension-tester/page-objects/src/errors/NullAttributeError.ts new file mode 100644 index 00000000..1826dc55 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/errors/NullAttributeError.ts @@ -0,0 +1,6 @@ +export class NullAttributeError extends Error { + constructor(message?: string) { + super(message); + this.name = 'NullAttributeError'; + } +} diff --git a/vscode-extension-tester/page-objects/src/index.ts b/vscode-extension-tester/page-objects/src/index.ts new file mode 100644 index 00000000..c3188f56 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/index.ts @@ -0,0 +1,99 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +import { WebDriver } from "selenium-webdriver"; + +import { AbstractElement } from "./components/AbstractElement"; +import { LocatorLoader } from "./locators/loader"; +import { Locators } from "./locators/locators"; + +export * from "selenium-webdriver"; +export * from "./locators/locators"; +export * from "./errors/NullAttributeError"; + +export * from "./components/menu/Menu"; +export * from "./components/menu/MenuItem"; +export * from "./components/menu/TitleBar"; +export * from "./components/menu/MacTitleBar"; +export * from "./components/menu/ContextMenu"; +export * from "./components/menu/WindowControls"; + +export * from "./components/activityBar/ActivityBar"; +export * from "./components/activityBar/ViewControl"; +export * from "./components/activityBar/ActionsControl"; + +export * from "./components/sidebar/SideBarView"; +export * from "./components/sidebar/ViewTitlePart"; +export * from "./components/sidebar/ViewContent"; +export * from "./components/sidebar/ViewSection"; +export * from "./components/sidebar/ViewItem"; +export * from "./components/sidebar/WelcomeContent"; + +export * from "./components/sidebar/tree/TreeSection"; +export * from "./components/sidebar/tree/default/DefaultTreeSection"; +export * from "./components/sidebar/tree/default/DefaultTreeItem"; +export * from "./components/sidebar/tree/custom/CustomTreeSection"; +export * from "./components/sidebar/tree/custom/CustomTreeItem"; +export * from "./components/sidebar/tree/debug/DebugBreakpointSection"; +export * from "./components/sidebar/tree/debug/BreakpointSectionItem"; +export * from "./components/sidebar/tree/debug/DebugVariablesSection"; +export * from "./components/sidebar/tree/debug/VariableSectionItem"; +export * from "./components/sidebar/extensions/ExtensionsViewSection"; +export * from "./components/sidebar/extensions/ExtensionsViewItem"; +export { ScmView, ScmProvider, ScmChange } from "./components/sidebar/scm/ScmView"; +export * from "./components/sidebar/scm/NewScmView"; +export * from "./components/sidebar/debug/DebugView"; + +export * from "./components/bottomBar/BottomBarPanel"; +export * from "./components/bottomBar/ProblemsView"; +export * from "./components/bottomBar/WebviewView"; +export * from "./components/bottomBar/Views"; +export * from "./components/statusBar/StatusBar"; + +export * from "./components/editor/EditorView"; +export * from "./components/editor/EditorAction"; +export * from "./components/editor/Breakpoint"; +export * from "./components/editor/TextEditor"; +export * from "./components/editor/Editor"; +export * from "./components/editor/SettingsEditor"; +export * from "./components/editor/DiffEditor"; +export * from "./components/editor/WebView"; +export * from "./components/editor/ContentAssist"; +export * from "./components/editor/CustomEditor"; + +export { Notification, NotificationType } from "./components/workbench/Notification"; +export * from "./components/workbench/NotificationsCenter"; +export * from "./components/workbench/input/Input"; +export * from "./components/workbench/input/InputBox"; +export * from "./components/workbench/input/QuickOpenBox"; +export * from "./components/workbench/Workbench"; +export * from "./components/workbench/DebugToolbar"; + +export * from "./components/dialog/ModalDialog"; + +export * from "./conditions/WaitForAttribute"; + +/** + * Initialize the page objects for your tests + * + * @param currentVersion version of the locators to load + * @param baseVersion base version of the locators if you have multiple versions with diffs, otherwise leave the same as currentVersion + * @param locatorFolder folder that contains locator files + * @param driver WebDriver instance + * @param browserID identifier/name of the browser (i.e. vscode) + */ +export function initPageObjects( + currentVersion: string, + baseVersion: string, + locatorFolder: string, + driver: WebDriver, + browserID: string, +): void { + const locators: Locators = new LocatorLoader(currentVersion, baseVersion, locatorFolder).loadLocators(); + + AbstractElement.init(locators, driver, browserID, currentVersion); +} diff --git a/vscode-extension-tester/page-objects/src/locators/loader.ts b/vscode-extension-tester/page-objects/src/locators/loader.ts new file mode 100644 index 00000000..a36c831a --- /dev/null +++ b/vscode-extension-tester/page-objects/src/locators/loader.ts @@ -0,0 +1,115 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +import * as fs from "fs-extra"; +import * as path from "path"; + +import { DeepPartial, DeepRequired, Merge } from "ts-essentials"; +import { LocatorDiff, Locators } from "./locators"; +import clone from "clone-deep"; +import { compareVersions } from "compare-versions"; + +/** + * Utility for loading locators for a given vscode version + */ +export class LocatorLoader { + private baseVersion: string; + private baseFolder: string; + private version: string; + private locators: Locators; + + /** + * Construct new loader for a given vscode version + * @param version select version of vscode + */ + constructor(version: string, baseVersion: string, baseFolder: string) { + this.version = version; + + if (version.endsWith("-insider")) { + this.version = version.substring(0, version.indexOf("-insider")); + } + + this.baseVersion = baseVersion; + this.baseFolder = path.resolve(baseFolder); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const temp: { locators: Locators } = require(path.resolve(baseFolder, baseVersion)); + this.locators = temp.locators as Locators; + } + + /** + * Loads locators for the selected vscode version + * @returns object containing all locators + */ + loadLocators(): Locators { + let versions: string[] = fs + .readdirSync(this.baseFolder) + .filter((file: string) => file.endsWith(".js")) + .map((file: string) => path.basename(file, ".js")); + + if (compareVersions(this.baseVersion, this.version) === 0) { + return this.locators; + } + + if (compareVersions(this.baseVersion, this.version) < 0) { + versions = versions + .filter( + (ver: string) => + compareVersions(this.baseVersion, ver) < 0 && compareVersions(ver, this.version) <= 0, + ) + .sort(compareVersions); + } else { + versions = versions + .filter( + (ver: string) => + compareVersions(this.baseVersion, ver) > 0 && compareVersions(ver, this.version) >= 0, + ) + .sort(compareVersions) + .reverse(); + } + + for (let i: number = 0; i < versions.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const diff: LocatorDiff = require(path.join(this.baseFolder, versions[i])).diff as LocatorDiff; + + const newLocators: Merge> = mergeLocators(this.locators, diff); + this.locators = newLocators as DeepRequired>>; + } + + return this.locators; + } +} + +function mergeLocators(original: Locators, diff: LocatorDiff): Locators { + const target: Locators = clone(original); + const targetDiff: DeepPartial = diff.locators; + + merge(target, targetDiff) as Locators; + + return target; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function merge(target: any, obj: any): any { + for (const key in obj) { + if (key === "__proto__" || !Object.prototype.hasOwnProperty.call(obj, key)) { + continue; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const oldVal: any = obj[key]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newVal: any = target[key]; + + if (typeof newVal === "object" && typeof oldVal === "object") { + target[key] = merge(newVal, oldVal); + } else { + target[key] = clone(oldVal); + } + } + + return target; +} diff --git a/vscode-extension-tester/page-objects/src/locators/locators.ts b/vscode-extension-tester/page-objects/src/locators/locators.ts new file mode 100644 index 00000000..4dfd6c31 --- /dev/null +++ b/vscode-extension-tester/page-objects/src/locators/locators.ts @@ -0,0 +1,547 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +import { By, WebElement } from "selenium-webdriver"; +import { DeepPartial } from "ts-essentials"; +import { ViewSection } from "../components/sidebar/ViewSection"; + +type WebElementFunction = (element: E) => T | PromiseLike; +type LocatorAwareWebElementFunction = (element: WebElement, locator: Locators) => T | PromiseLike; + +/** + * Type definitions for all used locators + */ +export interface Locators { + // AbstractElement properties + AbstractElement: { + enabled: WebElementFunction; + selected: WebElementFunction; + }; + + // Activity Bar + ActivityBar: { + constructor: By; + viewContainer: By; + label: string; + actionsContainer: By; + actionItem: By; + }; + ViewControl: { + attribute: string; + klass: string; + scmId: By; + debugId: By; + badge: By; + }; + + // Bottom Bar + BottomBarPanel: { + constructor: By; + problemsTab: string; + outputTab: string; + debugTab: string; + terminalTab: string; + maximize: string; + restore: string; + close: string; + tabContainer: By; + tab: (title: string) => By; + actions: By; + globalActions: By; + action: (label: string) => By; + closeAction: By; + }; + BottomBarViews: { + actionsContainer: (label: string) => By; + channelOption: By; + channelCombo: By; + channelText: By; + channelRow: By; + textArea: By; + clearText: By; + }; + ProblemsView: { + constructor: By; + markersFilter: By; + input: By; + collapseAll: By; + markerRow: By; + rowLabel: string; + label: By; + markerTwistie: By; + changeCount: By; + }; + TerminalView: { + constructor: By; + actionsLabel: string; + textArea: By; + killTerminal: By; + newTerminal: By; + tabList: By; + singleTab: By; + selectedRow: By; + row: By; + newCommand: string; + }; + DebugConsoleView: { + constructor: By; + }; + OutputView: { + constructor: By; + actionsLabel: string; + optionByName: (name: string) => By; + }; + WebviewView: { + iframe: By; + }; + + // Editors + EditorView: { + constructor: By; + editorGroup: By; + settingsEditor: By; + webView: By; + diffEditor: By; + tab: By; + closeTab: By; + tabTitle: string; + tabSeparator: string; + tabLabel: string; + actionContainer: By; + actionItem: By; + attribute: string; + }; + Editor: { + constructor: By; + inputArea: By; + title: By; + }; + TextEditor: { + activeTab: By; + breakpoint: { + pauseSelector: By; + generalSelector: By; + properties: { + enabled: WebElementFunction; + line: { + selector: By; + number: WebElementFunction; + }; + paused: WebElementFunction; + }; + }; + editorContainer: By; + dataUri: string; + formatDoc: string; + marginArea: By; + lineNumber: (line: number) => By; + lineOverlay: (line: number) => By; + debugHint: By; + selection: By; + findWidget: By; + }; + FindWidget: { + toggleReplace: By; + replacePart: By; + findPart: By; + matchCount: By; + input: By; + content: By; + button: (title: string) => By; + checkbox: (title: string) => By; + }; + ContentAssist: { + constructor: By; + message: By; + itemRows: By; + itemRow: By; + itemLabel: By; + itemText: By; + itemList: By; + firstItem: By; + }; + SettingsEditor: { + title: string; + itemRow: By; + header: By; + tabs: By; + actions: By; + action: (label: string) => By; + settingConstructor: (title: string, category: string) => By; + settingDesctiption: By; + settingLabel: By; + settingCategory: By; + comboSetting: By; + comboOption: By; + comboValue: string; + textSetting: By; + checkboxSetting: By; + checkboxChecked: string; + linkButton: By; + itemCount: By; + }; + DiffEditor: { + originalEditor: By; + modifiedEditor: By; + }; + WebView: { + iframe: By; + activeFrame: By; + container: (id: string) => By; + attribute: string; + }; + + // Menus + ContextMenu: { + contextView: By; + constructor: By; + itemConstructor: (label: string) => By; + itemElement: By; + itemLabel: By; + itemText: string; + itemNesting: By; + viewBlock: By; + }; + TitleBar: { + constructor: By; + itemConstructor: (label: string) => By; + itemElement: By; + itemLabel: string; + title: By; + }; + WindowControls: { + constructor: By; + minimize: By; + maximize: By; + restore: By; + close: By; + }; + + // Side Bar + SideBarView: { + constructor: By; + }; + ViewTitlePart: { + constructor: By; + title: By; + action: By; + actionLabel: string; + actionConstructor: (title: string) => By; + }; + ViewContent: { + constructor: By; + progress: By; + section: By; + defaultView: By; + extensionsView: By; + }; + ViewSection: { + title: By; + titleText: string; + header: By; + headerExpanded: string; + actions: By; + actionConstructor: (label: string) => By; + button: By; + buttonLabel: string; + level: string; + index: string; + welcomeContent: By; + }; + TreeItem: { + actions: By; + actionLabel: By; + actionTitle: string; + twistie: By; + }; + DefaultTreeSection: { + itemRow: By; + itemLabel: string; + rowContainer: By; + rowWithLabel: (label: string) => By; + lastRow: By; + type: { + default: LocatorAwareWebElementFunction; + marketplace: { + extension: LocatorAwareWebElementFunction; + }; + }; + }; + DefaultTreeItem: { + ctor: (label: string) => By; + twistie: By; + tooltip: By; + labelAttribute: string; + }; + CustomTreeSection: { + itemRow: By; + itemLabel: By; + rowContainer: By; + rowWithLabel: (label: string) => By; + }; + CustomTreeItem: { + constructor: (label: string) => By; + expandedAttr: string; + expandedValue: string; + tooltipAttribute: string; + description: By; + }; + DebugBreakpointSection: { + predicate: WebElementFunction; + }; + BreakpointSectionItem: { + breakpoint: { + constructor: By; + }; + breakpointCheckbox: { + constructor: By; + value: WebElementFunction; + }; + label: { + constructor: By; + value: WebElementFunction; + }; + filePath: { + constructor: By; + value: WebElementFunction; + }; + lineNumber: { + constructor: By; + value: WebElementFunction; + }; + }; + DebugVariableSection: { + predicate: WebElementFunction; + }; + VariableSectionItem: { + label: WebElementFunction; + name: { + constructor: By; + value: WebElementFunction; + tooltip: WebElementFunction; + }; + value: { + constructor: By; + value: WebElementFunction; + tooltip: WebElementFunction; + }; + }; + ExtensionsViewSection: { + items: By; + itemRow: By; + itemTitle: By; + searchBox: By; + textContainer: By; + textField: By; + }; + ExtensionsViewItem: { + version: By; + author: By; + description: By; + install: By; + manage: By; + }; + ScmView: { + providerHeader: By; + providerRelative: By; + initButton: By; + providerTitle: By; + providerType: By; + action: By; + actionConstructor: (title: string) => By; + actionLabel: string; + inputField: By; + changeItem: By; + changeName: By; + changeCount: By; + changeLabel: By; + changeDesc: By; + resource: By; + changes: By; + stagedChanges: By; + expand: By; + more: By; + multiMore: By; + multiScmProvider: By; + singleScmProvider: By; + multiProviderItem: By; + itemLevel: (level: number) => By; + itemIndex: (index: number) => By; + }; + DebugView: { + launchCombo: By; + launchSelect: By; + launchOption: By; + optionByName: (name: string) => By; + startButton: By; + }; + DebugToolbar: { + ctor: By; + button: (title: string) => By; + }; + + // Status Bar + StatusBar: { + constructor: By; + language: By; + lines: By; + encoding: By; + indent: By; + selection: By; + notifications: By; + bell: By; + item: By; + itemTitle: string; + }; + + // Workbench + Workbench: { + constructor: By; + notificationContainer: By; + notificationItem: By; + }; + Notification: { + message: By; + icon: By; + source: By; + progress: By; + dismiss: By; + expand: By; + actions: By; + action: By; + actionLabel: { + value: WebElementFunction; + }; + standalone: (id: string) => By; + standaloneContainer: By; + center: (index: number) => By; + buttonConstructor: (title: string) => By; + }; + NotificationsCenter: { + constructor: By; + close: By; + clear: By; + row: By; + }; + + // Inputs + Input: { + inputBox: By; + input: By; + quickPickIndex: (index: number) => By; + quickPickPosition: (index: number) => By; + quickPickLabel: By; + quickPickDescription: By; + quickPickSelectAll: By; + titleBar: By; + title: By; + backButton: By; + multiSelectIndex: (index: number) => By; + }; + InputBox: { + constructor: By; + message: By; + progress: By; + quickList: By; + rows: By; + row: By; + }; + QuickOpenBox: { + constructor: By; + progress: By; + quickList: By; + row: By; + }; + + // Dialogs + Dialog: { + constructor: By; + message: By; + details: By; + buttonContainer: By; + button: By; + closeButton: By; + buttonLabel: { + value: WebElementFunction; + }; + }; + + WelcomeContent: { + button: By; + text: By; + buttonOrText: By; + }; +} + +/** + * Definition for locator diff object + */ +export interface LocatorDiff { + locators: DeepPartial; + extras?: object; +} + +export function hasAttribute(attr: string, value?: string, locator?: By): (el: WebElement) => Promise { + return async (el: WebElement) => { + el = locator ? el.findElement(locator) : el; + const attrValue: string = await el.getAttribute(attr); + + if (value === undefined) { + return attrValue !== null; + } + + return attrValue === value; + }; +} + +export function hasClass( + classOrPredicate: string | ((klass: string) => boolean), + locator?: By, +): (el: WebElement) => Promise { + return async (el: WebElement) => { + el = locator ? el.findElement(locator) : el; + const klasses: string = await el.getAttribute("class"); + const segments: string[] = klasses?.split(/\s+/g); + + const predicate: (val: string) => boolean = + typeof classOrPredicate === "string" + ? (klass: string): boolean => klass === classOrPredicate + : classOrPredicate; + + return segments.find(predicate) !== undefined; + }; +} + +export function hasNotClass(klass: string, locator?: By): (el: WebElement) => Promise { + return async (el: WebElement) => { + el = locator ? el.findElement(locator) : el; + + return !(await hasClass(klass).call(undefined, el)); + }; +} + +export function hasElement( + locatorSelector: (l: Locators) => By, +): (el: WebElement, locators: Locators) => Promise { + return async (el: WebElement, locators: Locators) => (await el.findElements(locatorSelector(locators))).length > 0; +} + +export function fromAttribute(attribute: string, locator?: By): (el: WebElement) => Promise { + return async (el: WebElement) => { + el = locator ? el.findElement(locator) : el; + + return el.getAttribute(attribute); + }; +} + +export function fromText(locator?: By): (el: WebElement) => Promise { + return async (el: WebElement) => { + el = locator ? el.findElement(locator) : el; + + return el.getText(); + }; +} diff --git a/vscode-extension-tester/page-objects/tsconfig.json b/vscode-extension-tester/page-objects/tsconfig.json new file mode 100644 index 00000000..a5b15a8d --- /dev/null +++ b/vscode-extension-tester/page-objects/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "outDir": "out", + "lib": [ + "ES2022" + ], + "sourceMap": true, + "rootDir": "src", + "strict": true, + "noUnusedLocals": true, + "experimentalDecorators": true, + "resolveJsonModule": true, + "declaration": true, + "skipLibCheck": true + }, + "include": [ + "src" + ] +}